diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43f2dec --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# PROJECT +/go/mobile/lib/*.aar +/go/mobile/lib/*.jar +/go/mobile/lib/*.framework/ +/go/vendor/ diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..56bfc2c --- /dev/null +++ b/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f4abaa0735eba4dfd8f33f73363911d63931fe03 + channel: stable + +project_type: app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aeb2ac1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2021-2021 niuhuan + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index e69de29..b6d1b5e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,148 @@ +PIKAPI - 漫画客户端 +======== +[![license](https://img.shields.io/github/license/niuhuan/pikapi-flutter)](https://raw.githubusercontent.com/niuhuan/pikapi-flutter/master/LICENSE) +[![releases](https://img.shields.io/github/v/release/niuhuan/pikapi-flutter)](https://github.com/niuhuan/pikapi-flutter/releases) +[![downloads](https://img.shields.io/github/downloads/niuhuan/pikapi-flutter/total)](https://github.com/niuhuan/pikapi-flutter/releases) + +- 美观易用且无广告的漫画客户端, 能运行在Windows/MacOS/Linux/Android/IOS中。 +- 本仓库仅作为学习交流使用, 请您遵守当地法律法规以及开源协议。 +- 您的star和issue是对开发者的莫大鼓励, 可以源仓库下载最新的源码/安装包, 表示支持/提出建议。 +- 源仓库地址 [https://github.com/niuhuan/pikapi-flutter](https://github.com/niuhuan/pikapi-flutter) +- 此项目仅接受简体中文的issues。 + +## 界面 / 功能 + +![阅读器](images/reader.png) + +### 分流 + +VPN->代理->分流, 这三个功能如果同时设置, 您会在您手机的VPN上访问代理, 使用代理请求分流服务器。 + +### 漫画分类/搜索 + +![分类](images/categories_screen.png) ![列表](images/comic_list.png) + +### 漫画阅读/下载/导入/导出 + +您可以在除IOS外导出任意已经完成的下载到zip, 从另外一台设备导入。 导出的zip解压后可以直接使用其中的HTML进行阅读 + +![导出下载](images/exporting.png) + +![HTML预览](images/exporting2.png) + +### 游戏 + +![games](images/games.png) +![game](images/game.png) + +## 特性 + +- [x] 用户 + - [x] 登录 / 注册 / 获取个人信息 / 自动打卡 +- [x] 漫画 + - [x] 分类 / 搜索 / 随机本子 / 看此本子的也在看 / 排行榜 + - [x] 在分类中搜索 / 按 "分类 / 标签 / 创建人 / 汉化组" 检索 + - [x] 漫画详情 / 章节 / 看图 / 将图片保存到相册 + - [x] 收藏 / 喜欢 + - [x] 获取评论 / 评论 / 评论回复 (社区评论后无法删除, 请谨慎使用) +- [x] 游戏 + - [x] 列表 / 详情 / 无广告下载 +- [x] 下载 + - [x] 导入导出 / 无线共享 / 移动设备与PC设备传输 +- [ ] 聊天室 +- [x] 缓存 / 清理 +- [x] 设备支持 + - [x] 安卓 + - [x] 高刷新频率屏幕适配 (90/120/144... Hz) + - [x] 安卓10以上随系统进入深色/夜间模式 + +## 其他说明 + +- 在ios/android环境 数据文件将会保存在程序自身数据目录中, 删除就会清理 +- 在 windows 数据文件将会保存在程序同一目录 +- 在 macos 数据文件将会"~/Library/Application Support/pikapi" +- 在 linux 数据文件将会"~/.pikapi" + +## 运行 / 构建 + +这个应用程序使用golang和dart(flutter)作为主要语言, 可以兼容Windows, linux, MacOS, Android, IOS + +使用了不同的框架桥接到桌面和移动平台上 + +- go-flutter => Windows / MacOS / Linux +- gomobile => Android / IOS + +![平台](images/platforms.png) + +### 开发环境准备 + +- [golang](https://golang.org/) (1.16以上版本) +- [flutter](https://flutter.dev/) (桌面端 Tag 2.2.3 以兼容hover) + +### 环境配置 + +- 将~/go/bin (GoPath/bin) 设置到PATH环境变量内 +- golang开启模块化 +- 设置GoProxy (可选,在中国大陆网络建议设置) +- 参考地址 [https://goproxy.cn/](https://goproxy.cn/) + +### 桌面平台 (go-flutter) + +- [安装hover(go-flutter编译脚手架)](https://github.com/go-flutter-desktop/hover) + ```shell + GO111MODULE=on go get -u -a github.com/go-flutter-desktop/hover + ``` +- 执行编译命令 ($system替换为windows/darwin等) + ```shell + hover run + hover build $system + ``` + +### Linux的附加说明 + +- linux编译可能会遇到的问题 + ```shell + # No package 'gl' found + sudo apt install libgl1-mesa-dev + # X11/Xlib.h: No such file or directory + # 或者更多x11的头找不到等 + sudo apt install xorg-dev + ``` +- 字体不显示 + 1. 将字体文件复制到项目目录下 + ```shell + cp /usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf fonts/ + ``` + 2. 设置flutter打包的字体 + ```yaml + fonts: + - family: Roboto + fonts: + - asset: fonts/DroidSansFallbackFull.ttf + ``` + +### 移动端 (gomobile) + +- [安装gomobile](https://github.com/golang/mobile) + ```shell + go install golang.org/x/mobile/cmd/gomobile@latest + go get golang.org/x/mobile/cmd/gobind + ``` +- 执行编译命令 (bind-android.sh/bind-ios.sh根据平台选择, $system替换为apk/ipa等) + ```shell + cd go/mobile + sh bind-ios.sh + sh bind-android.sh + cd ../../ + flutter build $system + ``` + +## 请您遵守使用规则 + +本软件或本软件的拓展, 个人或企业不可用于商业用途, 不可上架任何商店 + +拓展包括但是不限于以下内容 + +- 使用本软件进行继续开发形成的软件。 +- 引入本软件部分内容为依赖/参考本软件/使用本软件内代码的同时, 包含本软件内一致内容或功能的软件。 +- 直接对本软件进行打包发布 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0a741cb --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..3cf3bfa --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,64 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +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" + +android { + compileSdkVersion 30 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "niuhuan.pikapi" + minSdkVersion 16 + targetSdkVersion 30 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + ndk { + abiFilters 'armeabi-v7a' + } + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' + implementation fileTree(dir: "../../go/mobile/lib", include: ["*.jar", "*.aar"]) +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..d811c4f --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..dcd3814 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt b/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt new file mode 100644 index 0000000..444d785 --- /dev/null +++ b/android/app/src/main/kotlin/niuhuan/pikapi/MainActivity.kt @@ -0,0 +1,278 @@ +package niuhuan.pikapi + +import android.content.ContentValues +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.view.Display +import android.view.KeyEvent +import androidx.annotation.NonNull +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.sync.Mutex +import mobile.Mobile +import java.util.concurrent.Executors + +class MainActivity : FlutterActivity() { + + // 为什么换成换成线程池而不继续使用携程 : 下载图片速度慢会占满携程造成拥堵, 接口无法请求 + private val pool = Executors.newCachedThreadPool { runnable -> + Thread(runnable).also { it.isDaemon = true } + } + private val uiThreadHandler = Handler(Looper.getMainLooper()) + private val scope = CoroutineScope(newSingleThreadContext("worker-scope")) + + private val notImplementedToken = Any() + private fun MethodChannel.Result.withCoroutine(exec: () -> Any?) { + pool.submit { + try { + val data = exec() + uiThreadHandler.post { + when (data) { + notImplementedToken -> { + notImplemented() + } + is Unit, null -> { + success(null) + } + else -> { + success(data) + } + } + } + } catch (e: Exception) { + uiThreadHandler.post { + error("", e.message, "") + } + } + + } + } + + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + Mobile.initApplication(context!!.filesDir.absolutePath) + // Method Channel + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "method").setMethodCallHandler { call, result -> + result.withCoroutine { + when (call.method) { + "flatInvoke" -> { + Mobile.flatInvoke( + call.argument("method")!!, + call.argument("params")!! + ) + } + "androidSaveFileToImage" -> { + saveImage(call.argument("path")!!) + } + "androidGetModes" -> { + modes() + } + "androidSetMode" -> { + setMode(call.argument("mode")!!) + } + "androidGetUiMode" -> { + uiMode() + } + "androidGetVersion" -> Build.VERSION.SDK_INT + else -> { + notImplementedToken + } + } + } + } + + // + val eventMutex = Mutex() + var eventSink: EventChannel.EventSink? = null + EventChannel(flutterEngine.dartExecutor.binaryMessenger, "flatEvent") + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + events?.let { events -> + scope.launch { + eventMutex.lock() + eventSink = events + eventMutex.unlock() + } + } + } + + override fun onCancel(arguments: Any?) { + scope.launch { + eventMutex.lock() + eventSink = null + eventMutex.unlock() + } + } + }) + Mobile.eventNotify { message -> + scope.launch { + eventMutex.lock() + try { + eventSink?.let { + uiThreadHandler.post { + it.success(message) + } + } + } finally { + eventMutex.unlock() + } + } + } + + // + EventChannel(flutterEngine.dartExecutor.binaryMessenger, "volume_button") + .setStreamHandler(volumeStreamHandler) + + // + EventChannel(flutterEngine.dartExecutor.binaryMessenger, "ui_mode") + .setStreamHandler(uiModeStreamHandler) + } + + // save_image + + private fun saveImage(path: String) { + BitmapFactory.decodeFile(path)?.let { bitmap -> + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, System.currentTimeMillis().toString()) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { //this one + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)?.let { uri -> + contentResolver.openOutputStream(uri)?.use { fos -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { //this one + contentValues.clear() + contentValues.put(MediaStore.Video.Media.IS_PENDING, 0) + contentResolver.update(uri, contentValues, null, null) + } + } + } + } + + // fps mods + private fun mixDisplay(): Display? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + display?.let { + return it + } + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + windowManager.defaultDisplay?.let { + return it + } + } + return null + } + + private fun modes(): List { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mixDisplay()?.let { display -> + return display.supportedModes.map { mode -> + mode.toString() + } + } + } + return ArrayList() + } + + private fun setMode(string: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mixDisplay()?.let { display -> + return display.supportedModes.forEach { mode -> + if (mode.toString() == string) { + uiThreadHandler.post { + window.attributes = window.attributes.also { attr -> + attr.preferredDisplayModeId = mode.modeId + } + } + return + } + } + } + } + } + + // volume_buttons + + private var volumeEvents: EventChannel.EventSink? = null + + private val volumeStreamHandler = object : EventChannel.StreamHandler { + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + volumeEvents = events + } + + override fun onCancel(arguments: Any?) { + volumeEvents = null + } + } + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + volumeEvents?.let { + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) { + uiThreadHandler.post { + it.success("DOWN") + } + return true + } + if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { + uiThreadHandler.post { + it.success("UP") + } + return true + } + } + return super.onKeyDown(keyCode, event) + } + + // ui_mode + + private var uiModeEvents: EventChannel.EventSink? = null + + private val uiModeStreamHandler = object : EventChannel.StreamHandler { + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + uiModeEvents = events + } + + override fun onCancel(arguments: Any?) { + uiModeEvents = null + } + } + + override fun onConfigurationChanged(newConfig: Configuration) { + when (newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> { + uiModeEvents?.let { it.success("NIGHT") } + } + Configuration.UI_MODE_NIGHT_NO -> { + uiModeEvents?.let { it.success("NORMAL") } + } + } + super.onConfigurationChanged(newConfig) + } + + private fun uiMode(): String { + return when (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_YES -> "NIGHT" + else -> "NORMAL" + } + } + + +} diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..f745851 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..cb7d6bc Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..ff94e25 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..da88d84 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..0ec2954 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..449a9f9 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..d74aa35 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..39cb93a --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..9b6ed06 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bc6a58a --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +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/go/.gitignore b/go/.gitignore new file mode 100644 index 0000000..fd0e2eb --- /dev/null +++ b/go/.gitignore @@ -0,0 +1,3 @@ +build +.last_goflutter_check +.last_go-flutter_check diff --git a/go/assets/icon.png b/go/assets/icon.png new file mode 100644 index 0000000..f14bdd1 Binary files /dev/null and b/go/assets/icon.png differ diff --git a/go/cmd/init.go b/go/cmd/init.go new file mode 100644 index 0000000..fddcc97 --- /dev/null +++ b/go/cmd/init.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "os" + "os/exec" + "path" + path2 "path" + "path/filepath" + "pgo/pikapi/config" + "runtime" + "strings" +) + +func init() { + applicationDir, err := os.UserHomeDir() + if err != nil { + panic(err) + } + switch runtime.GOOS { + case "windows": + // applicationDir = path.Join(applicationDir, "AppData", "Roaming") + file, err := exec.LookPath(os.Args[0]) + if err != nil { + panic(err) + } + path, err := filepath.Abs(file) + if err != nil { + panic(err) + } + i := strings.LastIndex(path, "/") + if i < 0 { + i = strings.LastIndex(path, "\\") + } + if i < 0 { + panic(errors.New(" can't find \"/\" or \"\\\"")) + } + applicationDir = path2.Join(path[0:i+1], "data", "pikapi") + case "darwin": + applicationDir = path.Join(applicationDir, "Library", "Application Support", "pikapi") + case "linux": + applicationDir = path.Join(applicationDir, ".pikapi") + default: + panic(errors.New("not supported system")) + } + if _, err = os.Stat(applicationDir); err != nil { + if os.IsNotExist(err) { + err = os.MkdirAll(applicationDir, os.FileMode(0700)) + if err != nil { + panic(err) + } + } else { + panic(err) + } + } + config.InitApplication(applicationDir) +} diff --git a/go/cmd/main.go b/go/cmd/main.go new file mode 100644 index 0000000..166c11c --- /dev/null +++ b/go/cmd/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + "github.com/go-flutter-desktop/go-flutter" + "github.com/pkg/errors" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "pgo/pikapi/database/properties" + "strconv" + "strings" +) + +// vmArguments may be set by hover at compile-time +var vmArguments string + +func main() { + // DO NOT EDIT, add options in options.go + mainOptions := []flutter.Option{ + flutter.OptionVMArguments(strings.Split(vmArguments, ";")), + flutter.WindowIcon(iconProvider), + } + // 窗口初始化大小的处理 + widthStr, _ := properties.LoadProperty("window_width", "600") + heightStr, _ := properties.LoadProperty("window_height", "900") + width, _ := strconv.Atoi(widthStr) + height, _ := strconv.Atoi(heightStr) + if width <= 0 { + width = 600 + } + if height <= 0 { + height = 900 + } + sizeOption := flutter.WindowInitialDimensions(width, height) + options = append(options, sizeOption) + // + err := flutter.Run(append(options, mainOptions...)...) + if err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func iconProvider() ([]image.Image, error) { + execPath, err := os.Executable() + if err != nil { + return nil, errors.Wrap(err, "failed to resolve executable path") + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return nil, errors.Wrap(err, "failed to eval symlinks for executable path") + } + imgFile, err := os.Open(filepath.Join(filepath.Dir(execPath), "assets", "icon.png")) + if err != nil { + return nil, errors.Wrap(err, "failed to open assets/icon.png") + } + img, _, err := image.Decode(imgFile) + if err != nil { + return nil, errors.Wrap(err, "failed to decode image") + } + return []image.Image{img}, nil +} diff --git a/go/cmd/options.go b/go/cmd/options.go new file mode 100644 index 0000000..a58f795 --- /dev/null +++ b/go/cmd/options.go @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/go-flutter-desktop/go-flutter" + "github.com/go-flutter-desktop/plugins/url_launcher" + "github.com/miguelpruivo/flutter_file_picker/go" + "pgo/cmd/plugin/pikapi" +) + +var options = []flutter.Option{ + flutter.AddPlugin(&pikapi.Plugin{}), + flutter.AddPlugin(&file_picker.FilePickerPlugin{}), + flutter.AddPlugin(&url_launcher.UrlLauncherPlugin{}), +} diff --git a/go/cmd/plugin/pikapi/plugin.go b/go/cmd/plugin/pikapi/plugin.go new file mode 100644 index 0000000..a09d1f6 --- /dev/null +++ b/go/cmd/plugin/pikapi/plugin.go @@ -0,0 +1,72 @@ +package pikapi + +import ( + "errors" + "github.com/go-flutter-desktop/go-flutter/plugin" + "github.com/go-gl/glfw/v3.3/glfw" + "pgo/pikapi/controller" + "pgo/pikapi/database/properties" + "strconv" + "sync" +) + +var eventMutex = sync.Mutex{} +var eventSink *plugin.EventSink + +type EventHandler struct { +} + +func (s *EventHandler) OnListen(arguments interface{}, sink *plugin.EventSink) { + eventMutex.Lock() + defer eventMutex.Unlock() + eventSink = sink +} + +func (s *EventHandler) OnCancel(arguments interface{}) { + eventMutex.Lock() + defer eventMutex.Unlock() + eventSink = nil +} + +const channelName = "method" + +type Plugin struct { +} + +func (p *Plugin) InitPlugin(messenger plugin.BinaryMessenger) error { + + channel := plugin.NewMethodChannel(messenger, channelName, plugin.StandardMethodCodec{}) + + channel.HandleFunc("flatInvoke", func(arguments interface{}) (interface{}, error) { + if argumentsMap, ok := arguments.(map[interface{}]interface{}); ok { + if method, ok := argumentsMap["method"].(string); ok { + if params, ok := argumentsMap["params"].(string); ok { + return controller.FlatInvoke(method, params) + } + } + } + return nil, errors.New("params error") + }) + + exporting := plugin.NewEventChannel(messenger, "flatEvent", plugin.StandardMethodCodec{}) + exporting.Handle(&EventHandler{}) + + controller.EventNotify = func(message string) { + eventMutex.Lock() + defer eventMutex.Unlock() + sink := eventSink + if sink != nil { + sink.Success(message) + } + } + + return nil // no error +} + +func (p *Plugin) InitPluginGLFW(window *glfw.Window) error { + window.SetSizeCallback(func(w *glfw.Window, width int, height int) { + properties.SaveProperty("window_width", strconv.Itoa(width)) + properties.SaveProperty("window_height", strconv.Itoa(height)) + }) + return nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..e9d7dfb --- /dev/null +++ b/go/go.mod @@ -0,0 +1,17 @@ +module pgo + +go 1.16 + +require ( + github.com/PuerkitoBio/goquery v1.7.1 + github.com/go-flutter-desktop/go-flutter v0.43.0 + github.com/go-flutter-desktop/plugins/url_launcher v0.1.2 + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20201108214237-06ea97f0c265 + github.com/miguelpruivo/flutter_file_picker/go v0.0.0-20210622152105-9f0a811028a0 + github.com/niuhuan/pica-go v0.0.0-20210923020558-090104e7b1a7 + github.com/pkg/errors v0.9.1 + golang.org/x/image v0.0.0-20190802002840-cff245a6509b + golang.org/x/sys v0.0.0-20210510120138-977fb7262007 // indirect + gorm.io/driver/sqlite v1.1.4 + gorm.io/gorm v1.21.12 +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..886738f --- /dev/null +++ b/go/go.sum @@ -0,0 +1,81 @@ +github.com/PuerkitoBio/goquery v1.7.1 h1:oE+T06D+1T7LNrn91B4aERsRIeCLJ/oPSa6xB9FPnz4= +github.com/PuerkitoBio/goquery v1.7.1/go.mod h1:XY0pP4kfraEmmV1O7Uf6XyjoslwsneBbgeDjLYuN8xY= +github.com/Xuanwo/go-locale v1.0.0 h1:oqC32Kyiu2XZq+fxtwEg0mWiv9WyDhyHu+sT5cDkgME= +github.com/Xuanwo/go-locale v1.0.0/go.mod h1:kB9tcLfr4Sp+ByIE9SE7vbUkXkGQqel2XH3EHpL0haA= +github.com/andybalholm/cascadia v1.2.0 h1:vuRCkM5Ozh/BfmsaTm26kbjm0mIOM3yS5Ek/F5h18aE= +github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxBp0T0eFw1RUQY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gen2brain/dlgs v0.0.0-20190708095831-3854608588f7 h1:qA8Mdjwrlv/r/aMqArqO0IMHUiy6ApdW4+8DtKr7PvA= +github.com/gen2brain/dlgs v0.0.0-20190708095831-3854608588f7/go.mod h1:/eFcjDXaU2THSOOqLxOPETIbHETnamk8FA/hMjhg/gU= +github.com/go-flutter-desktop/go-flutter v0.30.0/go.mod h1:NCryd/AqiRbYSd8pMzQldYkgH1tZIFGt2ToUghZcWGA= +github.com/go-flutter-desktop/go-flutter v0.43.0 h1:7tdUbGKmHwdsUnBfC/h7zAO3T67cAkKSCWi9ZDFg25A= +github.com/go-flutter-desktop/go-flutter v0.43.0/go.mod h1:GSCn6XOpB0cnYlK9/BdSwxi99t5YD1XEk0v4agI7SS4= +github.com/go-flutter-desktop/plugins/url_launcher v0.1.2 h1:oFiIJjotMQvF8rfKWVJrf+1/JgTXShEIsibkiXrQnUw= +github.com/go-flutter-desktop/plugins/url_launcher v0.1.2/go.mod h1:GYgRDaLDAJRYvaASQk8HEmI8YJurbZGW5VVDIMxwzBU= +github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 h1:SCYMcCJ89LjRGwEa0tRluNRiMjZHalQZrVrvTbPh+qw= +github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7/go.mod h1:482civXOzJJCPzJ4ZOX/pwvXBWSnzD4OKMdH4ClKGbk= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1 h1:QbL/5oDUmRBzO9/Z7Seo6zf912W/a6Sr4Eu0G/3Jho0= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20201108214237-06ea97f0c265 h1:BcbKYUZo/TKPsiSh7LymK3p+TNAJJW3OfGO/21sBbiA= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20201108214237-06ea97f0c265/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190915194858-d3ddacdb130f h1:TyqzGm2z1h3AGhjOoRYyeLcW4WlW81MDQkWa+rx/000= +github.com/gopherjs/gopherjs v0.0.0-20190915194858-d3ddacdb130f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.2 h1:eVKgfIdy9b6zbWBMgFpfDPoAMifwSZagU9HmEU6zgiI= +github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/mattn/go-sqlite3 v1.14.5 h1:1IdxlwTNazvbKJQSxoJ5/9ECbEeaTTyeU7sEAZ5KKTQ= +github.com/mattn/go-sqlite3 v1.14.5/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/miguelpruivo/flutter_file_picker/go v0.0.0-20210622152105-9f0a811028a0 h1:hXl9AMW20Php3xWlWZr2Acw50tqeblLgtLfLoRCACmA= +github.com/miguelpruivo/flutter_file_picker/go v0.0.0-20210622152105-9f0a811028a0/go.mod h1:csuW+TFyYKtiUwNvcvhcpyX4quPI7Pvv0SUogdqCW4I= +github.com/niuhuan/pica-go v0.0.0-20210923020558-090104e7b1a7 h1:E0WsH0UeFvuGiaEb1/tyy35ot76YDJKZ2q0/QjRQMWA= +github.com/niuhuan/pica-go v0.0.0-20210923020558-090104e7b1a7/go.mod h1:fx2m+OgMeEZf6/TrfblV9i85SjPsOGbnjIL2gohxP4M= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200802091954-4b90ce9b60b3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0vZtiN6xWYARwirrOSfE= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM= +gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw= +gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw= +gorm.io/gorm v1.21.12 h1:3fQM0Eiz7jcJEhPggHEpoYnsGZqynMzverL77DV40RM= +gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0= diff --git a/go/hover.yaml b/go/hover.yaml new file mode 100644 index 0000000..355dc91 --- /dev/null +++ b/go/hover.yaml @@ -0,0 +1,9 @@ +#application-name: "pikapi" # Uncomment to modify this value. +#executable-name: "pikapi" # Uncomment to modify this value. Only lowercase a-z, numbers, underscores and no spaces +#package-name: "pikapi" # Uncomment to modify this value. Only lowercase a-z, numbers and no underscores or spaces +organization-name: "com.pikapi" +license: "" # MANDATORY: Fill in your SPDX license name: https://spdx.org/licenses +target: lib/main_desktop.dart +# opengl: "none" # Uncomment this line if you have trouble with your OpenGL driver (https://github.com/go-flutter-desktop/go-flutter/issues/272) +docker: false +engine-version: "" # change to a engine version commit diff --git a/go/mobile/bind-android-debug.sh b/go/mobile/bind-android-debug.sh new file mode 100644 index 0000000..8bf267d --- /dev/null +++ b/go/mobile/bind-android-debug.sh @@ -0,0 +1 @@ +gomobile bind -target=android/arm,android/arm64,android/386 -o lib/Pikapi.aar ./ diff --git a/go/mobile/bind-android.sh b/go/mobile/bind-android.sh new file mode 100644 index 0000000..7efb1cb --- /dev/null +++ b/go/mobile/bind-android.sh @@ -0,0 +1 @@ +gomobile bind -target=android/arm -o lib/Pikapi.aar ./ diff --git a/go/mobile/bind-ios.sh b/go/mobile/bind-ios.sh new file mode 100644 index 0000000..3442a6e --- /dev/null +++ b/go/mobile/bind-ios.sh @@ -0,0 +1 @@ +gomobile bind -target=ios -o lib/Pikapi.framework ./ diff --git a/go/mobile/lib/.keep b/go/mobile/lib/.keep new file mode 100644 index 0000000..e69de29 diff --git a/go/mobile/mobile.go b/go/mobile/mobile.go new file mode 100644 index 0000000..96535bc --- /dev/null +++ b/go/mobile/mobile.go @@ -0,0 +1,22 @@ +package mobile + +import ( + "pgo/pikapi/config" + "pgo/pikapi/controller" +) + +func InitApplication(application string) { + config.InitApplication(application) +} + +func FlatInvoke(method string, params string) (string, error) { + return controller.FlatInvoke(method, params) +} + +func EventNotify(notify EventNotifyHandler) { + controller.EventNotify = notify.OnNotify +} + +type EventNotifyHandler interface { + OnNotify(message string) +} diff --git a/go/packaging/darwin-bundle/{{.applicationName}} {{.version}}.app/Contents/Info.plist.tmpl b/go/packaging/darwin-bundle/{{.applicationName}} {{.version}}.app/Contents/Info.plist.tmpl new file mode 100644 index 0000000..e49ccf2 --- /dev/null +++ b/go/packaging/darwin-bundle/{{.applicationName}} {{.version}}.app/Contents/Info.plist.tmpl @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + {{.executableName}} + CFBundleGetInfoString + {{.description}} + CFBundleIconFile + icon.icns + NSHighResolutionCapable + + CFBundleIdentifier + {{.organizationName}}.{{.packageName}} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLongVersionString + {{.version}} + CFBundleName + {{.applicationName}} + CFBundlePackageType + APPL + CFBundleShortVersionString + {{.version}} + CFBundleSignature + {{.organizationName}}.{{.packageName}} + CFBundleVersion + {{.version}} + CSResourcesFileMapped + + NSHumanReadableCopyright + + NSPrincipalClass + NSApplication + + diff --git a/go/packaging/linux-appimage/AppRun.tmpl b/go/packaging/linux-appimage/AppRun.tmpl new file mode 100644 index 0000000..9272c3d --- /dev/null +++ b/go/packaging/linux-appimage/AppRun.tmpl @@ -0,0 +1,3 @@ +#!/bin/sh +cd "$(dirname "$0")" +exec ./build/{{.executableName}} diff --git a/go/packaging/linux-appimage/{{.packageName}}.desktop.tmpl b/go/packaging/linux-appimage/{{.packageName}}.desktop.tmpl new file mode 100644 index 0000000..cd17a38 --- /dev/null +++ b/go/packaging/linux-appimage/{{.packageName}}.desktop.tmpl @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Terminal=false +Categories= +Comment={{.description}} +Name={{.applicationName}} +Icon={{.iconPath}} +Exec={{.executablePath}} diff --git a/go/pikapi/config/common.go b/go/pikapi/config/common.go new file mode 100644 index 0000000..2c8d1f1 --- /dev/null +++ b/go/pikapi/config/common.go @@ -0,0 +1,29 @@ +package config + +import ( + "path" + "pgo/pikapi/controller" + "pgo/pikapi/database/comic_center" + "pgo/pikapi/database/network_cache" + "pgo/pikapi/database/properties" + "pgo/pikapi/utils" +) + +// InitApplication 初始化文件保存的位置 +func InitApplication(applicationDir string) { + println("初始化 : " + applicationDir) + var databasesDir, remoteDir, downloadDir, tmpDir string + databasesDir = path.Join(applicationDir, "databases") + remoteDir = path.Join(applicationDir, "pictures", "remote") + downloadDir = path.Join(applicationDir, "download") + tmpDir = path.Join(applicationDir, "download") + utils.Mkdir(databasesDir) + utils.Mkdir(remoteDir) + utils.Mkdir(downloadDir) + utils.Mkdir(tmpDir) + properties.InitDBConnect(databasesDir) + network_cache.InitDBConnect(databasesDir) + comic_center.InitDBConnect(databasesDir) + controller.InitClient() + controller.InitPlugin(remoteDir, downloadDir, tmpDir) +} diff --git a/go/pikapi/const_value/common.go b/go/pikapi/const_value/common.go new file mode 100644 index 0000000..58db97d --- /dev/null +++ b/go/pikapi/const_value/common.go @@ -0,0 +1,16 @@ +package const_value + +import ( + "gorm.io/gorm" + "gorm.io/gorm/logger" + "os" +) + +var ( + CreateDirMode = os.FileMode(0700) + CreateFileMode = os.FileMode(0600) + GormConfig = &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), + } +) + diff --git a/go/pikapi/controller/client.go b/go/pikapi/controller/client.go new file mode 100644 index 0000000..2ad40e1 --- /dev/null +++ b/go/pikapi/controller/client.go @@ -0,0 +1,459 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + source "github.com/niuhuan/pica-go" + "net" + "net/http" + "net/url" + "pgo/pikapi/database/comic_center" + "pgo/pikapi/database/network_cache" + "pgo/pikapi/database/properties" + "regexp" + "strconv" + "strings" + "time" +) + +func InitClient() { + client.Timeout = time.Second * 60 + switchAddress, _ = properties.LoadSwitchAddress() + proxy, _ := properties.LoadProxy() + changeProxyUrl(proxy) +} + +var client = source.Client{} +var dialer = &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, +} + +// SwitchAddress +// addr = "172.67.7.24:443" +// addr = "104.20.180.50:443" +// addr = "172.67.208.169:443" +var switchAddress = "" +var switchAddressPattern, _ = regexp.Compile("^.+picacomic\\.com:\\d+$") + +func switchAddressContext(ctx context.Context, network, addr string) (net.Conn, error) { + if switchAddressPattern.MatchString(addr) && switchAddress != "" { + addr = switchAddress + } + return dialer.DialContext(ctx, network, addr) +} + +func changeProxyUrl(urlStr string) bool { + if urlStr == "" { + client.Transport = &http.Transport{ + TLSHandshakeTimeout: time.Second * 10, + ExpectContinueTimeout: time.Second * 10, + ResponseHeaderTimeout: time.Second * 10, + IdleConnTimeout: time.Second * 10, + DialContext: switchAddressContext, + } + return false + } + client.Transport = &http.Transport{ + Proxy: func(_ *http.Request) (*url.URL, error) { + return url.Parse(urlStr) + }, + TLSHandshakeTimeout: time.Second * 10, + ExpectContinueTimeout: time.Second * 10, + ResponseHeaderTimeout: time.Second * 10, + IdleConnTimeout: time.Second * 10, + DialContext: switchAddressContext, + } + return true +} + +func cacheable(key string, expire time.Duration, reload func() (interface{}, error)) (string, error) { + // CACHE + cache := network_cache.LoadCache(key, expire) + if cache != "" { + return cache, nil + } + // obj + obj, err := reload() + if err != nil { + return "", err + } + buff, err := json.Marshal(obj) + // push to cache + if err != nil { + return "", err + } + // return + cache = string(buff) + network_cache.SaveCache(key, cache) + return cache, nil +} + +func categories() (string, error) { + key := "CATEGORIES" + expire := time.Hour * 3 + cache := network_cache.LoadCache(key, expire) + if cache != "" { + return cache, nil + } + categories, err := client.Categories() + if err != nil { + return "", err + } + var dbCategories []comic_center.Category + for _, c := range categories { + dbCategories = append(dbCategories, comic_center.Category{ + ID: c.Id, + Title: c.Title, + Description: c.Description, + IsWeb: c.IsWeb, + Active: c.Active, + Link: c.Link, + ThumbOriginalName: c.Thumb.OriginalName, + ThumbFileServer: c.Thumb.FileServer, + ThumbPath: c.Thumb.Path, + }) + } + err = comic_center.UpSetCategories(&dbCategories) + if err != nil { + return "", err + } + buff, _ := json.Marshal(&categories) + cache = string(buff) + network_cache.SaveCache(key, cache) + return cache, nil +} + +func comics(params string) (string, error) { + var paramsStruct struct { + Category string `json:"category"` + Tag string `json:"tag"` + CreatorId string `json:"creatorId"` + ChineseTeam string `json:"chineseTeam"` + Sort string `json:"sort"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + return cacheable( + fmt.Sprintf("COMICS$%s$%s$%s$%s$%s$%d", paramsStruct.Category, paramsStruct.Tag, paramsStruct.CreatorId, paramsStruct.ChineseTeam, paramsStruct.Sort, paramsStruct.Page), + time.Hour*2, + func() (interface{}, error) { + return client.Comics(paramsStruct.Category, paramsStruct.Tag, paramsStruct.CreatorId, paramsStruct.ChineseTeam, paramsStruct.Sort, paramsStruct.Page) + }, + ) +} + +func searchComics(params string) (string, error) { + var paramsStruct struct { + Categories []string `json:"categories"` + Keyword string `json:"keyword"` + Sort string `json:"sort"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + categories := paramsStruct.Categories + keyword := paramsStruct.Keyword + sort := paramsStruct.Sort + page := paramsStruct.Page + // + var categoriesInKey string + if len(categories) == 0 { + categoriesInKey = "" + } else { + b, _ := json.Marshal(categories) + categoriesInKey = string(b) + } + return cacheable( + fmt.Sprintf("SEARCH$%s$%s$%s$%d", categoriesInKey, keyword, sort, page), + time.Hour*2, + func() (interface{}, error) { + return client.SearchComics(categories, keyword, sort, page) + }, + ) +} + +func randomComics() (string, error) { + return cacheable( + fmt.Sprintf("RANDOM"), + time.Millisecond*1, + func() (interface{}, error) { + return client.RandomComics() + }, + ) +} + +func leaderboard(typeName string) (string, error) { + return cacheable( + fmt.Sprintf("LEADERBOARD$%s", typeName), + time.Second*200, + func() (interface{}, error) { + return client.Leaderboard(typeName) + }, + ) +} + +func comicInfo(comicId string) (string, error) { + var err error + var comic *source.ComicInfo + // cache + key := fmt.Sprintf("COMIC_INFO$%s", comicId) + expire := time.Hour * 24 * 7 + cache := network_cache.LoadCache(key, expire) + if cache != "" { + var co source.ComicInfo + err = json.Unmarshal([]byte(cache), &co) + if err != nil { + panic(err) + return "", err + } + comic = &co + } else { + // get + comic, err = client.ComicInfo(comicId) + if err != nil { + return "", err + } + var buff []byte + buff, err = json.Marshal(comic) + if err != nil { + return "", err + } + cache = string(buff) + network_cache.SaveCache(key, cache) + } + // 标记历史记录 + view := comic_center.ComicView{} + view.ID = comicId + view.CreatedAt = comic.CreatedAt + view.UpdatedAt = comic.UpdatedAt + view.Title = comic.Title + view.Author = comic.Author + view.PagesCount = int32(comic.PagesCount) + view.EpsCount = int32(comic.EpsCount) + view.Finished = comic.Finished + c, _ := json.Marshal(comic.Categories) + view.Categories = string(c) + view.ThumbOriginalName = comic.Thumb.OriginalName + view.ThumbFileServer = comic.Thumb.FileServer + view.ThumbPath = comic.Thumb.Path + view.LikesCount = int32(comic.LikesCount) + view.Description = comic.Description + view.ChineseTeam = comic.ChineseTeam + t, _ := json.Marshal(comic.Tags) + view.Tags = string(t) + view.AllowDownload = comic.AllowDownload + view.ViewsCount = int32(comic.ViewsCount) + view.IsFavourite = comic.IsFavourite + view.IsLiked = comic.IsLiked + view.CommentsCount = int32(comic.CommentsCount) + err = comic_center.ViewComicUpdateInfo(&view) + if err != nil { + return "", err + } + // return + return cache, nil +} + +func ComicInfoCleanCache(comicId string) { + key := fmt.Sprintf("COMIC_INFO$%s", comicId) + network_cache.RemoveCache(key) +} + +func epPage(params string) (string, error) { + var paramsStruct struct { + ComicId string `json:"comicId"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comicId := paramsStruct.ComicId + page := paramsStruct.Page + // + return cacheable( + fmt.Sprintf("COMIC_EP_PAGE$%s$%d", comicId, page), + time.Hour*2, + func() (interface{}, error) { + return client.ComicEpPage(comicId, page) + }, + ) +} + +func comicPicturePageWithQuality(params string) (string, error) { + var paramsStruct struct { + ComicId string `json:"comicId"` + EpOrder int `json:"epOrder"` + Page int `json:"page"` + Quality string `json:"quality"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comicId := paramsStruct.ComicId + epOrder := paramsStruct.EpOrder + page := paramsStruct.Page + quality := paramsStruct.Quality + // + return cacheable( + fmt.Sprintf("COMIC_EP_PAGE$%s$%ds$%ds$%s", comicId, epOrder, page, quality), + time.Hour*2, + func() (interface{}, error) { + return client.ComicPicturePageWithQuality(comicId, epOrder, page, quality) + }, + ) +} + +func switchLike(comicId string) (string, error) { + point, err := client.SwitchLike(comicId) + if err != nil { + return "", err + } + // 更新viewLog里面的favour + comic_center.ViewComicUpdateLike(comicId, strings.HasPrefix(*point, "un")) + // 删除缓存 + ComicInfoCleanCache(comicId) + return *point, nil +} + +func switchFavourite(comicId string) (string, error) { + point, err := client.SwitchFavourite(comicId) + if err != nil { + return "", err + } + // 更新viewLog里面的favour + comic_center.ViewComicUpdateFavourite(comicId, strings.HasPrefix(*point, "un")) + // 删除缓存 + ComicInfoCleanCache(comicId) + return *point, nil +} + +func favouriteComics(params string) (string, error) { + var paramsStruct struct { + Sort string `json:"sort"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + sort := paramsStruct.Sort + page := paramsStruct.Page + // + point, err := client.FavouriteComics(sort, page) + if err != nil { + return "", err + } + str, err := json.Marshal(point) + if err != nil { + return "", err + } + return string(str), nil +} + +func recommendation(comicId string) (string, error) { + return cacheable( + fmt.Sprintf("RECOMMENDATION$%s", comicId), + time.Hour*2, + func() (interface{}, error) { + return client.ComicRecommendation(comicId) + }, + ) +} + +func comments(params string) (string, error) { + var paramsStruct struct { + ComicId string `json:"comicId"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comicId := paramsStruct.ComicId + page := paramsStruct.Page + return cacheable( + fmt.Sprintf("COMMENTS$%s$%d", comicId, page), + time.Hour*2, + func() (interface{}, error) { + return client.ComicCommentsPage(comicId, page) + }, + ) +} + +func commentChildren(params string) (string, error) { + var paramsStruct struct { + CommentId string `json:"commentId"` + Page int `json:"page"` + } + json.Unmarshal([]byte(params), ¶msStruct) + commentId := paramsStruct.CommentId + page := paramsStruct.Page + return cacheable( + fmt.Sprintf("COMMENT_CHILDREN$%s$%d", commentId, page), + time.Hour*2, + func() (interface{}, error) { + return client.CommentChildren(commentId, page) + }, + ) +} + +func postComment(params string) (string, error) { + var paramsStruct struct { + ComicId string `json:"comicId"` + Content string `json:"content"` + } + json.Unmarshal([]byte(params), ¶msStruct) + err := client.PostComment(paramsStruct.ComicId, paramsStruct.Content) + if err != nil { + return "", err + } + network_cache.RemoveCaches("MY_COMMENTS$%") + network_cache.RemoveCaches(fmt.Sprintf("COMMENTS$%s$%%", paramsStruct.ComicId)) + return "", nil +} + +func postChildComment(params string) (string, error) { + var paramsStruct struct { + ComicId string `json:"comicId"` + CommentId string `json:"commentId"` + Content string `json:"content"` + } + json.Unmarshal([]byte(params), ¶msStruct) + err := client.PostChildComment(paramsStruct.CommentId, paramsStruct.Content) + if err != nil { + return "", err + } + network_cache.RemoveCaches(fmt.Sprintf("COMMENT_CHILDREN$%s$%%", paramsStruct.CommentId)) + network_cache.RemoveCaches("MY_COMMENTS$%") + network_cache.RemoveCaches(fmt.Sprintf("COMMENTS$%s$%%", paramsStruct.ComicId)) + return "", nil +} + +func myComments(pageStr string) (string, error) { + page, err := strconv.Atoi(pageStr) + if err != nil { + return "", err + } + return cacheable( + fmt.Sprintf("MY_COMMENTS$%d", page), + time.Hour*2, + func() (interface{}, error) { + return client.MyComments(page) + }, + ) +} + +func games(pageStr string) (string, error) { + page, err := strconv.Atoi(pageStr) + if err != nil { + return "", err + } + return cacheable( + fmt.Sprintf("GAMES$%d", page), + time.Hour*2, + func() (interface{}, error) { + return client.GamePage(page) + }, + ) +} + +func game(gameId string) (string, error) { + return cacheable( + fmt.Sprintf("GAME$%s", gameId), + time.Hour*2, + func() (interface{}, error) { + return client.GameInfo(gameId) + }, + ) +} diff --git a/go/pikapi/controller/common.go b/go/pikapi/controller/common.go new file mode 100644 index 0000000..932c5a6 --- /dev/null +++ b/go/pikapi/controller/common.go @@ -0,0 +1,45 @@ +package controller + +import ( + "encoding/json" + "pgo/pikapi/database/comic_center" +) + +var EventNotify func(message string) + +func onEvent(function string, content string) { + event := EventNotify + if event != nil { + message := map[string]string{ + "function": function, + "content": content, + } + buff, err := json.Marshal(message) + if err == nil { + event(string(buff)) + } else { + print("SEND ERR?") + } + } +} + +func downloadComicEventSend(comicDownload *comic_center.ComicDownload) { + buff, err := json.Marshal(comicDownload) + if err == nil { + onEvent("DOWNLOAD", string(buff)) + } else { + print("SEND ERR?") + } +} + +func notifyExport(str string) { + onEvent("EXPORT", str) +} + +func serialize(point interface{}, err error) (string, error) { + if err != nil { + return "", err + } + buff, err := json.Marshal(point) + return string(buff), nil +} diff --git a/go/pikapi/controller/download.go b/go/pikapi/controller/download.go new file mode 100644 index 0000000..feb6e54 --- /dev/null +++ b/go/pikapi/controller/download.go @@ -0,0 +1,307 @@ +package controller + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "pgo/pikapi/const_value" + "pgo/pikapi/database/comic_center" + utils2 "pgo/pikapi/utils" + "time" +) + +var downloadRunning = false +var downloadRestart = false + +var downloadingComic *comic_center.ComicDownload +var downloadingEp *comic_center.ComicDownloadEp +var downloadingPicture *comic_center.ComicDownloadPicture + +func downloadBackground() { + println("后台线程启动") + go downloadBegin() +} + +func downloadBegin() { + time.Sleep(time.Second * 3) + go downloadLoadComic() +} + +func downloadHasStop() bool { + if !downloadRunning { + go downloadBegin() + return true + } + if downloadRestart { + downloadRestart = false + go downloadBegin() + return true + } + return false +} + +func downloadDelete() bool { + c, e := comic_center.DeletingComic() + if e != nil { + panic(e) + } + if c != nil { + os.RemoveAll(downloadPath(c.ID)) + e = comic_center.TrueDelete(c.ID) + if e != nil { + panic(e) + } + return true + } + return false +} + +func downloadLoadComic() { + for downloadDelete() { + } + if downloadHasStop() { + return + } + var err error + downloadingComic, err = comic_center.LoadFirstNeedDownload() + // 查库有错误就停止 + if err != nil { + panic(err) + } + go downloadInitComic() +} + +func downloadInitComic() { + if downloadHasStop() { + return + } + if downloadingComic == nil { + println("没有找到要下载的漫画") + go downloadBegin() + return + } + println("正在下载漫画 " + downloadingComic.Title) + downloadComicEventSend(downloadingComic) + eps, err := comic_center.ListDownloadEpByComicId(downloadingComic.ID) + if err != nil { + panic(err) + } + for _, ep := range eps { + if !ep.FetchedPictures { + println("正在获取章节的图片 " + downloadingComic.Title + " " + ep.Title) + for i := 0; i < 5; i++ { + if client.Token == "" { + continue + } + err := downloadFetchPictures(&ep) + if err != nil { + println(err.Error()) + continue + } + ep.FetchedPictures = true + break + } + if !ep.FetchedPictures { + println("章节的图片获取失败 " + downloadingComic.Title + " " + ep.Title) + err = comic_center.EpFailed(ep.ID) + if err != nil { + panic(err) + } + } else { + println("章节的图片获取成功 " + downloadingComic.Title + " " + ep.Title) + downloadingComic.SelectedPictureCount = downloadingComic.SelectedPictureCount + ep.SelectedPictureCount + downloadComicEventSend(downloadingComic) + } + } + } + go downloadLoadEp() +} + +func downloadFetchPictures(downloadEp *comic_center.ComicDownloadEp) error { + var list []comic_center.ComicDownloadPicture + page := 1 + for true { + rsp, err := client.ComicPicturePage(downloadingComic.ID, int(downloadEp.EpOrder), page) + if err != nil { + return err + } + for _, doc := range rsp.Docs { + list = append(list, comic_center.ComicDownloadPicture{ + ID: doc.Id, + ComicId: downloadEp.ComicId, + EpId: downloadEp.ID, + EpOrder: downloadEp.EpOrder, + OriginalName: doc.Media.OriginalName, + FileServer: doc.Media.FileServer, + Path: doc.Media.Path, + }) + } + if rsp.Page.Page < rsp.Page.Pages { + page++ + continue + } + break + } + err := comic_center.FetchPictures(downloadEp.ComicId, downloadEp.ID, &list) + if err != nil { + panic(err) + } + downloadEp.SelectedPictureCount = int32(len(list)) + return err +} + +func downloadLoadEp() { + if downloadHasStop() { + return + } + var err error + downloadingEp, err = comic_center.LoadFirstNeedDownloadEp(downloadingComic.ID) + if err != nil { + panic(err) + } + go downloadInitEp() +} + +func downloadInitEp() { + if downloadingEp == nil { + // 所有Ep都下完了, 汇总Download下载情况 + go downloadSummaryDownload() + return + } + println("正在下载章节 " + downloadingEp.Title) + go downloadLoadPicture() +} + +func downloadSummaryDownload() { + if downloadHasStop() { + return + } + list, err := comic_center.ListDownloadEpByComicId(downloadingComic.ID) + if err != nil { + panic(err) + } + over := true + for _, downloadEp := range list { + over = over && downloadEp.DownloadFinished + } + if over { + err = comic_center.DownloadSuccess(downloadingComic.ID) + if err != nil { + panic(err) + } + downloadingComic.DownloadFinished = true + downloadingComic.DownloadFinishedTime = time.Now() + } else { + err = comic_center.DownloadFailed(downloadingComic.ID) + if err != nil { + panic(err) + } + downloadingComic.DownloadFailed = true + } + downloadComicEventSend(downloadingComic) + go downloadLoadComic() +} + +func downloadLoadPicture() { + if downloadHasStop() { + return + } + var err error + downloadingPicture, err = comic_center.LoadFirstNeedDownloadPicture(downloadingEp.ID) + if err != nil { + panic(err) + } + go downloadInitPicture() +} + +func downloadInitPicture() { + if downloadHasStop() { + return + } + if downloadingPicture == nil { + // 所有图片都下完了, 汇总EP下载情况 + go downloadSummaryEp() + return + } + println("正在下载图片 " + fmt.Sprintf("%d", downloadingPicture.RankInEp)) + for i := 0; i < 5; i++ { + err := downloadThePicture(downloadingPicture) + if err != nil { + continue + } + downloadingPicture.DownloadFinished = true + downloadingEp.DownloadPictureCount = downloadingEp.DownloadPictureCount + 1 + downloadingComic.DownloadPictureCount = downloadingComic.DownloadPictureCount + 1 + downloadComicEventSend(downloadingComic) + break + } + if !downloadingPicture.DownloadFinished { + err := comic_center.PictureFailed(downloadingPicture.ID) + if err != nil { + panic(err) + } + } + go downloadLoadPicture() +} + +func downloadThePicture(picturePoint *comic_center.ComicDownloadPicture) error { + lock := utils2.HashLock(fmt.Sprintf("%s$%s", picturePoint.FileServer, picturePoint.Path)) + lock.Lock() + defer lock.Unlock() + picturePath := fmt.Sprintf("%s/%d/%d", picturePoint.ComicId, picturePoint.EpOrder, picturePoint.RankInEp) + realPath := downloadPath(picturePath) + // 从缓存 + buff, img, format, err := decodeFromCache(picturePoint.FileServer, picturePoint.Path) + if err != nil { + // 从网络 + buff, img, format, err = decodeFromUrl(picturePoint.FileServer, picturePoint.Path) + } + if err != nil { + return err + } + dir := filepath.Dir(realPath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.Mkdir(dir, const_value.CreateDirMode) + } + err = ioutil.WriteFile(downloadPath(picturePath), buff, const_value.CreateFileMode) + if err != nil { + return err + } + return comic_center.PictureSuccess( + picturePoint.ComicId, + picturePoint.EpId, + picturePoint.ID, + int64(len(buff)), + format, + int32(img.Bounds().Dx()), + int32(img.Bounds().Dy()), + picturePath, + ) +} + +func downloadSummaryEp() { + if downloadHasStop() { + return + } + list, err := comic_center.ListDownloadPictureByEpId(downloadingEp.ID) + if err != nil { + panic(err) + } + over := true + for _, downloadPicture := range list { + over = over && downloadPicture.DownloadFinished + } + if over { + err = comic_center.EpSuccess(downloadingEp.ComicId, downloadingEp.ID) + if err != nil { + panic(err) + } + } else { + err = comic_center.EpFailed(downloadingEp.ID) + if err != nil { + panic(err) + } + } + go downloadLoadEp() +} diff --git a/go/pikapi/controller/export.go b/go/pikapi/controller/export.go new file mode 100644 index 0000000..eff5a46 --- /dev/null +++ b/go/pikapi/controller/export.go @@ -0,0 +1,475 @@ +package controller + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "os" + "path" + "pgo/pikapi/const_value" + "pgo/pikapi/database/comic_center" + "time" +) + +var exportingListener net.Listener +var exportingConn net.Conn + +func exportComicUsingSocket(comicId string) (int, error) { + var err error + exportingListener, err = net.Listen("tcp", ":0") + if err != nil { + return 0, err + } + go handleExportingConn(comicId) + return exportingListener.Addr().(*net.TCPAddr).Port, nil +} + +func handleExportingConn(comicId string) { + defer exportingListener.Close() + var err error + exportingConn, err = exportingListener.Accept() + if err != nil { + notifyExport(fmt.Sprintf("导出失败")) + println(err.Error()) + return + } + defer exportingConn.Close() + gw := gzip.NewWriter(exportingConn) + defer gw.Close() + tw := tar.NewWriter(gw) + defer tw.Close() + err = exportComicDownloadFetch(comicId, func(path string, size int64) (io.Writer, error) { + header := tar.Header{} + header.Name = path + header.Size = size + return tw, tw.WriteHeader(&header) + }) + if err != nil { + notifyExport(fmt.Sprintf("导出失败")) + } else { + notifyExport(fmt.Sprintf("导出成功")) + } +} + +func exportComicUsingSocketExit() error { + if exportingConn != nil { + exportingConn.Close() + } + if exportingListener != nil { + exportingListener.Close() + } + return nil +} + +func exportComicDownload(params string) error { + var paramsStruct struct { + ComicId string `json:"comicId"` + Dir string `json:"dir"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comicId := paramsStruct.ComicId + dir := paramsStruct.Dir + println(fmt.Sprintf("导出 %s 到 %s", comicId, dir)) + comic, err := comic_center.FindComicDownloadById(comicId) + if err != nil { + return err + } + if comic == nil { + return errors.New("not found") + } + if !comic.DownloadFinished { + return errors.New("not download finish") + } + filePath := path.Join(dir, fmt.Sprintf("%s-%s.zip", comic.Title, time.Now().Format("2006_01_02_15_04_05.999"))) + println(fmt.Sprintf("ZIP : %s", filePath)) + fileStream, err := os.Create(filePath) + if err != nil { + return err + } + defer fileStream.Close() + zipWriter := zip.NewWriter(fileStream) + defer zipWriter.Close() + return exportComicDownloadFetch(comicId, func(path string, size int64) (io.Writer, error) { + header := tar.Header{} + header.Name = path + header.Size = size + return zipWriter.Create(path) + }) +} + +func exportComicDownloadFetch(comicId string, onWriteFile func(path string, size int64) (io.Writer, error)) error { + comic, err := comic_center.FindComicDownloadById(comicId) + if err != nil { + return err + } + if comic == nil { + return errors.New("not found") + } + if !comic.DownloadFinished { + return errors.New("not download finish") + } + epList, err := comic_center.ListDownloadEpByComicId(comicId) + if err != nil { + return err + } + jsonComic := JsonComicDownload{} + jsonComic.ComicDownload = *comic + jsonComic.EpList = make([]JsonComicDownloadEp, 0) + for _, ep := range epList { + jsonEp := JsonComicDownloadEp{} + jsonEp.ComicDownloadEp = ep + jsonEp.PictureList = make([]JsonComicDownloadPicture, 0) + pictures, err := comic_center.ListDownloadPictureByEpId(ep.ID) + if err != nil { + return err + } + for _, picture := range pictures { + jsonPicture := JsonComicDownloadPicture{} + jsonPicture.ComicDownloadPicture = picture + jsonPicture.SrcPath = fmt.Sprintf("pictures/%04d_%04d", ep.EpOrder, picture.RankInEp) + notifyExport(fmt.Sprintf("正在导出 EP:%d PIC:%d", ep.EpOrder, picture.RankInEp)) + entryWriter, err := onWriteFile(jsonPicture.SrcPath, jsonPicture.FileSize) + if err != nil { + return err + } + source, err := os.Open(downloadPath(picture.LocalPath)) + if err != nil { + return err + } + _, err = func() (int64, error) { + defer source.Close() + return io.Copy(entryWriter, source) + }() + if err != nil { + return err + } + jsonEp.PictureList = append(jsonEp.PictureList, jsonPicture) + } + jsonComic.EpList = append(jsonComic.EpList, jsonEp) + } + if comic.ThumbLocalPath != "" { + logoBuff, err := ioutil.ReadFile(downloadPath(comic.ThumbLocalPath)) + if err == nil { + entryWriter, err := onWriteFile("logo", int64(len(logoBuff))) + if err != nil { + return err + } + _, err = entryWriter.Write(logoBuff) + if err != nil { + return err + } + } + } + // JS + { + buff, err := json.Marshal(&jsonComic) + if err != nil { + return err + } + logoBuff := append([]byte("data = "), buff...) + if err == nil { + entryWriter, err := onWriteFile("data.js", int64(len(logoBuff))) + if err != nil { + return err + } + _, err = entryWriter.Write(logoBuff) + if err != nil { + return err + } + } + } + // HTML + { + var htmlBuff = []byte(indexHtml) + if err == nil { + entryWriter, err := onWriteFile("index.html", int64(len(htmlBuff))) + if err != nil { + return err + } + _, err = entryWriter.Write(htmlBuff) + if err != nil { + return err + } + } + } + println("OK") + // + return nil +} + +const indexHtml = ` + + + + + + + + + +
+ + +
+
+
+ + +` + +func exportComicDownloadToJPG(params string) error { + var paramsStruct struct { + ComicId string `json:"comicId"` + Dir string `json:"dir"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comicId := paramsStruct.ComicId + dir := paramsStruct.Dir + println(fmt.Sprintf("导出 %s 到 %s", comicId, dir)) + comic, err := comic_center.FindComicDownloadById(comicId) + if err != nil { + return err + } + if comic == nil { + return errors.New("not found") + } + if !comic.DownloadFinished { + return errors.New("not download finish") + } + dirPath := path.Join(dir, fmt.Sprintf("%s-%s", comic.Title, time.Now().Format("2006_01_02_15_04_05.999"))) + println(fmt.Sprintf("DIR : %s", dirPath)) + err = os.Mkdir(dirPath, const_value.CreateDirMode) + if err != nil { + return err + } + err = os.Mkdir(path.Join(dirPath, "pictures"), const_value.CreateDirMode) + if err != nil { + return err + } + + epList, err := comic_center.ListDownloadEpByComicId(comicId) + if err != nil { + return err + } + jsonComic := JsonComicDownload{} + jsonComic.ComicDownload = *comic + jsonComic.EpList = make([]JsonComicDownloadEp, 0) + for _, ep := range epList { + jsonEp := JsonComicDownloadEp{} + jsonEp.ComicDownloadEp = ep + jsonEp.PictureList = make([]JsonComicDownloadPicture, 0) + pictures, err := comic_center.ListDownloadPictureByEpId(ep.ID) + if err != nil { + return err + } + for _, picture := range pictures { + jsonPicture := JsonComicDownloadPicture{} + jsonPicture.ComicDownloadPicture = picture + jsonPicture.SrcPath = fmt.Sprintf("pictures/%04d_%04d.%s", ep.EpOrder, picture.RankInEp, picture.Format) + notifyExport(fmt.Sprintf("正在导出 EP:%d PIC:%d", ep.EpOrder, picture.RankInEp)) + entryWriter, err := os.Create(path.Join(dirPath, jsonPicture.SrcPath)) + if err != nil { + return err + } + err = func() error { + defer entryWriter.Close() + source, err := os.Open(downloadPath(picture.LocalPath)) + if err != nil { + return err + } + _, err = func() (int64, error) { + defer source.Close() + return io.Copy(entryWriter, source) + }() + return err + }() + jsonEp.PictureList = append(jsonEp.PictureList, jsonPicture) + } + jsonComic.EpList = append(jsonComic.EpList, jsonEp) + } + if comic.ThumbLocalPath != "" { + logoBuff, err := ioutil.ReadFile(downloadPath(comic.ThumbLocalPath)) + if err == nil { + entryWriter, err := os.Create(path.Join(dirPath, "logo")) + if err != nil { + return err + } + defer entryWriter.Close() + if err != nil { + return err + } + _, err = entryWriter.Write(logoBuff) + if err != nil { + return err + } + } + } + // JS + { + buff, err := json.Marshal(&jsonComic) + if err != nil { + return err + } + logoBuff := append([]byte("data = "), buff...) + if err == nil { + + entryWriter, err := os.Create(path.Join(dirPath, "data.js")) + if err != nil { + return err + } + defer entryWriter.Close() + _, err = entryWriter.Write(logoBuff) + if err != nil { + return err + } + } + } + // HTML + { + var htmlBuff = []byte(indexHtml) + if err == nil { + entryWriter, err := os.Create(path.Join(dirPath, "index.html")) + if err != nil { + return err + } + defer entryWriter.Close() + _, err = entryWriter.Write(htmlBuff) + if err != nil { + return err + } + } + } + println("OK") + return nil +} diff --git a/go/pikapi/controller/game.go b/go/pikapi/controller/game.go new file mode 100644 index 0000000..6243298 --- /dev/null +++ b/go/pikapi/controller/game.go @@ -0,0 +1,40 @@ +package controller + +import ( + "errors" + "fmt" + "github.com/PuerkitoBio/goquery" + "net/http" + "regexp" + "time" +) + +var downloadGameUrlPattern, _ = regexp.Compile("^https://game\\.eroge\\.xyz/hhh\\.php\\?id=\\d+$") + +func downloadGame(url string) (string, error) { + if downloadGameUrlPattern.MatchString(url) { + return cacheable(fmt.Sprintf("GAME_PAGE$%s", url), time.Hour*1000, func() (interface{}, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36") + rsp, err := client.Do(req) + if err != nil { + return nil, err + } + defer rsp.Body.Close() + doc, err := goquery.NewDocumentFromReader(rsp.Body) + if err != nil { + return nil, err + } + find := doc.Find("a.layui-btn") + list := make([]string, find.Size()) + find.Each(func(i int, selection *goquery.Selection) { + list[i] = selection.AttrOr("href", "") + }) + return list, nil + }) + } + return "", errors.New("not support url") +} diff --git a/go/pikapi/controller/image.go b/go/pikapi/controller/image.go new file mode 100644 index 0000000..f0e37b6 --- /dev/null +++ b/go/pikapi/controller/image.go @@ -0,0 +1,96 @@ +package controller + +import ( + "bytes" + "errors" + _ "golang.org/x/image/webp" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io/ioutil" + "net/http" + "pgo/pikapi/database/comic_center" + "sync" +) + +var mutexCounter = -1 +var busMutex *sync.Mutex +var subMutexes []*sync.Mutex + +func init() { + busMutex = &sync.Mutex{} + for i := 0; i < 5; i++ { + subMutexes = append(subMutexes, &sync.Mutex{}) + } +} + +// takeMutex 下载图片获取一个锁, 这样只能同时下载5张图片 +func takeMutex() *sync.Mutex { + busMutex.Lock() + defer busMutex.Unlock() + mutexCounter = (mutexCounter + 1) % len(subMutexes) + return subMutexes[mutexCounter] +} + +func decodeInfoFromBuff(buff []byte) (image.Image, string, error) { + buffer := bytes.NewBuffer(buff) + return image.Decode(buffer) +} + +func decodeFromFile(path string) ([]byte, image.Image, string, error) { + b, e := ioutil.ReadFile(path) + if e != nil { + return nil, nil, "", e + } + i, f, e := decodeInfoFromBuff(b) + if e != nil { + return nil, nil, "", e + } + return b, i, f, e +} + +// 下载图片并decode +func decodeFromUrl(fileServer string, path string) ([]byte, image.Image, string, error) { + m := takeMutex() + m.Lock() + defer m.Unlock() + request, err := http.NewRequest("GET", fileServer+"/static/"+path, nil) + if err != nil { + return nil, nil, "", err + } + response, err := client.Do(request) + if err != nil { + return nil, nil, "", err + } + defer response.Body.Close() + if response.StatusCode != 200 { + return nil, nil, "", errors.New("code is not 200") + } + buff, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, nil, "", err + } + img, format, err := decodeInfoFromBuff(buff) + if err != nil { + return nil, nil, "", err + } + return buff, img, format, err +} + +// decodeFromCache 仅下载使用 +func decodeFromCache(fileServer string, path string) ([]byte, image.Image, string, error) { + cache := comic_center.FindRemoteImage(fileServer, path) + if cache != nil { + buff, err := ioutil.ReadFile(remotePath(cache.LocalPath)) + if err != nil { + return nil, nil, "", err + } + img, format, err := decodeInfoFromBuff(buff) + if err != nil { + return nil, nil, "", err + } + return buff, img, format, err + } + return nil, nil, "", errors.New("not found") +} diff --git a/go/pikapi/controller/import.go b/go/pikapi/controller/import.go new file mode 100644 index 0000000..bbc2f2a --- /dev/null +++ b/go/pikapi/controller/import.go @@ -0,0 +1,197 @@ +package controller + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "gorm.io/gorm" + "io" + "io/ioutil" + "net" + "os" + path2 "path" + "pgo/pikapi/const_value" + "pgo/pikapi/database/comic_center" + "pgo/pikapi/utils" + "strconv" + "strings" +) + +func importComicDownloadUsingSocket(addr string) error { + // + conn, err := net.Dial("tcp", addr) + if err != nil { + return err + } + defer conn.Close() + gr, err := gzip.NewReader(conn) + if err != nil { + return err + } + tr := tar.NewReader(gr) + // + zipPath := path2.Join(tmpDir, "tmp.zip") + closed := false + zipFile, err := os.Create(zipPath) + if err != nil { + return err + } + defer func() { + if !closed { + zipFile.Close() + } + os.Remove(zipPath) + }() + zipWriter := zip.NewWriter(zipFile) + defer func() { + if !closed { + zipWriter.Close() + } + }() + // + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg { + continue + } + writer, err := zipWriter.Create(header.Name) + if err != nil { + return err + } + _, err = io.Copy(writer, tr) + if err != nil { + return err + } + } + err = zipWriter.Close() + zipFile.Close() + closed = true + return importComicDownload(zipPath) +} + +func importComicDownload(zipPath string) error { + zip, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer zip.Close() + dataJs, err := zip.Open("data.js") + if err != nil { + return err + } + defer dataJs.Close() + dataBuff, err := ioutil.ReadAll(dataJs) + if err != nil { + return err + } + data := strings.TrimLeft(string(dataBuff), "data = ") + var jsonComicDownload JsonComicDownload + err = json.Unmarshal([]byte(data), &jsonComicDownload) + if err != nil { + return err + } + return comic_center.Transaction(func(tx *gorm.DB) error { + // 删除 + err := tx.Unscoped().Delete(&comic_center.ComicDownload{}, "id = ?", jsonComicDownload.ID).Error + if err != nil { + return err + } + err = tx.Unscoped().Delete(&comic_center.ComicDownloadEp{}, "comic_id = ?", jsonComicDownload.ID).Error + if err != nil { + return err + } + err = tx.Unscoped().Delete(&comic_center.ComicDownloadPicture{}, "comic_id = ?", jsonComicDownload.ID).Error + if err != nil { + return err + } + // 插入 + err = tx.Save(&jsonComicDownload.ComicDownload).Error + if err != nil { + return err + } + for _, ep := range jsonComicDownload.EpList { + err = tx.Save(&ep.ComicDownloadEp).Error + if err != nil { + return err + } + for _, picture := range ep.PictureList { + notifyExport("事务 : " + picture.LocalPath) + err = tx.Save(&picture.ComicDownloadPicture).Error + if err != nil { + return err + } + } + } + // VIEW日志 + view := comic_center.ComicView{} + view.ID = jsonComicDownload.ID + view.CreatedAt = jsonComicDownload.CreatedAt + view.UpdatedAt = jsonComicDownload.UpdatedAt + view.Title = jsonComicDownload.Title + view.Author = jsonComicDownload.Author + view.PagesCount = jsonComicDownload.PagesCount + view.EpsCount = jsonComicDownload.EpsCount + view.Finished = jsonComicDownload.Finished + c, _ := json.Marshal(jsonComicDownload.Categories) + view.Categories = string(c) + view.ThumbOriginalName = jsonComicDownload.ThumbOriginalName + view.ThumbFileServer = jsonComicDownload.ThumbFileServer + view.ThumbPath = jsonComicDownload.ThumbPath + view.LikesCount = 0 + view.Description = jsonComicDownload.Description + view.ChineseTeam = jsonComicDownload.ChineseTeam + t, _ := json.Marshal(jsonComicDownload.Tags) + view.Tags = string(t) + view.AllowDownload = true + view.ViewsCount = 0 + view.IsFavourite = false + view.IsLiked = false + view.CommentsCount = 0 + err = comic_center.NoLockActionViewComicUpdateInfoDB(&view, tx) + if err != nil { + return err + } + // 覆盖文件 + comicDirPath := downloadPath(jsonComicDownload.ID) + utils.Mkdir(comicDirPath) + logoReader, err := zip.Open("logo") + if err == nil { + defer logoReader.Close() + logoBuff, err := ioutil.ReadAll(logoReader) + if err != nil { + return err + } + ioutil.WriteFile(path2.Join(comicDirPath, "logo"), logoBuff, const_value.CreateFileMode) + } + for _, ep := range jsonComicDownload.EpList { + utils.Mkdir(path2.Join(comicDirPath, strconv.Itoa(int(ep.EpOrder)))) + for _, picture := range ep.PictureList { + notifyExport("写入 : " + picture.LocalPath) + zipEntry, err := zip.Open(picture.SrcPath) + if err != nil { + return err + } + err = func() error { + defer zipEntry.Close() + entryBuff, err := ioutil.ReadAll(zipEntry) + if err != nil { + return err + } + return ioutil.WriteFile(downloadPath(picture.LocalPath), entryBuff, const_value.CreateFileMode) + }() + if err != nil { + return err + } + } + } + // 结束 + return nil + }) +} diff --git a/go/pikapi/controller/modles.go b/go/pikapi/controller/modles.go new file mode 100644 index 0000000..e31273c --- /dev/null +++ b/go/pikapi/controller/modles.go @@ -0,0 +1,31 @@ +package controller + +import "pgo/pikapi/database/comic_center" + +type DisplayImageData struct { + FileSize int64 `json:"fileSize"` + Format string `json:"format"` + Width int32 `json:"width"` + Height int32 `json:"height"` + FinalPath string `json:"finalPath"` +} + +type ComicDownloadPictureWithFinalPath struct { + comic_center.ComicDownloadPicture + FinalPath string `json:"finalPath"` +} + +type JsonComicDownload struct { + comic_center.ComicDownload + EpList []JsonComicDownloadEp `json:"epList"` +} + +type JsonComicDownloadEp struct { + comic_center.ComicDownloadEp + PictureList []JsonComicDownloadPicture `json:"pictureList"` +} + +type JsonComicDownloadPicture struct { + comic_center.ComicDownloadPicture + SrcPath string `json:"srcPath"` +} diff --git a/go/pikapi/controller/network.go b/go/pikapi/controller/network.go new file mode 100644 index 0000000..9be445e --- /dev/null +++ b/go/pikapi/controller/network.go @@ -0,0 +1,23 @@ +package controller + +import ( + "net" + "strings" +) + +func clientIpSet() (string, error) { + address, err := net.InterfaceAddrs() + if err != nil { + return "", err + } + ipSet := make([]string, 0) + for _, address := range address { + // 检查ip地址判断是否回环地址 + if ipNet, ok := address.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + ipSet = append(ipSet, ipNet.IP.To4().String()) + } + } + } + return strings.Join(ipSet, ","), nil +} diff --git a/go/pikapi/controller/pikapi.go b/go/pikapi/controller/pikapi.go new file mode 100644 index 0000000..e2c3894 --- /dev/null +++ b/go/pikapi/controller/pikapi.go @@ -0,0 +1,578 @@ +package controller + +import ( + "crypto/md5" + "encoding/json" + "errors" + "fmt" + source "github.com/niuhuan/pica-go" + "image/jpeg" + "io/ioutil" + "os" + path2 "path" + "pgo/pikapi/const_value" + "pgo/pikapi/database/comic_center" + "pgo/pikapi/database/network_cache" + "pgo/pikapi/database/properties" + "pgo/pikapi/utils" + "strconv" + "time" +) + +var ( + remoteDir string + downloadDir string + tmpDir string +) + +func InitPlugin(_remoteDir string, _downloadDir string, _tmpDir string) { + remoteDir = _remoteDir + downloadDir = _downloadDir + tmpDir = _tmpDir + comic_center.ResetAll() + go downloadBackground() + downloadRunning = true +} + +func remotePath(path string) string { + return path2.Join(remoteDir, path) +} + +func downloadPath(path string) string { + return path2.Join(downloadDir, path) +} + +func saveProperty(params string) error { + var paramsStruct struct { + Name string `json:"name"` + Value string `json:"value"` + } + json.Unmarshal([]byte(params), ¶msStruct) + return properties.SaveProperty(paramsStruct.Name, paramsStruct.Value) +} + +func loadProperty(params string) (string, error) { + var paramsStruct struct { + Name string `json:"name"` + DefaultValue string `json:"defaultValue"` + } + json.Unmarshal([]byte(params), ¶msStruct) + return properties.LoadProperty(paramsStruct.Name, paramsStruct.DefaultValue) +} + +func setSwitchAddress(nSwitchAddress string) error { + err := properties.SaveSwitchAddress(nSwitchAddress) + if err != nil { + return err + } + switchAddress = nSwitchAddress + return nil +} + +func getSwitchAddress() (string, error) { + return switchAddress, nil +} + +func setProxy(value string) error { + err := properties.SaveProxy(value) + if err != nil { + return err + } + changeProxyUrl(value) + return nil +} + +func getProxy() (string, error) { + return properties.LoadProxy() +} + +func setUsername(value string) error { + return properties.SaveUsername(value) +} + +func getUsername() (string, error) { + return properties.LoadUsername() +} + +func setPassword(value string) error { + return properties.SavePassword(value) +} + +func getPassword() (string, error) { + return properties.LoadPassword() +} + +func preLogin() (string, error) { + token, _ := properties.LoadToken() + tokenTime, _ := properties.LoadTokenTime() + if token != "" && tokenTime > 0 { + if utils.Timestamp()-(1000*60*60*24) < tokenTime { + client.Token = token + return "true", nil + } + } + err := login() + if err == nil { + return "true", nil + } + return "false", nil +} + +func login() error { + username, _ := properties.LoadUsername() + password, _ := properties.LoadPassword() + if password == "" || username == "" { + return errors.New(" 需要设定用户名和密码 ") + } + err := client.Login(username, password) + if err != nil { + return err + } + properties.SaveToken(client.Token) + properties.SaveTokenTime(utils.Timestamp()) + return nil +} + +func register(params string) error { + var dto source.RegisterDto + err := json.Unmarshal([]byte(params), &dto) + if err != nil { + return err + } + return client.Register(dto) +} + +func clearToken() error { + properties.SaveTokenTime(0) + properties.SaveToken("") + return nil +} + +func userProfile() (string, error) { + return serialize(client.UserProfile()) +} + +func punchIn() (string, error) { + return serialize(client.PunchIn()) +} + +func remoteImageData(params string) (string, error) { + var paramsStruct struct { + FileServer string `json:"fileServer"` + Path string `json:"path"` + } + json.Unmarshal([]byte(params), ¶msStruct) + fileServer := paramsStruct.FileServer + path := paramsStruct.Path + lock := utils.HashLock(fmt.Sprintf("%s$%s", fileServer, path)) + lock.Lock() + defer lock.Unlock() + cache := comic_center.FindRemoteImage(fileServer, path) + if cache == nil { + buff, img, format, err := decodeFromUrl(fileServer, path) + if err != nil { + println(fmt.Sprintf("decode error : %s/static/%s %s", fileServer, path, err.Error())) + return "", err + } + local := + fmt.Sprintf("%x", + md5.Sum([]byte(fmt.Sprintf("%s$%s", fileServer, path))), + ) + real := remotePath(local) + err = ioutil.WriteFile( + real, + buff, os.FileMode(0600), + ) + if err != nil { + return "", err + } + remote := comic_center.RemoteImage{ + FileServer: fileServer, + Path: path, + FileSize: int64(len(buff)), + Format: format, + Width: int32(img.Bounds().Dx()), + Height: int32(img.Bounds().Dy()), + LocalPath: local, + } + err = comic_center.SaveRemoteImage(&remote) + if err != nil { + return "", err + } + cache = &remote + } + display := DisplayImageData{ + FileSize: cache.FileSize, + Format: cache.Format, + Width: cache.Width, + Height: cache.Height, + FinalPath: remotePath(cache.LocalPath), + } + return serialize(&display, nil) +} + +func downloadImagePath(path string) (string, error) { + return downloadPath(path), nil +} + +func createDownload(params string) error { + var paramsStruct struct { + Comic comic_center.ComicDownload `json:"comic"` + EpList []comic_center.ComicDownloadEp `json:"epList"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comic := paramsStruct.Comic + epList := paramsStruct.EpList + if comic.Title == "" || len(epList) == 0 { + return errors.New("params error") + } + err := comic_center.CreateDownload(&comic, &epList) + if err != nil { + return err + } + // 创建文件夹 + utils.Mkdir(downloadPath(comic.ID)) + // 复制图标 + downloadComicLogo(&comic) + return nil +} + +func downloadComicLogo(comic *comic_center.ComicDownload) { + lock := utils.HashLock(fmt.Sprintf("%s$%s", comic.ThumbFileServer, comic.ThumbPath)) + lock.Lock() + defer lock.Unlock() + buff, image, format, err := decodeFromCache(comic.ThumbFileServer, comic.ThumbPath) + if err != nil { + buff, image, format, err = decodeFromUrl(comic.ThumbFileServer, comic.ThumbPath) + } + if err == nil { + comicLogoPath := path2.Join(comic.ID, "logo") + ioutil.WriteFile(downloadPath(comicLogoPath), buff, const_value.CreateFileMode) + comic_center.UpdateDownloadLogo( + comic.ID, + int64(len(buff)), + format, + int32(image.Bounds().Dx()), + int32(image.Bounds().Dy()), + comicLogoPath, + ) + comic.ThumbFileSize = int64(len(buff)) + comic.ThumbFormat = format + comic.ThumbWidth = int32(image.Bounds().Dx()) + comic.ThumbHeight = int32(image.Bounds().Dy()) + comic.ThumbLocalPath = comicLogoPath + } + if err != nil { + println(err.Error()) + } +} + +func addDownload(params string) error { + var paramsStruct struct { + Comic comic_center.ComicDownload `json:"comic"` + EpList []comic_center.ComicDownloadEp `json:"epList"` + } + json.Unmarshal([]byte(params), ¶msStruct) + comic := paramsStruct.Comic + epList := paramsStruct.EpList + if comic.Title == "" || len(epList) == 0 { + return errors.New("params error") + } + return comic_center.AddDownload(&comic, &epList) +} + +func deleteDownloadComic(comicId string) error { + err := comic_center.Deleting(comicId) + if err != nil { + return err + } + downloadRestart = true + return nil +} + +func loadDownloadComic(comicId string) (string, error) { + download, err := comic_center.FindComicDownloadById(comicId) + if err != nil { + return "", err + } + if download == nil { + return "", nil + } + comic_center.ViewComic(comicId) // VIEW + return serialize(download, err) +} + +func allDownloads() (string, error) { + return serialize(comic_center.AllDownloads()) +} + +func downloadEpList(comicId string) (string, error) { + return serialize(comic_center.ListDownloadEpByComicId(comicId)) +} + +func viewLogPage(params string) (string, error) { + var paramsStruct struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + } + json.Unmarshal([]byte(params), ¶msStruct) + return serialize(comic_center.ViewLogPage(paramsStruct.Offset, paramsStruct.Limit)) +} + +func downloadPicturesByEpId(epId string) (string, error) { + return serialize(comic_center.ListDownloadPictureByEpId(epId)) +} + +func getDownloadRunning() bool { + return downloadRunning +} + +func setDownloadRunning(status bool) { + downloadRunning = status +} + +func clean() error { + var err error + notifyExport("清理网络缓存") + err = network_cache.RemoveAll() + if err != nil { + return err + } + notifyExport("清理图片缓存") + err = comic_center.RemoveAllRemoteImage() + if err != nil { + return err + } + notifyExport("清理图片文件") + os.RemoveAll(remoteDir) + utils.Mkdir(remoteDir) + notifyExport("清理结束") + return nil +} + +func autoClean(expire int64) error { + now := time.Now() + earliest := now.Add(time.Second * time.Duration(0-expire)) + err := network_cache.RemoveEarliest(earliest) + if err != nil { + return err + } + pageSize := 10 + for true { + images, err := comic_center.EarliestRemoteImage(earliest, pageSize) + if err != nil { + return err + } + if len(images) == 0 { + return comic_center.VACUUM() + } + // delete data & remove pic + err = comic_center.DeleteRemoteImages(images) + if err != nil { + return err + } + for i := 0; i < len(images); i++ { + err = os.Remove(remotePath(images[i].LocalPath)) + if err != nil { + return err + } + } + } + return nil +} + +func storeViewEp(params string) error { + var paramsStruct struct { + ComicId string `json:"comicId"` + EpOrder int `json:"epOrder"` + EpTitle string `json:"epTitle"` + PictureRank int `json:"pictureRank"` + } + json.Unmarshal([]byte(params), ¶msStruct) + return comic_center.ViewEpAndPicture( + paramsStruct.ComicId, + paramsStruct.EpOrder, + paramsStruct.EpTitle, + paramsStruct.PictureRank, + ) +} + +func loadView(comicId string) (string, error) { + view, err := comic_center.LoadViewLog(comicId) + if err != nil { + return "", nil + } + if view != nil { + b, err := json.Marshal(view) + if err != nil { + return "", err + } + return string(b), nil + } + return "", nil +} + +func convertImageToJPEG100(params string) error { + var paramsStruct struct { + Path string `json:"path"` + Dir string `json:"dir"` + } + err := json.Unmarshal([]byte(params), ¶msStruct) + if err != nil { + return err + } + _, i, _, err := decodeFromFile(paramsStruct.Path) + if err != nil { + return err + } + to := path2.Join(paramsStruct.Dir, path2.Base(paramsStruct.Path)+".jpg") + stream, err := os.Create(to) + if err != nil { + return err + } + defer stream.Close() + return jpeg.Encode(stream, i, &jpeg.Options{Quality: 100}) +} + +func FlatInvoke(method string, params string) (string, error) { + switch method { + case "saveProperty": + return "", saveProperty(params) + case "loadProperty": + return loadProperty(params) + case "setSwitchAddress": + return "", setSwitchAddress(params) + case "getSwitchAddress": + return getSwitchAddress() + case "setProxy": + return "", setProxy(params) + case "getProxy": + return getProxy() + case "setUsername": + return "", setUsername(params) + case "setPassword": + return "", setPassword(params) + case "getUsername": + return getUsername() + case "getPassword": + return getPassword() + case "preLogin": + return preLogin() + case "login": + return "", login() + case "register": + return "", register(params) + case "clearToken": + return "", clearToken() + case "userProfile": + return userProfile() + case "punchIn": + return punchIn() + case "categories": + return categories() + case "comics": + return comics(params) + case "searchComics": + return searchComics(params) + case "randomComics": + return randomComics() + case "leaderboard": + return leaderboard(params) + case "comicInfo": + return comicInfo(params) + case "comicEpPage": + return epPage(params) + case "comicPicturePageWithQuality": + return comicPicturePageWithQuality(params) + case "switchLike": + return switchLike(params) + case "switchFavourite": + return switchFavourite(params) + case "favouriteComics": + return favouriteComics(params) + case "recommendation": + return recommendation(params) + case "comments": + return comments(params) + case "commentChildren": + return commentChildren(params) + case "myComments": + return myComments(params) + case "postComment": + return postComment(params) + case "postChildComment": + return postChildComment(params) + case "game": + return game(params) + case "games": + return games(params) + case "viewLogPage": + return viewLogPage(params) + case "clearAllViewLog": + comic_center.ClearAllViewLog() + return "", nil + case "deleteViewLog": + comic_center.DeleteViewLog(params) + return "", nil + case "clean": + return "", clean() + case "autoClean": + expire, err := strconv.ParseInt(params, 10, 64) + if err != nil { + return "", err + } + return "", autoClean(expire) + case "storeViewEp": + return "", storeViewEp(params) + case "loadView": + return loadView(params) + case "downloadRunning": + return strconv.FormatBool(getDownloadRunning()), nil + case "setDownloadRunning": + b, e := strconv.ParseBool(params) + if e != nil { + setDownloadRunning(b) + } + return "", e + case "createDownload": + return "", createDownload(params) + case "addDownload": + return "", addDownload(params) + case "loadDownloadComic": + return loadDownloadComic(params) + case "allDownloads": + return allDownloads() + case "deleteDownloadComic": + return "", deleteDownloadComic(params) + case "downloadEpList": + return downloadEpList(params) + case "downloadPicturesByEpId": + return downloadPicturesByEpId(params) + case "resetAllDownloads": + return "", comic_center.ResetAll() + case "exportComicDownload": + return "", exportComicDownload(params) + case "exportComicDownloadToJPG": + return "", exportComicDownloadToJPG(params) + case "exportComicUsingSocket": + i, e := exportComicUsingSocket(params) + return fmt.Sprintf("%d", i), e + case "exportComicUsingSocketExit": + return "", exportComicUsingSocketExit() + case "importComicDownload": + return "", importComicDownload(params) + case "importComicDownloadUsingSocket": + return "", importComicDownloadUsingSocket(params) + case "remoteImageData": + return remoteImageData(params) + case "clientIpSet": + return clientIpSet() + case "downloadImagePath": + return downloadImagePath(params) + case "downloadGame": + return downloadGame(params) + case "convertImageToJPEG100": + return "", convertImageToJPEG100(params) + } + return "", errors.New("method not found : " + method) +} diff --git a/go/pikapi/database/comic_center/center.go b/go/pikapi/database/comic_center/center.go new file mode 100644 index 0000000..b89fb29 --- /dev/null +++ b/go/pikapi/database/comic_center/center.go @@ -0,0 +1,593 @@ +package comic_center + +import ( + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "path" + "pgo/pikapi/const_value" + "sync" + "time" +) + +var mutex = sync.Mutex{} +var db *gorm.DB + +func InitDBConnect(databaseDir string) { + mutex.Lock() + defer mutex.Unlock() + var err error + db, err = gorm.Open(sqlite.Open(path.Join(databaseDir, "comic_center.db")), const_value.GormConfig) + if err != nil { + panic("failed to connect database") + } + db.AutoMigrate(&Category{}) + db.AutoMigrate(&ComicView{}) + db.AutoMigrate(&RemoteImage{}) + db.AutoMigrate(&ComicDownload{}) + db.AutoMigrate(&ComicDownloadEp{}) + db.AutoMigrate(&ComicDownloadPicture{}) +} + +func Transaction(t func(tx *gorm.DB) error) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(t) +} + +func UpSetCategories(categories *[]Category) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + var in []string + for _, c := range *categories { + if c.ID == "" { + continue + } + in = append(in, c.ID) + err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "updated_at", + "title", + "description", + "is_web", + "active", + "link", + "thumb_original_name", + "thumb_file_server", + "thumb_path", + }), + }).Create(&c).Error + if err != nil { + return err + } + } + err := tx.Unscoped().Model(&Category{}).Where(" id in ?", in).Update("deleted_at", gorm.DeletedAt{ + Valid: false, + }).Error + if err != nil { + return err + } + return tx.Unscoped().Model(&Category{}).Where(" id not in ?", in).Update("deleted_at", gorm.DeletedAt{ + Time: time.Now(), + Valid: true, + }).Error + }) +} + +func NoLockActionViewComicUpdateInfoDB(view *ComicView, db *gorm.DB) error { + view.LastViewTime = time.Now() + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "id"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "created_at", + "updated_at", + "title", + "author", + "pages_count", + "eps_count", + "finished", + "categories", + "thumb_original_name", + "thumb_file_server", + "thumb_path", + "likes_count", + "description", + "chinese_team", + "tags", + "allow_download", + "views_count", + "is_favourite", + "is_liked", + "comments_count", + "last_view_time", + }), + }).Create(view).Error +} + +func ViewComicUpdateInfo(view *ComicView) error { + mutex.Lock() + defer mutex.Unlock() + return NoLockActionViewComicUpdateInfoDB(view, db) +} + +func ViewComic(comicId string) error { + return db.Model(&ComicView{}).Where( + "id = ?", comicId, + ).Update( + "last_view_time", + time.Now(), + ).Error +} + +func ViewComicUpdateFavourite(comicId string, favourite bool) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicView{}).Where( + "id = ?", comicId, + ).Update( + "is_favourite", + favourite, + ).Error +} + +func ViewComicUpdateLike(comicId string, like bool) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicView{}).Where( + "id = ?", comicId, + ).Update( + "is_like", + like, + ).Error +} + +func ViewEpAndPicture(comicId string, epOrder int, epTitle string, pictureRank int) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicView{}).Where("id", comicId).Updates( + map[string]interface{}{ + "last_view_time": time.Now(), + "last_view_ep_order": epOrder, + "last_view_ep_title": epTitle, + "last_view_picture_rank": pictureRank, + }, + ).Error +} + +func LoadViewLog(comicId string) (*ComicView, error) { + mutex.Lock() + defer mutex.Unlock() + var view ComicView + err := db.First(&view, "id = ?", comicId).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &view, nil +} + +func FindRemoteImage(fileServer string, path string) *RemoteImage { + mutex.Lock() + defer mutex.Unlock() + var remoteImage RemoteImage + err := db.First(&remoteImage, "file_server = ? AND path = ?", fileServer, path).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } else { + panic(err) + } + } + return &remoteImage +} + +func SaveRemoteImage(remote *RemoteImage) error { + mutex.Lock() + defer mutex.Unlock() + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "file_server"}, {Name: "path"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "updated_at", + "file_size", + "format", + "width", + "height", + "local_path", + }), + }).Create(remote).Error +} + +func CreateDownload(comic *ComicDownload, epList *[]ComicDownloadEp) error { + mutex.Lock() + defer mutex.Unlock() + comic.SelectedEpCount = int32(len(*epList)) + return db.Transaction(func(tx *gorm.DB) error { + err := tx.Create(comic).Error + if err != nil { + return err + } + for _, ep := range *epList { + err := tx.Create(&ep).Error + if err != nil { + return err + } + } + return nil + }) +} + +func AddDownload(comic *ComicDownload, epList *[]ComicDownloadEp) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + err := tx.Model(comic).Where("id = ?", comic.ID).Updates(map[string]interface{}{ + "created_at": comic.CreatedAt, + "updated_at": comic.UpdatedAt, + "title": comic.Title, + "author": comic.Author, + "pages_count": comic.PagesCount, + "eps_count": comic.EpsCount, + "finished": comic.Finished, + "categories": comic.Categories, + "thumb_original_name": comic.ThumbOriginalName, + "thumb_file_server": comic.ThumbFileServer, + "thumb_path": comic.ThumbPath, + "description": comic.Description, + "chinese_team": comic.ChineseTeam, + "tags": comic.Tags, + "download_finished": false, // restart + }).Error + if err != nil { + return err + } + err = tx.Exec( + "UPDATE comic_downloads SET eps_count = selected_ep_count + ? WHERE id = ?", + len(*epList), comic.ID, + ).Error + if err != nil { + return err + } + for _, ep := range *epList { + err := tx.Create(&ep).Error + if err != nil { + return err + } + } + return nil + }) +} + +func UpdateDownloadLogo(comicId string, fileSize int64, format string, width int32, height int32, localPath string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownload{}).Where("id = ?", comicId).Updates(map[string]interface{}{ + "thumb_file_size": fileSize, + "thumb_format": format, + "thumb_width": width, + "thumb_height": height, + "thumb_local_path": localPath, + }).Error +} + +func FindComicDownloadById(comicId string) (*ComicDownload, error) { + mutex.Lock() + defer mutex.Unlock() + var download ComicDownload + err := db.First(&download, "id = ?", comicId).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &download, nil +} + +func ListDownloadEpByComicId(comicId string) ([]ComicDownloadEp, error) { + mutex.Lock() + defer mutex.Unlock() + var epList []ComicDownloadEp + err := db.Where("comic_id = ?", comicId).Order("ep_order ASC").Find(&epList).Error + return epList, err +} + +func ListDownloadPictureByEpId(epId string) ([]ComicDownloadPicture, error) { + mutex.Lock() + defer mutex.Unlock() + var pictureList []ComicDownloadPicture + err := db.Where("ep_id = ?", epId).Order("rank_in_ep ASC").Find(&pictureList).Error + return pictureList, err +} + +func AllDownloads() (*[]ComicDownload, error) { + mutex.Lock() + defer mutex.Unlock() + var downloads []ComicDownload + err := db.Table("comic_downloads"). + Joins("LEFT JOIN comic_views ON comic_views.id = comic_downloads.id"). + Select("comic_downloads.*"). + Order("comic_views.last_view_time DESC"). + Scan(&downloads).Error + // err := db.Find(&downloads).Error + return &downloads, err +} + +func LoadFirstNeedDownload() (*ComicDownload, error) { + mutex.Lock() + defer mutex.Unlock() + var download ComicDownload + err := db.First(&download, "download_failed = 0 AND pause = 0 AND deleting = 0 AND download_finished = 0").Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &download, nil +} + +func LoadFirstNeedDownloadEp(comicId string) (*ComicDownloadEp, error) { + mutex.Lock() + defer mutex.Unlock() + var ep ComicDownloadEp + err := db.First( + &ep, + " comic_id = ? AND download_failed = 0 AND download_finished = 0 AND fetched_pictures = 1", + comicId, + ).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &ep, nil +} + +func LoadFirstNeedDownloadPicture(epId string) (*ComicDownloadPicture, error) { + mutex.Lock() + defer mutex.Unlock() + var picture ComicDownloadPicture + err := db.First( + &picture, + "ep_id = ? AND download_failed = 0 AND download_finished = 0", + epId, + ).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &picture, nil +} + +func FetchPictures(comicId string, epId string, list *[]ComicDownloadPicture) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + var rankInEp int32 + for _, picture := range *list { + rankInEp = rankInEp + 1 + picture.RankInEp = rankInEp + err := tx.Create(&picture).Error + if err != nil { + return err + } + } + err := tx.Model(&ComicDownloadEp{}).Where("id = ?", epId).Updates(map[string]interface{}{ + "fetched_pictures": true, + "selected_picture_count": len(*list), + }).Error + if err != nil { + return err + } + return tx.Exec( + "UPDATE comic_downloads SET selected_picture_count = selected_picture_count + ? WHERE id = ?", + len(*list), comicId, + ).Error + }) +} + +func DownloadFailed(comicId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownload{}).Where("id = ?", comicId).Update("download_failed", true).Error +} + +func DownloadSuccess(comicId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownload{}).Where("id = ?", comicId).Updates(map[string]interface{}{ + "download_finished": true, + "download_finished_time": time.Now(), + }).Error +} + +func EpFailed(epId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownloadEp{}).Where("id = ?", epId).Update("download_failed", true).Error +} + +func EpSuccess(comicId string, epId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + err := tx.Model(&ComicDownloadEp{}).Where("id = ?", epId).Updates(map[string]interface{}{ + "download_finished": true, + "download_finished_time": time.Now(), + }).Error + if err != nil { + return err + } + return tx.Exec( + "UPDATE comic_downloads SET download_ep_count = download_ep_count + 1 WHERE id = ?", + comicId, + ).Error + }) +} + +func PictureFailed(pictureId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownloadPicture{}).Where("id = ?", pictureId).Update("download_failed", true).Error +} + +func PictureSuccess( + comicId string, epId string, pictureId string, + fileSize int64, format string, width int32, height int32, localPath string, +) error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + err := tx.Model(&ComicDownloadPicture{}).Where("id = ?", pictureId).Updates(map[string]interface{}{ + "file_size": fileSize, + "format": format, + "width": width, + "height": height, + "local_path": localPath, + "download_finished": true, + "download_finished_time": time.Now(), + }).Error + if err != nil { + return err + } + err = tx.Exec( + "UPDATE comic_download_eps SET download_picture_count = download_picture_count + 1 WHERE id = ?", + epId, + ).Error + if err != nil { + return err + } + return tx.Exec( + "UPDATE comic_downloads SET download_picture_count = download_picture_count + 1 WHERE id = ?", + comicId, + ).Error + }) +} + +func ResetAll() error { + mutex.Lock() + defer mutex.Unlock() + return db.Transaction(func(tx *gorm.DB) error { + err := tx.Model(&ComicDownload{}).Where("1 = 1"). + Update("download_failed", false).Error + if err != nil { + return err + } + err = tx.Model(&ComicDownloadEp{}).Where("1 = 1"). + Update("download_failed", false).Error + if err != nil { + return err + } + err = tx.Model(&ComicDownloadPicture{}).Where("1 = 1"). + Update("download_failed", false).Error + return err + }) +} + +func ViewLogPage(offset int, limit int) (*[]ComicView, error) { + mutex.Lock() + defer mutex.Unlock() + var list []ComicView + err := db.Offset(offset).Limit(limit).Order("last_view_time DESC").Find(&list).Error + return &list, err +} + +func ClearAllViewLog() { + mutex.Lock() + defer mutex.Unlock() + db.Unscoped().Where("1 = 1").Delete(&ComicView{}) +} + +func DeleteViewLog(id string) { + mutex.Lock() + defer mutex.Unlock() + db.Unscoped().Where("id = ?", id).Delete(&ComicView{}) +} + +func DeletingComic() (*ComicDownload, error) { + mutex.Lock() + defer mutex.Unlock() + var download ComicDownload + err := db.First(&download, "deleting = 1").Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return &download, err +} + +func TrueDelete(comicId string) error { + mutex.Lock() + defer mutex.Unlock() + err := db.Transaction(func(tx *gorm.DB) error { + err := tx.Unscoped().Delete(&ComicDownload{}, "id = ?", comicId).Error + if err != nil { + return err + } + err = tx.Unscoped().Delete(&ComicDownloadEp{}, "comic_id = ?", comicId).Error + if err != nil { + return err + } + err = tx.Unscoped().Delete(&ComicDownloadPicture{}, "comic_id = ?", comicId).Error + if err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + return db.Raw("VACUUM").Error +} + +func Deleting(comicId string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Model(&ComicDownload{}).Where("id = ?", comicId).Updates(map[string]interface{}{ + "deleting": true, + }).Error +} + +func RemoveAllRemoteImage() error { + mutex.Lock() + defer mutex.Unlock() + err := db.Unscoped().Delete(&RemoteImage{}, "1 = 1").Error + if err != nil { + return err + } + return db.Raw("VACUUM").Error +} + +func EarliestRemoteImage(earliest time.Time, pageSize int) ([]RemoteImage, error) { + mutex.Lock() + defer mutex.Unlock() + var images []RemoteImage + err := db.Where("strftime('%s',updated_at) < strftime('%s',?)", earliest). + Order("updated_at").Limit(pageSize).Find(&images).Error + return images, err +} + +func DeleteRemoteImages(images []RemoteImage) error { + mutex.Lock() + defer mutex.Unlock() + if len(images) == 0 { + return nil + } + ids := make([]uint, len(images)) + for i := 0; i < len(images); i++ { + ids[i] = images[i].ID + } + return db.Unscoped().Model(&RemoteImage{}).Delete("id in ?", ids).Error +} + +func VACUUM() error { + mutex.Lock() + defer mutex.Unlock() + return db.Raw("VACUUM").Error +} diff --git a/go/pikapi/database/comic_center/entities.go b/go/pikapi/database/comic_center/entities.go new file mode 100644 index 0000000..6c34fb4 --- /dev/null +++ b/go/pikapi/database/comic_center/entities.go @@ -0,0 +1,122 @@ +package comic_center + +import ( + "gorm.io/gorm" + "time" +) + +type Category struct { + ID string `gorm:"primarykey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Title string `json:"title"` + Description string `json:"description"` + IsWeb bool `json:"isWeb"` + Active bool `json:"active"` + Link string `json:"link"` + ThumbOriginalName string + ThumbFileServer string + ThumbPath string +} + +type RemoteImage struct { + gorm.Model + FileServer string `gorm:"index:uk_fp,unique" json:"fileServer"` + Path string `gorm:"index:uk_fp,unique" json:"path"` + FileSize int64 `json:"fileSize"` + Format string `json:"format"` + Width int32 `json:"width"` + Height int32 `json:"height"` + LocalPath string `json:"localPath"` +} + +type ComicSimple struct { + ID string `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Title string `json:"title"` + Author string `json:"author"` + PagesCount int32 `json:"pagesCount"` + EpsCount int32 `json:"epsCount"` + Finished bool `json:"finished"` + Categories string `json:"categories"` + ThumbOriginalName string `json:"thumbOriginalName"` + ThumbFileServer string `json:"thumbFileServer"` + ThumbPath string `json:"thumbPath"` +} + +type ComicInfo struct { + ComicSimple + LikesCount int32 `json:"likesCount"` + Description string `json:"description"` + ChineseTeam string `json:"chineseTeam"` + Tags string `json:"tags"` + AllowDownload bool `json:"allowDownload"` + ViewsCount int32 `json:"viewsCount"` + IsFavourite bool `json:"isFavourite"` + IsLiked bool `json:"isLiked"` + CommentsCount int32 `json:"commentsCount"` +} + +type ComicView struct { + ComicInfo + LastViewTime time.Time `json:"lastViewTime"` + LastViewEpOrder int32 `json:"lastViewEpOrder"` + LastViewEpTitle string `json:"lastViewEpTitle"` + LastViewPictureRank int32 `json:"lastViewPictureRank"` +} + +type ComicDownload struct { + ComicSimple + Description string `json:"description"` + ChineseTeam string `json:"chineseTeam"` + Tags string `json:"tags"` + SelectedEpCount int32 `json:"selectedEpCount"` + SelectedPictureCount int32 `json:"selectedPictureCount"` + DownloadEpCount int32 `json:"downloadEpCount"` + DownloadPictureCount int32 `json:"downloadPictureCount"` + DownloadFinished bool `json:"downloadFinished"` + DownloadFinishedTime time.Time `json:"downloadFinishedTime"` + DownloadFailed bool `json:"downloadFailed"` + Deleting bool `json:"deleting"` + ThumbFileSize int64 `json:"thumbFileSize"` + ThumbFormat string `json:"thumbFormat"` + ThumbWidth int32 `json:"thumbWidth"` + ThumbHeight int32 `json:"thumbHeight"` + ThumbLocalPath string `json:"thumbLocalPath"` + Pause bool `json:"pause"` +} + +type ComicDownloadEp struct { + ComicId string `gorm:"index:idx_comic_id" json:"comicId"` + ID string `gorm:"primarykey" json:"id"` + UpdatedAt time.Time `json:"updated_at"` + EpOrder int32 `json:"epOrder"` + Title string `json:"title"` + FetchedPictures bool `json:"fetchedPictures"` + SelectedPictureCount int32 `json:"selectedPictureCount"` + DownloadPictureCount int32 `json:"downloadPictureCount"` + DownloadFinished bool `json:"downloadFinish"` + DownloadFinishedTime time.Time `json:"downloadFinishTime"` + DownloadFailed bool `json:"downloadFailed"` +} + +type ComicDownloadPicture struct { + ID string `gorm:"primarykey" json:"id"` + ComicId string `gorm:"index:idx_comic_id" json:"comicId"` + EpId string `gorm:"index:idx_ep_id" json:"epId"` + EpOrder int32 `gorm:"index:idx_ep_order" json:"epOrder"` + RankInEp int32 `json:"rankInEp"` + DownloadFinished bool `json:"downloadFinish"` + DownloadFinishedTime time.Time `json:"downloadFinishTime"` + DownloadFailed bool `json:"downloadFailed"` + OriginalName string + FileServer string `gorm:"index:idx_fp,priority:1" json:"fileServer"` + Path string `gorm:"index:idx_fp,priority:2" json:"path"` + FileSize int64 `json:"fileSize"` + Format string `json:"format"` + Width int32 `json:"width"` + Height int32 `json:"height"` + LocalPath string `json:"localPath"` +} diff --git a/go/pikapi/database/network_cache/cache.go b/go/pikapi/database/network_cache/cache.go new file mode 100644 index 0000000..a05076b --- /dev/null +++ b/go/pikapi/database/network_cache/cache.go @@ -0,0 +1,99 @@ +package network_cache + +import ( + "errors" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "path" + "pgo/pikapi/const_value" + "sync" + "time" +) + +var mutex = sync.Mutex{} +var db *gorm.DB + +type NetworkCache struct { + gorm.Model + K string `gorm:"index:uk_k,unique"` + V string +} + +func InitDBConnect(databaseDir string) { + mutex.Lock() + defer mutex.Unlock() + var err error + db, err = gorm.Open(sqlite.Open(path.Join(databaseDir, "network_cache.db")), const_value.GormConfig) + if err != nil { + panic("failed to connect database") + } + db.AutoMigrate(&NetworkCache{}) +} + +func LoadCache(key string, expire time.Duration) string { + mutex.Lock() + defer mutex.Unlock() + var cache NetworkCache + err := db.First(&cache, "k = ? AND updated_at > ?", key, time.Now().Add(expire*-1)).Error + if err == nil { + return cache.V + } + if gorm.ErrRecordNotFound == err { + return "" + } + panic(errors.New("?")) +} + +func SaveCache(key string, value string) { + mutex.Lock() + defer mutex.Unlock() + db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "k"}}, + DoUpdates: clause.AssignmentColumns([]string{"created_at", "updated_at", "v"}), + }).Create(&NetworkCache{ + K: key, + V: value, + }) +} + +func RemoveCache(key string) error { + mutex.Lock() + defer mutex.Unlock() + err := db.Unscoped().Delete(&NetworkCache{}, "k = ?", key).Error + if err == gorm.ErrRecordNotFound { + return nil + } + return err +} + +func RemoveCaches(like string) error { + mutex.Lock() + defer mutex.Unlock() + err := db.Unscoped().Delete(&NetworkCache{}, "k LIKE ?", like).Error + if err == gorm.ErrRecordNotFound { + return nil + } + return err +} + +func RemoveAll() error { + mutex.Lock() + defer mutex.Unlock() + err := db.Unscoped().Delete(&NetworkCache{}, "1 = 1").Error + if err != nil { + return err + } + return db.Raw("VACUUM").Error +} + +func RemoveEarliest(earliest time.Time) error { + mutex.Lock() + defer mutex.Unlock() + err := db.Unscoped().Where("strftime('%s',updated_at) < strftime('%s',?)", earliest). + Delete(&NetworkCache{}).Error + if err != nil { + return err + } + return db.Raw("VACUUM").Error +} diff --git a/go/pikapi/database/properties/properties.go b/go/pikapi/database/properties/properties.go new file mode 100644 index 0000000..0272867 --- /dev/null +++ b/go/pikapi/database/properties/properties.go @@ -0,0 +1,122 @@ +package properties + +import ( + "errors" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" + "path" + "pgo/pikapi/const_value" + "strconv" + "sync" +) + +var mutex = sync.Mutex{} +var db *gorm.DB + +func InitDBConnect(databaseDir string) { + mutex.Lock() + defer mutex.Unlock() + var err error + db, err = gorm.Open(sqlite.Open(path.Join(databaseDir, "properties.db")), const_value.GormConfig) + if err != nil { + panic("failed to connect database") + } + db.AutoMigrate(&Property{}) +} + +type Property struct { + gorm.Model + K string `gorm:"index:uk_k,unique"` + V string +} + +func LoadProperty(name string, defaultValue string) (string, error) { + mutex.Lock() + defer mutex.Unlock() + var property Property + err := db.First(&property, "k", name).Error + if err == nil { + return property.V, nil + } + if gorm.ErrRecordNotFound == err { + return defaultValue, nil + } + panic(errors.New("?")) +} + +func SaveProperty(name string, value string) error { + mutex.Lock() + defer mutex.Unlock() + return db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "k"}}, + DoUpdates: clause.AssignmentColumns([]string{"created_at", "updated_at", "v"}), + }).Create(&Property{ + K: name, + V: value, + }).Error +} + +func LoadBoolProperty(name string, defaultValue bool) (bool, error) { + stringValue, err := LoadProperty(name, strconv.FormatBool(defaultValue)) + if err != nil { + return false, err + } + return strconv.ParseBool(stringValue) +} + +func SaveBoolProperty(name string, value bool) error { + return SaveProperty(name, strconv.FormatBool(value)) +} + +func SaveSwitchAddress(value string) error { + return SaveProperty("switch_address", value) +} + +func LoadSwitchAddress() (string, error) { + return LoadProperty("switch_address", "") +} + +func SaveProxy(value string) error { + return SaveProperty("proxy", value) +} + +func LoadProxy() (string, error) { + return LoadProperty("proxy", "") +} + +func SaveUsername(value string) error { + return SaveProperty("username", value) +} + +func LoadUsername() (string, error) { + return LoadProperty("username", "") +} + +func SavePassword(value string) error { + return SaveProperty("password", value) +} + +func LoadPassword() (string, error) { + return LoadProperty("password", "") +} + +func SaveToken(value string) { + SaveProperty("token", value) +} + +func LoadToken() (string, error) { + return LoadProperty("token", "") +} + +func SaveTokenTime(value int64) { + SaveProperty("token_time", strconv.FormatInt(value, 10)) +} + +func LoadTokenTime() (int64, error) { + str, err := LoadProperty("token_time", "0") + if err != nil { + return 0, err + } + return strconv.ParseInt(str, 10, 64) +} diff --git a/go/pikapi/utils/file.go b/go/pikapi/utils/file.go new file mode 100644 index 0000000..fb8f05f --- /dev/null +++ b/go/pikapi/utils/file.go @@ -0,0 +1,19 @@ +package utils + +import ( + "os" + "pgo/pikapi/const_value" +) + +func Mkdir(dir string) { + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + err = os.MkdirAll(dir, const_value.CreateDirMode) + if err != nil { + panic(err) + } + } else { + panic(err) + } + } +} diff --git a/go/pikapi/utils/mutex.go b/go/pikapi/utils/mutex.go new file mode 100644 index 0000000..9f73309 --- /dev/null +++ b/go/pikapi/utils/mutex.go @@ -0,0 +1,21 @@ +package utils + +import ( + "hash/fnv" + "sync" +) + +var hashMutex []*sync.Mutex + +func init() { + for i := 0; i < 32; i++ { + hashMutex = append(hashMutex, &sync.Mutex{}) + } +} + +// HashLock Hash一样的图片不同时处理 +func HashLock(key string) *sync.Mutex { + hash := fnv.New32() + hash.Write([]byte(key)) + return hashMutex[int(hash.Sum32()%uint32(len(hashMutex)))] +} diff --git a/go/pikapi/utils/time.go b/go/pikapi/utils/time.go new file mode 100644 index 0000000..6dc3864 --- /dev/null +++ b/go/pikapi/utils/time.go @@ -0,0 +1,7 @@ +package utils + +import "time" + +func Timestamp() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} diff --git a/images/categories_screen.png b/images/categories_screen.png new file mode 100644 index 0000000..c460124 Binary files /dev/null and b/images/categories_screen.png differ diff --git a/images/comic_list.png b/images/comic_list.png new file mode 100644 index 0000000..3fae13c Binary files /dev/null and b/images/comic_list.png differ diff --git a/images/download_list_screen.png b/images/download_list_screen.png new file mode 100644 index 0000000..b57617c Binary files /dev/null and b/images/download_list_screen.png differ diff --git a/images/exporting.png b/images/exporting.png new file mode 100644 index 0000000..c5b8669 Binary files /dev/null and b/images/exporting.png differ diff --git a/images/exporting2.png b/images/exporting2.png new file mode 100644 index 0000000..9df3ff5 Binary files /dev/null and b/images/exporting2.png differ diff --git a/images/game.png b/images/game.png new file mode 100644 index 0000000..c7a1d5a Binary files /dev/null and b/images/game.png differ diff --git a/images/games.png b/images/games.png new file mode 100644 index 0000000..04113e1 Binary files /dev/null and b/images/games.png differ diff --git a/images/platforms.png b/images/platforms.png new file mode 100644 index 0000000..c7d517e Binary files /dev/null and b/images/platforms.png differ diff --git a/images/reader.png b/images/reader.png new file mode 100644 index 0000000..b4ee774 Binary files /dev/null and b/images/reader.png differ diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..151026b --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,33 @@ +*.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/ephemeral/ +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 new file mode 100644 index 0000000..9367d48 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + 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 new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..1e8c3c9 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '9.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..24b1049 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - Flutter (1.0.0) + - "permission_handler (5.1.0+2)": + - Flutter + - url_launcher (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - permission_handler (from `.symlinks/plugins/permission_handler/ios`) + - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + permission_handler: + :path: ".symlinks/plugins/permission_handler/ios" + url_launcher: + :path: ".symlinks/plugins/url_launcher/ios" + +SPEC CHECKSUMS: + Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c + permission_handler: ccb20a9fad0ee9b1314a52b70b76b473c5f8dab0 + url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef + +PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c + +COCOAPODS: 1.10.1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..36c179e --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,555 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 50; + objects = { + +/* Begin PBXBuildFile section */ + 0E44DEFD92B805627806403C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 605DB0C59210B25A843453FD /* Pods_Runner.framework */; }; + 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 */; }; + DDEFBAAB26AAE3AA00159A13 /* Pikapi.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDEFBAAA26AAE3AA00159A13 /* Pikapi.framework */; }; +/* 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 */ + 1001C50AAB0DFA884ACAD48C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 3742BDBA4B7EA3162E2CDC75 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 605DB0C59210B25A843453FD /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; + CA7EB5DA1FDE22BAC5B01D77 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + DDEFBAAA26AAE3AA00159A13 /* Pikapi.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Pikapi.framework; path = ../go/mobile/lib/Pikapi.framework; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DDEFBAAB26AAE3AA00159A13 /* Pikapi.framework in Frameworks */, + 0E44DEFD92B805627806403C /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 6CBB90743F7578DFC9C6BF75 /* Pods */ = { + isa = PBXGroup; + children = ( + 3742BDBA4B7EA3162E2CDC75 /* Pods-Runner.debug.xcconfig */, + 1001C50AAB0DFA884ACAD48C /* Pods-Runner.release.xcconfig */, + CA7EB5DA1FDE22BAC5B01D77 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + DDEFBAAA26AAE3AA00159A13 /* Pikapi.framework */, + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 6CBB90743F7578DFC9C6BF75 /* Pods */, + F6DB48AA376F5D49016BEA7A /* Frameworks */, + ); + 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 = ""; + }; + F6DB48AA376F5D49016BEA7A /* Frameworks */ = { + isa = PBXGroup; + children = ( + 605DB0C59210B25A843453FD /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 6D683F8ECDB7CFFB7E7E554B /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A66AE356A44E049B8DF0FD4F /* [CP] Embed Pods Frameworks */, + ); + 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"; + }; + 6D683F8ECDB7CFFB7E7E554B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 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"; + }; + A66AE356A44E049B8DF0FD4F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* 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; + FRAMEWORK_SEARCH_PATHS = "../go/mobile/lib/**"; + 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 = 10.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; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = niuhuan.pikapi; + 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; + FRAMEWORK_SEARCH_PATHS = "../go/mobile/lib/**"; + 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 = 10.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; + FRAMEWORK_SEARCH_PATHS = "../go/mobile/lib/**"; + 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 = 10.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + 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; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = niuhuan.pikapi; + 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; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = niuhuan.pikapi; + 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 new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..a28140c --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..3fd07f4 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,104 @@ +import UIKit +import Flutter +import Pikapi + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] + + MobileInitApplication(documentsPath) + + let controller = self.window.rootViewController as! FlutterViewController + let channel = FlutterMethodChannel.init(name: "method", binaryMessenger: controller as! FlutterBinaryMessenger) + + channel.setMethodCallHandler { (call, result) in + Thread { + if call.method == "flatInvoke" { + if let args = call.arguments as? Dictionary, + let method = args["method"] as? String, + let params = args["params"] as? String{ + var error: NSError? + let data = MobileFlatInvoke(method, params, &error) + if error != nil { + result(FlutterError(code: "", message: error?.localizedDescription, details: "")) + }else{ + result(data) + } + }else{ + result(FlutterError(code: "", message: "params error", details: "")) + } + } + else if call.method == "iosSaveFileToImage"{ + if let args = call.arguments as? Dictionary, + let path = args["path"] as? String{ + + do { + let fileURL: URL = URL(fileURLWithPath: path) + let imageData = try Data(contentsOf: fileURL) + + if let uiImage = UIImage(data: imageData) { + UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil) + result("OK") + }else{ + result(FlutterError(code: "", message: "Error loading image ", details: "")) + } + + } catch { + result(FlutterError(code: "", message: "Error loading image : \(error)", details: "")) + } + + }else{ + result(FlutterError(code: "", message: "params error", details: "")) + } + } + else{ + result(FlutterMethodNotImplemented) + } + }.start() + } + + // + let eventChannel = FlutterEventChannel.init(name: "flatEvent", binaryMessenger: controller as! FlutterBinaryMessenger) + + class EventChannelHandler:NSObject, FlutterStreamHandler { + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + objc_sync_enter(mutex) + sink = events + objc_sync_exit(mutex) + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + objc_sync_enter(mutex) + sink = nil + objc_sync_exit(mutex) + return nil + } + } + class EventNotifyHandler:NSObject, MobileEventNotifyHandlerProtocol { + func onNotify(_ message: String?) { + objc_sync_enter(mutex) + if sink != nil { + sink?(message) + } + objc_sync_exit(mutex) + } + } + eventChannel.setStreamHandler(EventChannelHandler.init()) + MobileEventNotify(EventNotifyHandler.init()) + + // + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + + +var sink : FlutterEventSink? +let mutex = NSObject.init() + diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "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 new file mode 100644 index 0000000..ea51e74 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png 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 new file mode 100644 index 0000000..9b01d66 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png 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 new file mode 100644 index 0000000..7521e9c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png 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 new file mode 100644 index 0000000..3d276e7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png 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 new file mode 100644 index 0000000..af7aea3 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png 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 new file mode 100644 index 0000000..4bd9511 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png 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 new file mode 100644 index 0000000..9584d06 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png 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 new file mode 100644 index 0000000..7521e9c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png 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 new file mode 100644 index 0000000..927ef0f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png 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 new file mode 100644 index 0000000..3facad9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png 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 new file mode 100644 index 0000000..3facad9 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png 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 new file mode 100644 index 0000000..9db0248 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png 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 new file mode 100644 index 0000000..d4d448b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png 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 new file mode 100644 index 0000000..ad6423c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png 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 new file mode 100644 index 0000000..c920d8e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "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 new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# 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 new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..7acbd3b --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,47 @@ + + + + + NSPhotoLibraryUsageDescription + Save images + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + pikapi + 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 new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/lib/assets/android.svg b/lib/assets/android.svg new file mode 100644 index 0000000..8a843bd --- /dev/null +++ b/lib/assets/android.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/assets/apple.svg b/lib/assets/apple.svg new file mode 100644 index 0000000..73630c9 --- /dev/null +++ b/lib/assets/apple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/assets/books.svg b/lib/assets/books.svg new file mode 100644 index 0000000..98b1894 --- /dev/null +++ b/lib/assets/books.svg @@ -0,0 +1,33 @@ + + + + image/svg+xmlbooks - lineart2016-04-03Frank Tremmelbooksbookreadingeducationteachingschoolbuchbücherunterrichtschuleline artoutlineline art books + + Ebene 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/assets/error.png b/lib/assets/error.png new file mode 100644 index 0000000..690d3fa Binary files /dev/null and b/lib/assets/error.png differ diff --git a/lib/assets/gamepad.svg b/lib/assets/gamepad.svg new file mode 100644 index 0000000..11d887a --- /dev/null +++ b/lib/assets/gamepad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/assets/github.svg b/lib/assets/github.svg new file mode 100644 index 0000000..aa05db9 --- /dev/null +++ b/lib/assets/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/assets/init.jpg b/lib/assets/init.jpg new file mode 100644 index 0000000..fc8e849 Binary files /dev/null and b/lib/assets/init.jpg differ diff --git a/lib/assets/random.svg b/lib/assets/random.svg new file mode 100644 index 0000000..3b14e3b --- /dev/null +++ b/lib/assets/random.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/lib/assets/rankings.svg b/lib/assets/rankings.svg new file mode 100644 index 0000000..e7f4836 --- /dev/null +++ b/lib/assets/rankings.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/assets/unknown.svg b/lib/assets/unknown.svg new file mode 100644 index 0000000..eab3a80 --- /dev/null +++ b/lib/assets/unknown.svg @@ -0,0 +1,6 @@ + + + + diff --git a/lib/basic/Channels.dart b/lib/basic/Channels.dart new file mode 100644 index 0000000..229e017 --- /dev/null +++ b/lib/basic/Channels.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +var _eventChannel = EventChannel("flatEvent"); +StreamSubscription? _eventChannelListen; + +Map _eventMap = {}; + +void registerEvent(void Function(String args) eventHandler, String eventName) { + if (_eventMap.containsKey(eventHandler)) { + throw 'once register'; + } + _eventMap[eventHandler] = eventName; + if (_eventMap.length == 1) { + _eventChannelListen = + _eventChannel.receiveBroadcastStream().listen(_onFlatEvent); + } +} + +void unregisterEvent(void Function(String args) eventHandler) { + if (!_eventMap.containsKey(eventHandler)) { + throw 'no register'; + } + _eventMap.remove(eventHandler); + if (_eventMap.length == 0) { + _eventChannelListen?.cancel(); + } +} + +void _onFlatEvent(dynamic t) { + _FlatEvent e = _FlatEvent.fromJson(jsonDecode(t)); + _eventMap.forEach((key, value) { + if (value == e.function) { + key(e.content); + } + }); +} + +class _FlatEvent { + late String function; + late String content; + + _FlatEvent.fromJson(Map json) { + this.function = json["function"]; + this.content = json["content"]; + } +} diff --git a/lib/basic/Common.dart b/lib/basic/Common.dart new file mode 100644 index 0000000..a6bcd75 --- /dev/null +++ b/lib/basic/Common.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_styled_toast/flutter_styled_toast.dart'; + +double coverWidth = 210; +double coverHeight = 315; + +String categoryTitle(String? categoryTitle) { + return categoryTitle ?? "全分类"; +} + +/// 显示一个toast +void defaultToast(BuildContext context, String title) { + showToast( + title, + context: context, + position: StyledToastPosition.center, + animation: StyledToastAnimation.scale, + reverseAnimation: StyledToastAnimation.fade, + duration: Duration(seconds: 4), + animDuration: Duration(seconds: 1), + curve: Curves.elasticOut, + reverseCurve: Curves.linear, + ); +} + +/// 显示一个确认框, 用户关闭弹窗以及选择否都会返回false, 仅当用户选择确定时返回true +Future confirmDialog( + BuildContext context, String title, String content) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: new SingleChildScrollView( + child: new ListBody( + children: [ + new Text(content), + ], + ), + ), + actions: [ + new MaterialButton( + child: new Text('取消'), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + new MaterialButton( + child: new Text('确定'), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ], + )) ?? + false; +} + +/// 显示一个消息提示框 +Future alertDialog(BuildContext context, String title, String content) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: new SingleChildScrollView( + child: new ListBody( + children: [ + new Text(content), + ], + ), + ), + actions: [ + new MaterialButton( + child: new Text('确定'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + )); +} + +/// stream-filter的替代方法 +List filteredList(List list, bool Function(T) filter) { + List result = []; + list.forEach((element) { + if (filter(element)) { + result.add(element); + } + }); + return result; +} + +/// 创建一个单选对话框, 用户取消选择返回null, 否则返回所选内容 +Future chooseListDialog( + BuildContext context, + String title, + List items, +) async { + return showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(title), + children: items + .map((e) => SimpleDialogOption( + onPressed: () { + Navigator.of(context).pop(e); + }, + child: Text('$e'), + )) + .toList(), + ); + }, + ); +} + +/// 创建一个单选对话框, 用户取消选择返回null, 否则返回所选内容(value) +Future chooseMapDialog( + BuildContext buildContext, Map values, String title) async { + return await showDialog( + context: buildContext, + builder: (BuildContext context) { + return SimpleDialog( + title: Text(title), + children: values.entries + .map((e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + )) + .toList(), + ); + }, + ); +} + +/// 输入对话框1 + +var _controller = TextEditingController.fromValue(TextEditingValue(text: '')); + +Future displayTextInputDialog( + BuildContext context, + String title, + String hint, + String src, + String desc, +) { + _controller.text = src; + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: ListBody( + children: [ + TextField( + controller: _controller, + decoration: InputDecoration(hintText: hint), + ), + desc.isEmpty + ? Container() + : Container( + padding: EdgeInsets.only(top: 20, bottom: 10), + child: Text( + desc, + style: TextStyle( + fontSize: 12, + color: Theme.of(context) + .textTheme + .bodyText1 + ?.color + ?.withOpacity(.5)), + ), + ), + ], + ), + ), + actions: [ + MaterialButton( + child: Text('取消'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + MaterialButton( + child: Text('确认'), + onPressed: () { + Navigator.of(context).pop(_controller.text); + }, + ), + ], + ); + }, + ); +} + +/// 将字符串前面加0直至满足len位 +String add0(int num, int len) { + var rsp = "$num"; + while (rsp.length < len) { + rsp = "0$rsp"; + } + return rsp; +} + +/// 格式化时间 2012-34-56 +String formatTimeToDate(String str) { + try { + var c = DateTime.parse(str); + return "${add0(c.year, 4)}-${add0(c.month, 2)}-${add0(c.day, 2)}"; + } catch (e) { + return "-"; + } +} + +/// 格式化时间 2012-34-56 12:34:56 +String formatTimeToDateTime(String str) { + try { + var c = DateTime.parse(str); + return "${add0(c.year, 4)}-${add0(c.month, 2)}-${add0(c.day, 2)} ${add0(c.hour, 2)}:${add0(c.minute, 2)}"; + } catch (e) { + return "-"; + } +} + +/// 输入对话框2 + +final TextEditingController _textEditController = + TextEditingController(text: ''); + +Future inputString(BuildContext context, String title, + {String hint = ""}) async { + _textEditController.clear(); + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Card( + child: SingleChildScrollView( + child: ListBody( + children: [ + Text(title), + Container( + child: TextField( + controller: _textEditController, + decoration: new InputDecoration( + labelText: "$hint", + ), + ), + ), + ], + ), + ), + ), + actions: [ + MaterialButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text('取消'), + ), + MaterialButton( + onPressed: () { + Navigator.pop(context, _textEditController.text); + }, + child: Text('确定'), + ), + ], + ); + }, + ); +} diff --git a/lib/basic/Cross.dart b/lib/basic/Cross.dart new file mode 100644 index 0000000..9db159c --- /dev/null +++ b/lib/basic/Cross.dart @@ -0,0 +1,98 @@ +/// 与平台交互的操作 + +import 'dart:io'; +import 'package:clipboard/clipboard.dart'; +import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'Method.dart'; + +/// 复制内容到剪切板 +void copyToClipBoard(BuildContext context, String string) { + if (Platform.isWindows || Platform.isMacOS) { + FlutterClipboard.copy(string); + defaultToast(context, "已复制到剪切板"); + } else if (Platform.isAndroid) { + FlutterClipboard.copy(string); + defaultToast(context, "已复制到剪切板"); + } +} + +/// 打开web页面 +Future openUrl(String url) async { + if (await canLaunch(url)) { + await launch( + url, + forceSafariVC: false, + ); + } +} + +/// 保存图片 +Future saveImage(String path, BuildContext context) async { + Future? future; + if (Platform.isIOS) { + future = method.iosSaveFileToImage(path); + } else if (Platform.isAndroid) { + future = _saveImageAndroid(path, context); + } else if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) { + String? folder = await chooseFolder(context); + if (folder != null) { + future = method.convertImageToJPEG100(path, folder); + } + } + if (future != null) { + try { + await future; + defaultToast(context, '保存成功'); + } catch (e, s) { + print("$e\n$s"); + defaultToast(context, '保存失败'); + } + } else { + defaultToast(context, '暂不支持该平台'); + } +} + +Future _saveImageAndroid(String path, BuildContext context) async { + var p = await Permission.storage.request(); + if (!p.isGranted) { + return; + } + return method.androidSaveFileToImage(path); +} + +/// 选择一个文件夹用于保存文件 +Future chooseFolder(BuildContext context) async { + late String root; + if (Platform.isWindows) { + root = '/'; + } else if (Platform.isMacOS) { + root = '/Users'; + } else if (Platform.isLinux) { + root = '/'; + } else if (Platform.isAndroid) { + var p = await Permission.storage.request(); + if (!p.isGranted) { + return null; + } + root = '/storage/emulated/0'; + } else { + throw 'error'; + } + return FilesystemPicker.open( + title: '选择一个文件夹', + pickText: '将文件保存到这里', + context: context, + fsType: FilesystemType.folder, + rootDirectory: Directory(root), + ); +} + +void confirmCopy(BuildContext context, String content) async { + if (await confirmDialog(context, "复制", content)) { + copyToClipBoard(context, content); + } +} diff --git a/lib/basic/Entities.dart b/lib/basic/Entities.dart new file mode 100644 index 0000000..375d6a3 --- /dev/null +++ b/lib/basic/Entities.dart @@ -0,0 +1,582 @@ +class PicaImage { + late String originalName; + late String path; + late String fileServer; + + PicaImage.fromJson(Map json) { + this.originalName = json["originalName"]; + this.path = json["path"]; + this.fileServer = json["fileServer"]; + } +} + +class BasicUser { + late String id; + late String gender; + late String name; + late String title; + late bool verified; + late int exp; + late int level; + late List characters; + late PicaImage avatar; + + BasicUser.fromJson(Map json) { + this.id = json["_id"]; + this.gender = json["gender"]; + this.name = json["name"]; + this.title = json["title"]; + this.verified = json["verified"]; + this.exp = json["exp"]; + this.level = json["level"]; + this.characters = List.of(json["characters"]).map((e) => "$e").toList(); + this.avatar = PicaImage.fromJson(Map.of(json["avatar"])); + } +} + +class UserProfile extends BasicUser { + late String birthday; + late String email; + late String createdAt; + late bool isPunched; + + UserProfile.fromJson(Map json) : super.fromJson(json) { + this.birthday = json["birthday"]; + this.email = json["email"]; + this.createdAt = json["created_at"]; + this.isPunched = json["isPunched"]; + } +} + +class Page { + late int total; + late int limit; + late int page; + late int pages; + + Page.fromJson(Map json) { + this.total = json["total"]; + this.limit = json["limit"]; + this.page = json["page"]; + this.pages = json["pages"]; + } +} + +class Category { + late String id; + late String title; + late String description; + late PicaImage thumb; + late bool isWeb; + late bool active; + late String link; + + Category.fromJson(Map json) { + this.id = json["_id"]; + this.title = json["title"]; + this.description = json["description"]; + this.thumb = PicaImage.fromJson(json["thumb"]); + this.isWeb = json["isWeb"]; + this.active = json["active"]; + this.link = json["link"]; + } +} + +class ComicsPage extends Page { + late List docs; + + ComicsPage.fromJson(Map json) : super.fromJson(json) { + this.docs = List.from(json["docs"]) + .map((e) => Map.from(e)) + .map((e) => ComicSimple.fromJson(e)) + .toList(); + } +} + +class ComicSimple { + late String id; + late String title; + late String author; + late int pagesCount; + late int epsCount; + late bool finished; + late List categories; + late PicaImage thumb; + late int likesCount; + + ComicSimple.fromJson(Map json) { + this.id = json["_id"]; + this.title = json["title"]; + this.author = json["author"]; + this.pagesCount = json["pagesCount"]; + this.epsCount = json["epsCount"]; + this.finished = json["finished"]; + this.categories = List.from(json["categories"]); + this.thumb = PicaImage.fromJson(json["thumb"]); + this.likesCount = json["likesCount"]; + } +} + +class ComicInfo extends ComicSimple { + late String description; + late String chineseTeam; + late List tags; + late String updatedAt; + late String createdAt; + late bool allowDownload; + late int viewsCount; + late bool isFavourite; + late bool isLiked; + late int commentsCount; + late Creator creator; + + ComicInfo.fromJson(Map json) : super.fromJson(json) { + this.description = json["description"]; + this.chineseTeam = json["chineseTeam"]; + this.tags = List.from(json["tags"]); + this.updatedAt = (json["updated_at"]); + this.createdAt = (json["created_at"]); + this.allowDownload = json["allowDownload"]; + this.viewsCount = json["viewsCount"]; + this.isFavourite = json["isFavourite"]; + this.isLiked = json["isLiked"]; + this.commentsCount = json["commentsCount"]; + this.creator = Creator.fromJson(Map.of(json["_creator"])); + } +} + +class Creator extends BasicUser { + late String slogan; + late String role; + late String character; + + Creator.fromJson(Map json) : super.fromJson(json) { + this.slogan = json["slogan"]; + this.role = json["role"]; + this.character = json["character"]; + } +} + +class Ep { + late String id; + late String title; + late int order; + late String updatedAt; + + Ep.fromJson(Map json) { + this.id = json["_id"]; + this.title = json["title"]; + this.order = json["order"]; + this.updatedAt = (json["updated_at"]); + } +} + +class EpPage extends Page { + late List docs; + + EpPage.fromJson(Map json) : super.fromJson(json) { + this.docs = List.from(json["docs"]) + .map((e) => Map.from(e)) + .map((e) => Ep.fromJson(e)) + .toList(); + } +} + +class PicturePage extends Page { + late List docs; + + PicturePage.fromJson(Map json) : super.fromJson(json) { + this.docs = List.from(json["docs"]) + .map((e) => Map.from(e)) + .map((e) => Picture.fromJson(e)) + .toList(); + } +} + +class Picture { + late String id; + late PicaImage media; + + Picture.fromJson(Map json) { + this.id = json["_id"]; + this.media = PicaImage.fromJson(json["media"]); + } +} + +class RemoteImageData { + late int fileSize; + late String format; + late int width; + late int height; + late String finalPath; + + RemoteImageData.forData( + this.fileSize, + this.format, + this.width, + this.height, + this.finalPath, + ); + + RemoteImageData.fromJson(Map json) { + this.fileSize = json["fileSize"]; + this.format = json["format"]; + this.width = json["width"]; + this.height = json["height"]; + this.finalPath = json["finalPath"]; + } +} + +class CommentPage extends Page { + late List docs; + + CommentPage.fromJson(Map json) : super.fromJson(json) { + this.docs = List.from(json["docs"]) + .map((e) => Map.from(e)) + .map((e) => Comment.fromJson(e)) + .toList(); + } +} + +class Comment { + late String id; + late String content; + late CommentUser user; + late String comic; + late bool isTop; + late bool hide; + late String createdAt; + late int likesCount; + late int commentsCount; + late bool isLiked; + + Comment.fromJson(Map json) { + this.id = json["_id"]; + this.content = json["content"]; + this.user = CommentUser.fromJson(Map.of(json["_user"])); + this.comic = json["_comic"]; + this.isTop = json["isTop"]; + this.hide = json["hide"]; + this.createdAt = json["created_at"]; + this.likesCount = json["likesCount"]; + this.commentsCount = json["commentsCount"]; + this.isLiked = json["isLiked"]; + } +} + +class CommentUser extends BasicUser { + late String role; + + CommentUser.fromJson(Map json) : super.fromJson(json) { + this.role = json["role"]; + } +} + +class DownloadPicture { + late int rankInEp; + late String fileServer; + late String path; + late String localPath; + late int width; + late int height; + late String format; + late int fileSize; + + DownloadPicture.fromJson(Map json) { + this.rankInEp = json["rankInEp"]; + this.fileServer = json["fileServer"]; + this.path = json["path"]; + this.localPath = json["localPath"]; + this.width = json["width"]; + this.height = json["height"]; + this.format = json["format"]; + this.fileSize = json["fileSize"]; + } +} + +class ViewLog { + late String id; + late String title; + late String author; + late int pagesCount; + late int epsCount; + late bool finished; + late String categories; + late String thumbOriginalName; + late String thumbFileServer; + late String thumbPath; + late String description; + late String chineseTeam; + late String tags; + late String lastViewTime; + late int lastViewEpOrder; + late String lastViewEpTitle; + late int lastViewPictureRank; + + ViewLog.fromJson(Map json) { + this.id = json["id"]; + this.title = json["title"]; + this.author = json["author"]; + this.pagesCount = json["pagesCount"]; + this.epsCount = json["epsCount"]; + this.finished = json["finished"]; + this.categories = json["categories"]; + this.thumbOriginalName = json["thumbOriginalName"]; + this.thumbFileServer = json["thumbFileServer"]; + this.thumbPath = json["thumbPath"]; + this.description = json["description"]; + this.chineseTeam = json["chineseTeam"]; + this.tags = json["tags"]; + this.lastViewTime = json["lastViewTime"]; + this.lastViewEpOrder = json["lastViewEpOrder"]; + this.lastViewEpTitle = json["lastViewEpTitle"]; + this.lastViewPictureRank = json["lastViewPictureRank"]; + } +} + +class DownloadComic { + late String id; + late String createdAt; + late String updatedAt; + late String title; + late String author; + late int pagesCount; + late int epsCount; + late bool finished; + + late String categories; + late String thumbOriginalName; + late String thumbFileServer; + late String thumbPath; + late String thumbLocalPath; + + late String description; + late String chineseTeam; + late String tags; + late int selectedEpCount; + late int selectedPictureCount; + late int downloadEpCount; + late int downloadPictureCount; + late bool downloadFinished; + late String downloadFinishedTime; + late bool downloadFailed; + late bool deleting; + + void copy(DownloadComic other) { + this.id = other.id; + this.createdAt = other.createdAt; + this.updatedAt = other.updatedAt; + this.title = other.title; + this.author = other.author; + this.pagesCount = other.pagesCount; + this.epsCount = other.epsCount; + this.finished = other.finished; + this.categories = other.categories; + this.thumbOriginalName = other.thumbOriginalName; + this.thumbFileServer = other.thumbFileServer; + this.thumbPath = other.thumbPath; + this.description = other.description; + this.chineseTeam = other.chineseTeam; + this.tags = other.tags; + this.selectedEpCount = other.selectedEpCount; + this.selectedPictureCount = other.selectedPictureCount; + this.downloadEpCount = other.downloadEpCount; + this.downloadPictureCount = other.downloadPictureCount; + this.downloadFinished = other.downloadFinished; + this.downloadFinishedTime = other.downloadFinishedTime; + this.downloadFailed = other.downloadFailed; + this.thumbLocalPath = other.thumbLocalPath; + // this.deleting = other.deleting; + } + + DownloadComic.fromJson(Map json) { + this.id = json["id"]; + this.createdAt = (json["createdAt"]); + this.updatedAt = (json["updatedAt"]); + this.title = json["title"]; + this.author = json["author"]; + this.pagesCount = json["pagesCount"]; + this.epsCount = json["epsCount"]; + this.finished = json["finished"]; + this.categories = json["categories"]; + this.thumbOriginalName = json["thumbOriginalName"]; + this.thumbFileServer = json["thumbFileServer"]; + this.thumbPath = json["thumbPath"]; + this.description = json["description"]; + this.chineseTeam = json["chineseTeam"]; + this.tags = json["tags"]; + this.selectedEpCount = json["selectedEpCount"]; + this.selectedPictureCount = json["selectedPictureCount"]; + this.downloadEpCount = json["downloadEpCount"]; + this.downloadPictureCount = json["downloadPictureCount"]; + this.downloadFinished = json["downloadFinished"]; + this.downloadFinishedTime = json["downloadFinishedTime"]; + this.downloadFailed = json["downloadFailed"]; + this.deleting = json["deleting"]; + this.thumbLocalPath = json["thumbLocalPath"]; + } +} + +class DownloadEp { + late String comicId; + late String id; + late String updatedAt; + + late int epOrder; + late String title; + + late bool fetchedPictures; + late int selectedPictureCount; + late int downloadPictureCount; + late bool downloadFinish; + late String downloadFinishTime; + late bool downloadFailed; + + DownloadEp.fromJson(Map json) { + this.comicId = json["comicId"]; + this.id = json["id"]; + this.epOrder = json["epOrder"]; + this.title = json["title"]; + + this.fetchedPictures = json["fetchedPictures"]; + this.selectedPictureCount = json["selectedPictureCount"]; + this.downloadPictureCount = json["downloadPictureCount"]; + this.downloadFinish = json["downloadFinish"]; + this.downloadFinishTime = json["downloadFinishTime"]; + this.downloadFailed = json["downloadFailed"]; + } +} + +class GamePage extends Page { + late List docs; + + GamePage.fromJson(Map json) : super.fromJson(json) { + this.docs = List.of(json["docs"]) + .map((e) => Map.of(e)) + .map((e) => GameSimple.fromJson(e)) + .toList(); + } +} + +class GameSimple { + late String id; + late String title; + late String version; + late PicaImage icon; + late String publisher; + late bool adult; + late bool suggest; + late int likesCount; + late bool android; + late bool ios; + + GameSimple.fromJson(Map json) { + this.id = json["_id"]; + this.title = json["title"]; + this.version = json["version"]; + this.icon = PicaImage.fromJson(json["icon"]); + this.publisher = json["publisher"]; + this.adult = json["adult"]; + this.suggest = json["suggest"]; + this.likesCount = json["likesCount"]; + this.android = json["android"]; + this.ios = json["ios"]; + } +} + +class GameInfo extends GameSimple { + late String description; + late String updateContent; + late String videoLink; + late List screenshots; + late int commentsCount; + late int downloadsCount; + late bool isLiked; + late List androidLinks; + late double androidSize; + late List iosLinks; + late double iosSize; + late String updatedAt; + late String createdAt; + + GameInfo.fromJson(Map json) : super.fromJson(json) { + this.description = json["description"]; + this.updateContent = json["updateContent"]; + this.videoLink = json["videoLink"]; + this.screenshots = List.of(json["screenshots"]) + .map((e) => Map.of(e)) + .map((e) => PicaImage.fromJson(e)) + .toList(); + this.commentsCount = json["commentsCount"]; + this.downloadsCount = json["downloadsCount"]; + this.isLiked = json["isLiked"]; + this.androidLinks = List.of(json["androidLinks"]).map((e) => "$e").toList(); + this.androidSize = double.parse(json["androidSize"].toString()); + this.iosLinks = List.of(json["iosLinks"]).map((e) => "$e").toList(); + this.iosSize = double.parse(json["iosSize"].toString()); + this.updatedAt = json["updated_at"]; + this.createdAt = json["created_at"]; + } +} + +class MyCommentsPage extends Page { + late List docs; + + MyCommentsPage.fromJson(Map json) : super.fromJson(json) { + this.docs = + List.of(json["docs"]).map((e) => MyComment.fromJson(e)).toList(); + } +} + +class MyComment { + late String id; + late String content; + late bool hide; + late String createdAt; + late int likesCount; + late int commentsCount; + late bool isLiked; + late MyCommentComic comic; + + MyComment.fromJson(Map json) { + this.id = json["_id"]; + this.content = json["content"]; + this.hide = json["hide"]; + this.createdAt = json["created_at"]; + this.likesCount = json["likesCount"]; + this.commentsCount = json["commentsCount"]; + this.isLiked = json["isLiked"]; + this.comic = MyCommentComic.fromJson(json["_comic"]); + } +} + +class MyCommentComic { + late String id; + late String title; + + MyCommentComic.fromJson(Map json) { + this.id = json["_id"]; + this.title = json["title"]; + } +} + +class CommentChildrenPage extends Page { + late List docs; + + CommentChildrenPage.fromJson(Map json) + : super.fromJson(json) { + this.docs = []; + if (json["docs"] != null) { + docs.addAll( + List.of(json["docs"]).map((e) => CommentChild.fromJson(e)).toList()); + } + } +} + +class CommentChild extends Comment { + late String parent; + + CommentChild.fromJson(Map json) : super.fromJson(json) { + this.parent = json["_parent"]; + } +} diff --git a/lib/basic/Method.dart b/lib/basic/Method.dart new file mode 100644 index 0000000..a903c8a --- /dev/null +++ b/lib/basic/Method.dart @@ -0,0 +1,527 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/config/Quality.dart'; + +final method = Method._(); + +class Method { + Method._(); + + MethodChannel _channel = MethodChannel("method"); + + Future _flatInvoke(String method, dynamic params) { + return _channel.invokeMethod("flatInvoke", { + "method": method, + "params": params is String ? params : jsonEncode(params), + }); + } + + Future loadTheme() async { + return await _flatInvoke("loadProperty", { + "name": "theme", + "defaultValue": "pink", + }); + } + + Future saveTheme(String code) async { + return await _flatInvoke("saveProperty", { + "name": "theme", + "value": code, + }); + } + + Future loadProperty(String propertyName, String defaultValue) async { + return await _flatInvoke("loadProperty", { + "name": propertyName, + "defaultValue": defaultValue, + }); + } + + Future saveProperty(String propertyName, String value) { + return _flatInvoke("saveProperty", { + "name": propertyName, + "value": value, + }); + } + + Future loadQuality() async { + return await _flatInvoke("loadProperty", { + "name": "quality", + "defaultValue": ImageQualityOriginal, + }); + } + + Future saveQuality(String code) async { + return await _flatInvoke("saveProperty", { + "name": "quality", + "value": code, + }); + } + + Future getSwitchAddress() async { + return await _flatInvoke("getSwitchAddress", ""); + } + + Future setSwitchAddress(String switchAddress) async { + return await _flatInvoke("setSwitchAddress", switchAddress); + } + + Future getProxy() async { + return await _flatInvoke("getProxy", ""); + } + + Future setProxy(String proxy) async { + return await _flatInvoke("setProxy", proxy); + } + + Future getUsername() async { + return await _flatInvoke("getUsername", ""); + } + + Future setUsername(String username) async { + return await _flatInvoke("setUsername", username); + } + + Future getPassword() async { + return await _flatInvoke("getPassword", ""); + } + + Future setPassword(String password) async { + return await _flatInvoke("setPassword", password); + } + + Future preLogin() async { + String rsp = await _flatInvoke("preLogin", ""); + return rsp == "true"; + } + + Future login() async { + return _flatInvoke("login", ""); + } + + Future register( + String email, + String name, + String password, + String gender, + String birthday, + String question1, + String answer1, + String question2, + String answer2, + String question3, + String answer3) { + return _flatInvoke("register", { + "email": email, + "name": name, + "password": password, + "gender": gender, + "birthday": birthday, + "question1": question1, + "answer1": answer1, + "question2": question2, + "answer2": answer2, + "question3": question3, + "answer3": answer3, + }); + } + + Future clearToken() { + return _flatInvoke("clearToken", ""); + } + + Future userProfile() async { + String rsp = await _flatInvoke("userProfile", ""); + return UserProfile.fromJson(json.decode(rsp)); + } + + Future punchIn() { + return _flatInvoke("punchIn", ""); + } + + Future remoteImageData( + String fileServer, String path) async { + var data = await _flatInvoke("remoteImageData", { + "fileServer": fileServer, + "path": path, + }); + return RemoteImageData.fromJson(json.decode(data)); + } + + Future downloadImagePath(String path) async { + return await _flatInvoke("downloadImagePath", path); + } + + Future> categories() async { + String rsp = await _flatInvoke("categories", ""); + List list = json.decode(rsp); + return list.map((e) => Category.fromJson(e)).toList(); + } + + Future comics( + String sort, + int page, { + String category = "", + String tag = "", + String creatorId = "", + String chineseTeam = "", + }) async { + String rsp = await _flatInvoke("comics", { + "category": category, + "tag": tag, + "creatorId": creatorId, + "chineseTeam": chineseTeam, + "sort": sort, + "page": page, + }); + return ComicsPage.fromJson(json.decode(rsp)); + } + + Future searchComics(String keyword, String sort, int page) { + return searchComicsInCategories(keyword, sort, page, []); + } + + Future searchComicsInCategories( + String keyword, String sort, int page, List categories) async { + String rsp = await _flatInvoke("searchComics", { + "keyword": keyword, + "sort": sort, + "page": page, + "categories": categories, + }); + return ComicsPage.fromJson(json.decode(rsp)); + } + + Future> randomComics() async { + String data = await _flatInvoke("randomComics", ""); + return List.of(jsonDecode(data)) + .map((e) => Map.of(e)) + .map((e) => ComicSimple.fromJson(e)) + .toList(); + } + + Future> leaderboard(String type) async { + String data = await _flatInvoke("leaderboard", type); + return List.of(jsonDecode(data)) + .map((e) => Map.of(e)) + .map((e) => ComicSimple.fromJson(e)) + .toList(); + } + + Future comicInfo(String comicId) async { + String rsp = await _flatInvoke("comicInfo", comicId); + return ComicInfo.fromJson(json.decode(rsp)); + } + + Future comicEpPage(String comicId, int page) async { + String rsp = await _flatInvoke("comicEpPage", { + "comicId": comicId, + "page": page, + }); + return EpPage.fromJson(json.decode(rsp)); + } + + Future comicPicturePageWithQuality( + String comicId, int epOrder, int page, String quality) async { + String data = await _flatInvoke("comicPicturePageWithQuality", { + "comicId": comicId, + "epOrder": epOrder, + "page": page, + "quality": quality, + }); + return PicturePage.fromJson(json.decode(data)); + } + + Future switchLike(String comicId) async { + return await _flatInvoke("switchLike", comicId); + } + + Future switchFavourite(String comicId) async { + return await _flatInvoke("switchFavourite", comicId); + } + + Future favouriteComics(String sort, int page) async { + var rsp = await _flatInvoke("favouriteComics", { + "sort": sort, + "page": page, + }); + return ComicsPage.fromJson(json.decode(rsp)); + } + + Future> recommendation(String comicId) async { + String rsp = await _flatInvoke("recommendation", comicId); + List list = json.decode(rsp); + return list.map((e) => ComicSimple.fromJson(e)).toList(); + } + + Future postComment(String comicId, String content) { + return _flatInvoke("postComment", { + "comicId": comicId, + "content": content, + }); + } + + Future postChildComment(String commentId, String content) { + return _flatInvoke("postChildComment", { + "commentId": commentId, + "content": content, + }); + } + + Future comments(String comicId, int page) async { + var rsp = await _flatInvoke("comments", { + "comicId": comicId, + "page": page, + }); + return CommentPage.fromJson(json.decode(rsp)); + } + + Future commentChildren( + String comicId, + String commentId, + int page, + ) async { + var rsp = await _flatInvoke("commentChildren", { + "comicId": comicId, + "commentId": commentId, + "page": page, + }); + return CommentChildrenPage.fromJson(json.decode(rsp)); + } + + Future myComments(int page) async { + String response = await _flatInvoke("myComments", "$page"); + print("RESPONSE"); + print(response); + return MyCommentsPage.fromJson(jsonDecode(response)); + } + + Future> viewLogPage(int offset, int limit) async { + var data = await _flatInvoke("viewLogPage", { + "offset": offset, + "limit": limit, + }); + List list = json.decode(data); + return list.map((e) => ViewLog.fromJson(e)).toList(); + } + + Future clearAllViewLog() { + return _flatInvoke("clearAllViewLog", ""); + } + + Future deleteViewLog(String id) { + return _flatInvoke("deleteViewLog", id); + } + + Future games(int page) async { + var data = await _flatInvoke("games", "$page"); + return GamePage.fromJson(json.decode(data)); + } + + Future game(String gameId) async { + var data = await _flatInvoke("game", gameId); + return GameInfo.fromJson(json.decode(data)); + } + + Future clean() { + return _flatInvoke("clean", ""); + } + + Future autoClean(String expireSec) { + return _flatInvoke("autoClean", expireSec); + } + + Future storeViewEp( + String comicId, int epOrder, String epTitle, int pictureRank) { + return _flatInvoke("storeViewEp", { + "comicId": comicId, + "epOrder": epOrder, + "epTitle": epTitle, + "pictureRank": pictureRank, + }); + } + + Future loadView(String comicId) async { + String data = await _flatInvoke("loadView", comicId); + if (data == "") { + return null; + } + return ViewLog.fromJson(jsonDecode(data)); + } + + Future downloadRunning() async { + String rsp = await _flatInvoke("downloadRunning", ""); + return rsp == "true"; + } + + Future setDownloadRunning(bool status) async { + return _flatInvoke("setDownloadRunning", "$status"); + } + + Future createDownload( + Map comic, List> epList) async { + return _flatInvoke("createDownload", { + "comic": comic, + "epList": epList, + }); + } + + Future addDownload( + Map comic, List> epList) async { + await _flatInvoke("addDownload", { + "comic": comic, + "epList": epList, + }); + } + + Future loadDownloadComic(String comicId) async { + var data = await _flatInvoke("loadDownloadComic", comicId); + // 未找到 且 未异常 + if (data == "") { + return null; + } + return DownloadComic.fromJson(json.decode(data)); + } + + Future> allDownloads() async { + var data = await _flatInvoke("allDownloads", ""); + data = jsonDecode(data); + if (data == null) { + return []; + } + List list = data; + return list.map((e) => DownloadComic.fromJson(e)).toList(); + } + + Future deleteDownloadComic(String comicId) async { + return _flatInvoke("deleteDownloadComic", comicId); + } + + Future> downloadEpList(String comicId) async { + var data = await _flatInvoke("downloadEpList", comicId); + List list = json.decode(data); + return list.map((e) => DownloadEp.fromJson(e)).toList(); + } + + Future> downloadPicturesByEpId(String epId) async { + var data = await _flatInvoke("downloadPicturesByEpId", epId); + List list = json.decode(data); + return list.map((e) => DownloadPicture.fromJson(e)).toList(); + } + + Future resetFailed() async { + return _flatInvoke("resetAllDownloads", ""); + } + + Future exportComicDownload(String comicId, String dir) { + return _flatInvoke("exportComicDownload", { + "comicId": comicId, + "dir": dir, + }); + } + + Future exportComicDownloadToJPG(String comicId, String dir) { + return _flatInvoke("exportComicDownloadToJPG", { + "comicId": comicId, + "dir": dir, + }); + } + + Future exportComicUsingSocket(String comicId) async { + return int.parse(await _flatInvoke("exportComicUsingSocket", comicId)); + } + + Future exportComicUsingSocketExit() { + return _flatInvoke("exportComicUsingSocketExit", ""); + } + + Future importComicDownload(String zipPath) { + return _flatInvoke("importComicDownload", zipPath); + } + + Future importComicDownloadUsingSocket(String addr) { + return _flatInvoke("importComicDownloadUsingSocket", addr); + } + + Future clientIpSet() async { + return await _flatInvoke("clientIpSet", ""); + } + + Future> downloadGame(String url) async { + if (url.startsWith("https://game.eroge.xyz/hhh.php")) { + var data = await _flatInvoke("downloadGame", url); + return List.of(jsonDecode(data)).map((e) => e.toString()).toList(); + } + return [url]; + } + + Future iosSaveFileToImage(String path) async { + return _channel.invokeMethod("iosSaveFileToImage", { + "path": path, + }); + } + + Future androidSaveFileToImage(String path) async { + return _channel.invokeMethod("androidSaveFileToImage", { + "path": path, + }); + } + + Future convertImageToJPEG100(String path, String dir) async { + return _flatInvoke("convertImageToJPEG100", { + "path": path, + "dir": dir, + }); + } + + Future getAutoFullScreen() async { + var value = await _flatInvoke("loadProperty", { + "name": "autoFullScreen", + "defaultValue": "false", + }); + return value == "true"; + } + + Future setAutoFullScreen(bool value) async { + return await _flatInvoke("saveProperty", { + "name": "autoFullScreen", + "value": "$value", + }); + } + + Future> getShadowCategories() async { + var value = await _flatInvoke("loadProperty", { + "name": "shadowCategories", + "defaultValue": jsonEncode([]), + }); + return List.of(jsonDecode(value)).map((e) => "$e").toList(); + } + + Future setShadowCategories(List value) { + return _flatInvoke("saveProperty", { + "name": "shadowCategories", + "value": jsonEncode(value), + }); + } + + Future> loadAndroidModes() async { + return List.of(await _channel.invokeMethod("androidGetModes")) + .map((e) => "$e") + .toList(); + } + + Future setAndroidMode(String androidDisplayMode) { + return _channel + .invokeMethod("androidSetMode", {"mode": androidDisplayMode}); + } + + Future androidGetUiMode() { + return _channel.invokeMethod("androidGetUiMode", {}); + } + + Future androidGetVersion() async { + return await _channel.invokeMethod("androidGetVersion", {}); + } +} diff --git a/lib/basic/Navigatior.dart b/lib/basic/Navigatior.dart new file mode 100644 index 0000000..b95e4ee --- /dev/null +++ b/lib/basic/Navigatior.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +final RouteObserver> routeObserver = + RouteObserver>(); + +Future navPushOrReplace( + BuildContext context, WidgetBuilder builder) async { + if (_depth < _depthMax) { + return Navigator.push( + context, + MaterialPageRoute(builder: builder), + ); + } else { + return Navigator.pushReplacement( + context, + MaterialPageRoute(builder: builder), + ); + } +} + +var navigatorObserver = _NavigatorObserver(); + +const _depthMax = 15; +var _depth = 0; + +int currentDepth() { + return _depth; +} + +class _NavigatorObserver extends NavigatorObserver { + @override + void didPop(Route route, Route? previousRoute) { + _depth--; + print("DEPTH : $_depth"); + super.didPop(route, previousRoute); + } + + @override + void didPush(Route route, Route? previousRoute) { + _depth++; + print("DEPTH : $_depth"); + super.didPush(route, previousRoute); + } +} diff --git a/lib/basic/config/Address.dart b/lib/basic/config/Address.dart new file mode 100644 index 0000000..ed61fea --- /dev/null +++ b/lib/basic/config/Address.dart @@ -0,0 +1,56 @@ +/// 分流地址 + +// addr = "172.67.7.24:443" +// addr = "104.20.180.50:443" +// addr = "172.67.208.169:443" + +import 'package:flutter/material.dart'; + +import '../Method.dart'; + +var _addresses = { + "不分流": "", + "分流1": "172.67.7.24:443", + "分流2": "104.20.180.50:443", + "分流3": "72.67.208.169:443", +}; + +late String _currentAddress; + +Future initAddress() async { + _currentAddress = await method.getSwitchAddress(); +} + +String currentAddressName() { + for (var value in _addresses.entries) { + if (value.value == _currentAddress) { + return value.key; + } + } + return ""; +} + +Future chooseAddress(BuildContext context) async { + String? choose = await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text('选择分流'), + children: [ + ..._addresses.entries.map( + (e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + ), + ), + ], + ); + }, + ); + if (choose != null) { + await method.setSwitchAddress(choose); + _currentAddress = choose; + } +} diff --git a/lib/basic/config/AndroidDisplayMode.dart b/lib/basic/config/AndroidDisplayMode.dart new file mode 100644 index 0000000..2d01748 --- /dev/null +++ b/lib/basic/config/AndroidDisplayMode.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Method.dart'; + +import '../Common.dart'; + +const _propertyName = "androidDisplayMode"; + +List modes = []; +String _androidDisplayMode = ""; + +Future initAndroidDisplayMode() async { + if (Platform.isAndroid) { + _androidDisplayMode = await method.loadProperty(_propertyName, ""); + modes = await method.loadAndroidModes(); + await _changeMode(); + } +} + +Future _changeMode() async { + await method.setAndroidMode(_androidDisplayMode); +} + +String androidDisplayModeName() { + return _androidDisplayMode; +} + +Future chooseAndroidDisplayMode(BuildContext context) async { + if (Platform.isAndroid) { + List list = [""]; + list.addAll(modes); + String? result = await chooseListDialog(context, "安卓屏幕刷新率 \n(若为置空操作重启应用生效)", list); + if (result != null) { + await method.saveProperty(_propertyName, "$result"); + _androidDisplayMode = result; + await _changeMode(); + } + } +} diff --git a/lib/basic/config/AutoClean.dart b/lib/basic/config/AutoClean.dart new file mode 100644 index 0000000..cc1737f --- /dev/null +++ b/lib/basic/config/AutoClean.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Method.dart'; + +late String _autoCleanSec; + +Future autoClean() async { + _autoCleanSec = await method.loadProperty("autoCleanSec", "${3600 * 24 * 30}"); + await method.autoClean(_autoCleanSec); +} + +var _autoCleanMap = { + "一个月前": "${3600 * 24 * 30}", + "一周前": "${3600 * 24 * 7}", + "一天前": "${3600 * 24 * 1}", +}; + +String currentAutoCleanSec() { + for (var value in _autoCleanMap.entries) { + if (value.value == _autoCleanSec) { + return value.key; + } + } + return ""; +} + +Future chooseAutoCleanSec(BuildContext context) async { + String? choose = await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text('选择分流'), + children: [ + ..._autoCleanMap.entries.map( + (e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + ), + ), + ], + ); + }, + ); + if (choose != null) { + await method.saveProperty("autoCleanSec", choose); + _autoCleanSec = choose; + } +} diff --git a/lib/basic/config/AutoFullScreen.dart b/lib/basic/config/AutoFullScreen.dart new file mode 100644 index 0000000..d166e22 --- /dev/null +++ b/lib/basic/config/AutoFullScreen.dart @@ -0,0 +1,26 @@ +/// 自动全屏 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +late bool gAutoFullScreen; + +Future initAutoFullScreen() async { + gAutoFullScreen = await method.getAutoFullScreen(); +} + +String autoFullScreenName() { + return gAutoFullScreen ? "是" : "否"; +} + +Future chooseAutoFullScreen(BuildContext context) async { + String? result = + await chooseListDialog(context, "进入阅读器自动全屏", ["是", "否"]); + if (result != null) { + var target = result == "是"; + await method.setAutoFullScreen(target); + gAutoFullScreen = target; + } +} diff --git a/lib/basic/config/ContentFailedReloadAction.dart b/lib/basic/config/ContentFailedReloadAction.dart new file mode 100644 index 0000000..af589ee --- /dev/null +++ b/lib/basic/config/ContentFailedReloadAction.dart @@ -0,0 +1,56 @@ +/// 全屏操作 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +enum ContentFailedReloadAction { + PULL_DOWN, + TOUCH_LOADER, +} + +late ContentFailedReloadAction contentFailedReloadAction; + +const _propertyName = "contentFailedReloadAction"; + +Future initContentFailedReloadAction() async { + contentFailedReloadAction = + _contentFailedReloadActionFromString(await method.loadProperty( + _propertyName, + ContentFailedReloadAction.PULL_DOWN.toString(), + )); +} + +ContentFailedReloadAction _contentFailedReloadActionFromString(String string) { + for (var value in ContentFailedReloadAction.values) { + if (string == value.toString()) { + return value; + } + } + return ContentFailedReloadAction.PULL_DOWN; +} + +Map contentFailedReloadActionMap = { + "下拉刷新": ContentFailedReloadAction.PULL_DOWN, + "点击屏幕刷新": ContentFailedReloadAction.TOUCH_LOADER, +}; + +String currentContentFailedReloadActionName() { + for (var e in contentFailedReloadActionMap.entries) { + if (e.value == contentFailedReloadAction) { + return e.key; + } + } + return ''; +} + +Future chooseContentFailedReloadAction(BuildContext context) async { + ContentFailedReloadAction? result = + await chooseMapDialog( + context, contentFailedReloadActionMap, "选择页面加载失败刷新的方式"); + if (result != null) { + await method.saveProperty(_propertyName, result.toString()); + contentFailedReloadAction = result; + } +} diff --git a/lib/basic/config/FullScreenAction.dart b/lib/basic/config/FullScreenAction.dart new file mode 100644 index 0000000..7e533bb --- /dev/null +++ b/lib/basic/config/FullScreenAction.dart @@ -0,0 +1,54 @@ +/// 全屏操作 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +enum FullScreenAction { + CONTROLLER, + TOUCH_ONCE, +} + +late FullScreenAction fullScreenAction; + +const _propertyName = "fullScreenAction"; + +Future initFullScreenAction() async { + fullScreenAction = _fullScreenActionFromString(await method.loadProperty( + _propertyName, + FullScreenAction.CONTROLLER.toString(), + )); +} + +FullScreenAction _fullScreenActionFromString(String string) { + for (var value in FullScreenAction.values) { + if (string == value.toString()) { + return value; + } + } + return FullScreenAction.CONTROLLER; +} + +Map fullScreenActionMap = { + "使用控制器": FullScreenAction.CONTROLLER, + "点击屏幕一次": FullScreenAction.TOUCH_ONCE, +}; + +String currentFullScreenActionName() { + for (var e in fullScreenActionMap.entries) { + if (e.value == fullScreenAction) { + return e.key; + } + } + return ''; +} + +Future chooseFullScreenAction(BuildContext context) async { + FullScreenAction? result = await chooseMapDialog( + context, fullScreenActionMap, "选择进入全屏的方式"); + if (result != null) { + await method.saveProperty(_propertyName, result.toString()); + fullScreenAction = result; + } +} diff --git a/lib/basic/config/FullScreenUI.dart b/lib/basic/config/FullScreenUI.dart new file mode 100644 index 0000000..37db749 --- /dev/null +++ b/lib/basic/config/FullScreenUI.dart @@ -0,0 +1,72 @@ +/// 全屏操作 + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +enum FullScreenUI { + NO, + HIDDEN_BOTTOM, + ALL, +} + +late FullScreenUI fullScreenUI; + +const _propertyName = "fullScreenUI"; + +Future initFullScreenUI() async { + fullScreenUI = _fullScreenUIFromString(await method.loadProperty( + _propertyName, + FullScreenUI.NO.toString(), + )); +} + +FullScreenUI _fullScreenUIFromString(String string) { + for (var value in FullScreenUI.values) { + if (string == value.toString()) { + return value; + } + } + return FullScreenUI.NO; +} + +Map fullScreenUIMap = { + "不使用": FullScreenUI.NO, + "去除虚拟控制器": FullScreenUI.HIDDEN_BOTTOM, + "全屏": FullScreenUI.ALL, +}; + +String currentFullScreenUIName() { + for (var e in fullScreenUIMap.entries) { + if (e.value == fullScreenUI) { + return e.key; + } + } + return ''; +} + +Future chooseFullScreenUI(BuildContext context) async { + FullScreenUI? result = await chooseMapDialog( + context, fullScreenUIMap, "选择全屏UI"); + if (result != null) { + await method.saveProperty(_propertyName, result.toString()); + fullScreenUI = result; + switchFullScreenUI(); + } +} + +void switchFullScreenUI() { + List list = [...SystemUiOverlay.values]; + switch (fullScreenUI) { + case FullScreenUI.HIDDEN_BOTTOM: + list.remove(SystemUiOverlay.bottom); + break; + case FullScreenUI.ALL: + list.clear(); + break; + } + print(fullScreenUI); + SystemChrome.setEnabledSystemUIOverlays(list); +} diff --git a/lib/basic/config/KeyboardController.dart b/lib/basic/config/KeyboardController.dart new file mode 100644 index 0000000..b53d7ae --- /dev/null +++ b/lib/basic/config/KeyboardController.dart @@ -0,0 +1,29 @@ +/// 上下键翻页 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +const propertyName = "keyboardController"; + +late bool keyboardController; + +Future initKeyboardController() async { + keyboardController = + (await method.loadProperty(propertyName, "false")) == "true"; +} + +String keyboardControllerName() { + return keyboardController ? "是" : "否"; +} + +Future chooseKeyboardController(BuildContext context) async { + String? result = + await chooseListDialog(context, "键盘控制翻页", ["是", "否"]); + if (result != null) { + var target = result == "是"; + await method.saveProperty(propertyName, "$target"); + keyboardController = target; + } +} diff --git a/lib/basic/config/ListLayout.dart b/lib/basic/config/ListLayout.dart new file mode 100644 index 0000000..9453cd3 --- /dev/null +++ b/lib/basic/config/ListLayout.dart @@ -0,0 +1,57 @@ +/// 列表页的布局 + +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import '../Common.dart'; +import '../Method.dart'; + +enum ListLayout { + INFO_CARD, + ONLY_IMAGE, + COVER_AND_TITLE, +} + +late ListLayout currentLayout; + +Future initListLayout() async { + currentLayout = _listLayoutFromString(await method.loadProperty( + _propertyName, + ListLayout.INFO_CARD.toString(), + )); +} + +const _propertyName = "listLayout"; + +var listLayoutEvent = Event(); + +const Map _listLayoutMap = { + '详情': ListLayout.INFO_CARD, + '封面': ListLayout.ONLY_IMAGE, + '封面+标题': ListLayout.COVER_AND_TITLE, +}; + +ListLayout _listLayoutFromString(String layoutString) { + for (var value in ListLayout.values) { + if (layoutString == value.toString()) { + return value; + } + } + return ListLayout.INFO_CARD; +} + +void chooseListLayout(BuildContext context) async { + ListLayout? layout = await chooseMapDialog(context, _listLayoutMap, '请选择布局'); + if (layout != null) { + await method.saveProperty(_propertyName, layout.toString()); + currentLayout = layout; + listLayoutEvent.broadcast(); + } +} + +IconButton chooseLayoutAction(BuildContext context) => IconButton( + onPressed: () { + chooseListLayout(context); + }, + icon: Icon(Icons.view_quilt), + ); + diff --git a/lib/basic/config/PagerAction.dart b/lib/basic/config/PagerAction.dart new file mode 100644 index 0000000..ece808d --- /dev/null +++ b/lib/basic/config/PagerAction.dart @@ -0,0 +1,52 @@ +/// 列表页下一页的行为 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +enum PagerAction { + CONTROLLER, + STREAM, +} + +late PagerAction currentPagerAction; + +const _propertyName = "pagerAction"; + +Future initPagerAction() async { + currentPagerAction = _pagerActionFromString(await method.loadProperty( + _propertyName, PagerAction.CONTROLLER.toString())); +} + +PagerAction _pagerActionFromString(String string) { + for (var value in PagerAction.values) { + if (string == value.toString()) { + return value; + } + } + return PagerAction.CONTROLLER; +} + +Map _pagerActionMap = { + "使用按钮": PagerAction.CONTROLLER, + "瀑布流": PagerAction.STREAM, +}; + +String currentPagerActionName() { + for (var e in _pagerActionMap.entries) { + if (e.value == currentPagerAction) { + return e.key; + } + } + return ''; +} + +Future choosePagerAction(BuildContext context) async { + PagerAction? result = + await chooseMapDialog(context, _pagerActionMap, "选择列表页加载方式"); + if (result != null) { + await method.saveProperty(_propertyName, result.toString()); + currentPagerAction = result; + } +} diff --git a/lib/basic/config/Proxy.dart b/lib/basic/config/Proxy.dart new file mode 100644 index 0000000..d765955 --- /dev/null +++ b/lib/basic/config/Proxy.dart @@ -0,0 +1,30 @@ +/// 代理设置 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +late String _currentProxy; + +Future initProxy() async { + _currentProxy = await method.getProxy(); +} + +String currentProxyName() { + return _currentProxy == "" ? "未设置" : _currentProxy; +} + +Future inputProxy(BuildContext context) async { + String? input = await displayTextInputDialog( + context, + '代理服务器', + '请输入代理服务器', + _currentProxy, + " ( 例如 socks5://127.0.0.1:1080/ ) ", + ); + if (input != null) { + await method.setProxy(input); + _currentProxy = input; + } +} diff --git a/lib/basic/config/Quality.dart b/lib/basic/config/Quality.dart new file mode 100644 index 0000000..7ff7a4f --- /dev/null +++ b/lib/basic/config/Quality.dart @@ -0,0 +1,62 @@ +/// 图片质量 + +import 'package:flutter/material.dart'; + +import '../Method.dart'; + +late String currentQualityCode; + +Future initQuality() async { + currentQualityCode = await method.loadQuality(); +} + +const ImageQualityOriginal = "original"; +const ImageQualityLow = "low"; +const ImageQualityMedium = "medium"; +const ImageQualityHigh = "high"; + +const LabelOriginal = "原图"; +const LabelLow = "低"; +const LabelMedium = "中"; +const LabelHigh = "高"; + +var _qualities = { + LabelOriginal: ImageQualityOriginal, + LabelLow: ImageQualityLow, + LabelMedium: ImageQualityMedium, + LabelHigh: ImageQualityHigh, +}; + +Future chooseQuality(BuildContext context) async { + String? code = await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text("请选择图片质量"), + children: [ + ..._qualities.entries.map( + (e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + ), + ), + ], + ); + }, + ); + if (code != null) { + method.saveQuality(code); + currentQualityCode = code; + } +} + +String currentQualityName() { + for (var e in _qualities.entries) { + if (e.value == currentQualityCode) { + return e.key; + } + } + return ''; +} diff --git a/lib/basic/config/ReaderDirection.dart b/lib/basic/config/ReaderDirection.dart new file mode 100644 index 0000000..4c95c8f --- /dev/null +++ b/lib/basic/config/ReaderDirection.dart @@ -0,0 +1,66 @@ +/// 阅读器的方向 + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Method.dart'; + +enum ReaderDirection { + TOP_TO_BOTTOM, + LEFT_TO_RIGHT, + RIGHT_TO_LEFT, +} + +late ReaderDirection gReaderDirection; + +const _propertyName = "readerDirection"; + +Future initReaderDirection() async { + gReaderDirection = _pagerDirectionFromString(await method.loadProperty( + _propertyName, ReaderDirection.TOP_TO_BOTTOM.toString())); +} + +var _types = { + '从上到下': ReaderDirection.TOP_TO_BOTTOM, + '从左到右': ReaderDirection.LEFT_TO_RIGHT, + '从右到左': ReaderDirection.RIGHT_TO_LEFT, +}; + +ReaderDirection _pagerDirectionFromString(String pagerDirectionString) { + for (var value in ReaderDirection.values) { + if (pagerDirectionString == value.toString()) { + return value; + } + } + return ReaderDirection.TOP_TO_BOTTOM; +} + +String currentReaderDirectionName() { + for (var e in _types.entries) { + if (e.value == gReaderDirection) { + return e.key; + } + } + return ''; +} + +Future choosePagerDirection(BuildContext buildContext) async { + ReaderDirection? choose = await showDialog( + context: buildContext, + builder: (BuildContext context) { + return SimpleDialog( + title: Text("选择翻页方向"), + children: _types.entries + .map((e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + )) + .toList(), + ); + }, + ); + if (choose != null) { + await method.saveProperty(_propertyName, choose.toString()); + gReaderDirection = choose; + } +} diff --git a/lib/basic/config/ReaderType.dart b/lib/basic/config/ReaderType.dart new file mode 100644 index 0000000..7b2aedd --- /dev/null +++ b/lib/basic/config/ReaderType.dart @@ -0,0 +1,67 @@ +/// 阅读器的类型 + +import 'package:flutter/material.dart'; + +import '../Method.dart'; + +late ReaderType gReaderType; + +const _propertyName = "readerType"; + +Future initReaderType() async { + gReaderType = _readerTypeFromString( + await method.loadProperty(_propertyName, ReaderType.WEB_TOON.toString())); +} + +enum ReaderType { + WEB_TOON, + WEB_TOON_ZOOM, + GALLERY, +} + +var _types = { + 'WebToon (默认)': ReaderType.WEB_TOON, + 'WebToon + 双击放大': ReaderType.WEB_TOON_ZOOM, + '相册': ReaderType.GALLERY, +}; + +ReaderType _readerTypeFromString(String pagerTypeString) { + for (var value in ReaderType.values) { + if (pagerTypeString == value.toString()) { + return value; + } + } + return ReaderType.WEB_TOON; +} + +String currentReaderTypeName() { + for (var e in _types.entries) { + if (e.value == gReaderType) { + return e.key; + } + } + return ''; +} + +Future choosePagerType(BuildContext buildContext) async { + ReaderType? t = await showDialog( + context: buildContext, + builder: (BuildContext context) { + return SimpleDialog( + title: Text("选择阅读模式"), + children: _types.entries + .map((e) => SimpleDialogOption( + child: Text(e.key), + onPressed: () { + Navigator.of(context).pop(e.value); + }, + )) + .toList(), + ); + }, + ); + if (t != null) { + await method.saveProperty(_propertyName, t.toString()); + gReaderType = t; + } +} diff --git a/lib/basic/config/ShadowCategories.dart b/lib/basic/config/ShadowCategories.dart new file mode 100644 index 0000000..aa74574 --- /dev/null +++ b/lib/basic/config/ShadowCategories.dart @@ -0,0 +1,55 @@ +/// 屏蔽的分类 + +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:multi_select_flutter/dialog/mult_select_dialog.dart'; +import 'package:multi_select_flutter/multi_select_flutter.dart'; + +import '../Method.dart'; +import '../store/Categories.dart'; + +late List shadowCategories; + +var shadowCategoriesEvent = Event(); + +Future initShadowCategories() async { + shadowCategories = await method.getShadowCategories(); +} + +Future chooseShadowCategories(BuildContext context) async { + await showDialog( + context: context, + builder: (ctx) { + var initialValue = []; + shadowCategories.forEach((element) { + if (shadowCategories.contains(element)) { + initialValue.add(element); + } + }); + return MultiSelectDialog( + title: Text('封印'), + searchHint: '搜索', + cancelText: Text('取消'), + confirmText: Text('确定'), + items: storedCategories.map((e) => MultiSelectItem(e, e)).toList(), + initialValue: initialValue, + onConfirm: (List? value) async { + if (value != null) { + await method.setShadowCategories(value); + shadowCategories = value; + shadowCategoriesEvent.broadcast(); + } + }, + ); + }, + ); +} + +Widget shadowCategoriesActionButton(BuildContext context) { + return IconButton( + onPressed: () { + chooseShadowCategories(context); + }, + icon: Icon(Icons.dnd_forwardslash), + ); +} diff --git a/lib/basic/config/Themes.dart b/lib/basic/config/Themes.dart new file mode 100644 index 0000000..c0846f3 --- /dev/null +++ b/lib/basic/config/Themes.dart @@ -0,0 +1,229 @@ +/// 主题 + +import 'dart:io'; + +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../Method.dart'; + +// 主题包 +abstract class _ThemePackage { + String code(); + + String name(); + + ThemeData themeData(); +} + +class _OriginTheme extends _ThemePackage { + @override + String code() => "origin"; + + @override + String name() => "原生"; + + @override + ThemeData themeData() => ThemeData(); +} + +class _PinkTheme extends _ThemePackage { + @override + String code() => "pink"; + + @override + String name() => "粉色"; + + @override + ThemeData themeData() => + ThemeData().copyWith( + brightness: Brightness.light, + colorScheme: ColorScheme.light( + secondary: Colors.pink.shade200, + ), + appBarTheme: AppBarTheme( + brightness: Brightness.dark, + color: Colors.pink.shade200, + iconTheme: IconThemeData( + color: Colors.white, + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + selectedItemColor: Colors.pink[300], + unselectedItemColor: Colors.grey[500], + ), + dividerColor: Colors.grey.shade200, + ); +} + +class _BlackTheme extends _ThemePackage { + @override + String code() => "black"; + + @override + String name() => "酷黑"; + + @override + ThemeData themeData() => + ThemeData().copyWith( + brightness: Brightness.light, + colorScheme: ColorScheme.light( + secondary: Colors.pink.shade200, + ), + appBarTheme: AppBarTheme( + brightness: Brightness.dark, + color: Colors.grey.shade800, + iconTheme: IconThemeData( + color: Colors.white, + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + selectedItemColor: Colors.white, + unselectedItemColor: Colors.grey[400], + backgroundColor: Colors.grey.shade800, + ), + dividerColor: Colors.grey.shade200, + ); +} + +class _DarkTheme extends _ThemePackage { + @override + String code() => "dark"; + + @override + String name() => "暗黑"; + + @override + ThemeData themeData() => + ThemeData.dark().copyWith( + colorScheme: ColorScheme.light( + secondary: Colors.pink.shade200, + ), + appBarTheme: AppBarTheme( + brightness: Brightness.dark, + color: Color(0xFF1E1E1E), + iconTheme: IconThemeData( + color: Colors.white, + ), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + selectedItemColor: Colors.white, + unselectedItemColor: Colors.grey.shade300, + backgroundColor: Colors.grey.shade900, + ), + ); +} + +final _themePackages = <_ThemePackage>[ + _OriginTheme(), + _PinkTheme(), + _BlackTheme(), + _DarkTheme(), +]; + +// 主题更换事件 +var themeEvent = Event(); + +int _androidVersion = 1; +String? _themeCode; +ThemeData? _themeData; +bool _androidNightMode = false; +bool _systemNight = false; + +String currentThemeName() { + for (var package in _themePackages) { + if (_themeCode == package.code()) { + return package.name(); + } + } + return ""; +} + +ThemeData? currentThemeData() { + return (_androidNightMode && _systemNight) + ? _themePackages[3].themeData() + : _themeData; +} + +// 根据Code选择主题, 并发送主题更换事件 +void _changeThemeByCode(String themeCode) { + for (var package in _themePackages) { + if (themeCode == package.code()) { + _themeCode = themeCode; + _themeData = package.themeData(); + break; + } + } + themeEvent.broadcast(); +} + +// 为了匹配安卓夜间模式增加的配置文件 +const _nightModePropertyName = "androidNightMode"; + +Future initTheme() async { + if (Platform.isAndroid) { + _androidVersion = await method.androidGetVersion(); + if (_androidVersion >= 29) { + _androidNightMode = + (await method.loadProperty(_nightModePropertyName, "false")) == "true"; + _systemNight = (await method.androidGetUiMode()) == "NIGHT"; + EventChannel("ui_mode").receiveBroadcastStream().listen((event) { + _systemNight = "$event" == "NIGHT"; + themeEvent.broadcast(); + }); + } + } + _changeThemeByCode(await method.loadTheme()); +} + +// 选择主题的对话框 +Future chooseTheme(BuildContext buildContext) async { + String? theme = await showDialog( + context: buildContext, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + var list = []; + if (_androidVersion >= 29) { + list.add( + SimpleDialogOption( + child: Row( + children: [ + Checkbox( + value: _androidNightMode, + onChanged: (bool? v) async { + if (v != null) { + await method.saveProperty( + _nightModePropertyName, "$v"); + _androidNightMode = v; + } + setState(() {}); + themeEvent.broadcast(); + }), + Text("随手机进入夜间模式"), + ], + ), + ), + ); + } + list.addAll(_themePackages + .map((e) => + SimpleDialogOption( + child: Text(e.name()), + onPressed: () { + Navigator.of(context).pop(e.code()); + }, + ) + )); + return SimpleDialog( + title: Text("选择主题"), + children: list, + ); + }) + }, + ); + if (theme != null) { + method.saveTheme(theme); + _changeThemeByCode(theme); + } +} diff --git a/lib/basic/config/VolumeController.dart b/lib/basic/config/VolumeController.dart new file mode 100644 index 0000000..8e27ef3 --- /dev/null +++ b/lib/basic/config/VolumeController.dart @@ -0,0 +1,28 @@ +/// 音量键翻页 + +import 'package:flutter/material.dart'; + +import '../Common.dart'; +import '../Method.dart'; + +const propertyName = "volumeController"; + +late bool volumeController; + +Future initVolumeController() async { + volumeController = (await method.loadProperty(propertyName, "false")) == "true"; +} + +String volumeControllerName() { + return volumeController ? "是" : "否"; +} + +Future chooseVolumeController(BuildContext context) async { + String? result = + await chooseListDialog(context, "音量键控制翻页", ["是", "否"]); + if (result != null) { + var target = result == "是"; + await method.saveProperty(propertyName, "$target"); + volumeController = target; + } +} diff --git a/lib/basic/enum/ErrorTypes.dart b/lib/basic/enum/ErrorTypes.dart new file mode 100644 index 0000000..c9127e5 --- /dev/null +++ b/lib/basic/enum/ErrorTypes.dart @@ -0,0 +1,15 @@ +const ERROR_TYPE_NETWORK = "NETWORK_ERROR"; + +// 错误的类型, 方便照展示和谐的提示 +String errorType(String error) { + // EXCEPTION + // Get "https://picaapi.picacomic.com/categories": net/http: TLS handshake timeout + // Get "https://picaapi.picacomic.com/comics?c=%E9%95%B7%E7%AF%87&s=ua&page=1": proxyconnect tcp: dial tcp 192.168.123.217:1080: connect: connection refused + // Get "https://picaapi.picacomic.com/comics?c=%E5%85%A8%E5%BD%A9&s=ua&page=1": context deadline exceeded (Client.Timeout exceeded while awaiting headers) + if (error.contains("timeout") || + error.contains("connection refused") || + error.contains("deadline")) { + return ERROR_TYPE_NETWORK; + } + return ""; +} diff --git a/lib/basic/enum/Sort.dart b/lib/basic/enum/Sort.dart new file mode 100644 index 0000000..9be2068 --- /dev/null +++ b/lib/basic/enum/Sort.dart @@ -0,0 +1,40 @@ +/// 官方提供的排序方式 + +import 'package:flutter/material.dart'; + +const SORT_DEFAULT = "ua"; +const SORT_TIME_NEWEST = "dd"; +const SORT_TIME_OLDEST = "da"; +const SORT_LIKE_MOST = "ld"; +const SORT_GIVE_MOST = "vd"; + +const LABEL_DEFAULT = '默认排序'; +const LABEL_TIME_NEWEST = "时间最新"; +const LABEL_TIME_OLDEST = "时间最久"; +const LABEL_LIKE_MOST = "点赞最多"; +const LABEL_GIVE_MOST = "查看最多"; + +class _Sort { + final String code; + final String label; + + _Sort.of({ + required this.code, + required this.label, + }); +} + +final sortList = [ + _Sort.of(code: SORT_DEFAULT, label: LABEL_DEFAULT), + _Sort.of(code: SORT_TIME_NEWEST, label: LABEL_TIME_NEWEST), + _Sort.of(code: SORT_TIME_OLDEST, label: LABEL_TIME_OLDEST), + _Sort.of(code: SORT_LIKE_MOST, label: LABEL_LIKE_MOST), + _Sort.of(code: SORT_GIVE_MOST, label: LABEL_GIVE_MOST), +]; + +List> items = sortList + .map((e) => DropdownMenuItem( + value: e.code, + child: Text(e.label), + )) + .toList(); diff --git a/lib/basic/store/Categories.dart b/lib/basic/store/Categories.dart new file mode 100644 index 0000000..bed9e6d --- /dev/null +++ b/lib/basic/store/Categories.dart @@ -0,0 +1,6 @@ +/// 全局配置文件, 项目启动时加载 + + +// 数据缓存 +var storedCategories = []; + diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..f2915cb --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,44 @@ +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:pikapi/screens/InitScreen.dart'; +import 'package:pikapi/basic/Navigatior.dart'; + +import 'basic/config/Themes.dart'; + +void main() { + runApp(PikachuApp()); +} + +class PikachuApp extends StatefulWidget { + const PikachuApp({Key? key}) : super(key: key); + + @override + State createState() => _PikachuAppState(); +} + +class _PikachuAppState extends State { + @override + void initState() { + themeEvent.subscribe(_onChangeTheme); + super.initState(); + } + + @override + void dispose() { + themeEvent.unsubscribe(_onChangeTheme); + super.dispose(); + } + + void _onChangeTheme(EventArgs? args) { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: currentThemeData(), + navigatorObservers: [navigatorObserver, routeObserver], + home: InitScreen(), + ); + } +} diff --git a/lib/main_desktop.dart b/lib/main_desktop.dart new file mode 100644 index 0000000..7dc7766 --- /dev/null +++ b/lib/main_desktop.dart @@ -0,0 +1,6 @@ +import 'main.dart' as original_main; + +// This file is the default main entry-point for go-flutter application. +void main() { + original_main.main(); +} diff --git a/lib/screens/AboutScreen.dart b/lib/screens/AboutScreen.dart new file mode 100644 index 0000000..209c334 --- /dev/null +++ b/lib/screens/AboutScreen.dart @@ -0,0 +1,58 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pikapi/basic/Cross.dart'; + +class AboutScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + + return Scaffold( + appBar: AppBar( + title: Text('关于'), + ), + body: ListView( + children: [ + Container( + width: min / 2, + height: min / 2, + child: Center( + child: SvgPicture.asset( + 'lib/assets/github.svg', + width: min / 3, + height: min / 3, + color: Colors.grey.shade500, + ), + ), + ), + Container( + padding: EdgeInsets.all(20), + child: Text( + '请从软件取得渠道获取更新\n本软件开源, 若您想提出改进建议或者获取源码, 请在开源社区搜索 pikapi-flutter', + style: TextStyle( + height: 1.3, + ), + ), + ), + Container( + padding: EdgeInsets.all(20), + child: SelectableText( + "提示 : \n" + "1. 详情页的作者/上传者/分类/标签都可以点击\n" + "2. 详情页的作者/上传者/标题长按可以复制\n" + "3. 使用分页而不是瀑布流点击页码可以快速翻页\n" + "4. 下载指的是缓存到本地, 需要导出才可以分享\n" + "5. 下载长按可以删除\n", + style: TextStyle( + height: 1.3, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/AccountScreen.dart b/lib/screens/AccountScreen.dart new file mode 100644 index 0000000..1a40d7c --- /dev/null +++ b/lib/screens/AccountScreen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/config/Themes.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/enum/ErrorTypes.dart'; +import 'package:pikapi/screens/RegisterScreen.dart'; +import 'package:pikapi/screens/components/NetworkSetting.dart'; + +import 'AppScreen.dart'; +import 'DownloadListScreen.dart'; +import 'components/ContentLoading.dart'; + +// 账户设置 +class AccountScreen extends StatefulWidget { + @override + _AccountScreenState createState() => _AccountScreenState(); +} + +class _AccountScreenState extends State { + late bool _logging = false; + late String _username = ""; + late String _password = ""; + + @override + void initState() { + _loadProperties(); + super.initState(); + } + + Future _loadProperties() async { + var username = await method.getUsername(); + var password = await method.getPassword(); + setState(() { + _username = username; + _password = password; + }); + } + + @override + Widget build(BuildContext context) { + if (_logging) { + return _buildLogging(); + } + return _buildGui(); + } + + Widget _buildLogging() { + return Scaffold( + body: ContentLoading(label: '登录中'), + ); + } + + Widget _buildGui() { + return Scaffold( + appBar: AppBar( + brightness: Brightness.dark, + title: Text('配置选项'), + actions: [ + IconButton( + onPressed: () { + chooseTheme(context); + }, + icon: Text('主题'), + ), + IconButton( + onPressed: _toDownloadList, + icon: Icon(Icons.download_rounded), + ), + IconButton( + onPressed: _logIn, + icon: Icon(Icons.save), + ), + ], + ), + body: ListView( + children: [ + ListTile( + title: Text("账号"), + subtitle: Text(_username == "" ? "未设置" : _username), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '账号', + '请输入账号', + _username, + "", + ); + if (input != null) { + await method.setUsername(input); + setState(() { + _username = input; + }); + } + }, + ), + ListTile( + title: Text("密码"), + subtitle: Text(_password == "" ? "未设置" : _password), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '密码', + '请输入密码', + _password, + "", + ); + if (input != null) { + await method.setPassword(input); + setState(() { + _password = input; + }); + } + }, + ), + NetworkSetting(), + Row( + children: [ + Expanded( + child: Container( + padding: EdgeInsets.all(15), + child: Text.rich(TextSpan( + text: '没有账号,我要注册', + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext context) => + RegisterScreen()), + ).then((value) => _loadProperties()), + )), + ), + ), + ], + ), + ], + ), + ); + } + + _logIn() async { + setState(() { + _logging = true; + }); + try { + await method.login(); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => AppScreen()), + ); + } catch (e, s) { + print("$e\n$s"); + setState(() { + _logging = false; + }); + alertDialog( + context, + '登录失败', + errorType("$e") == ERROR_TYPE_NETWORK ? '网络不通' : '请检查账号密码', + ); + } + } + + _toDownloadList() { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => DownloadListScreen()), + ); + } +} diff --git a/lib/screens/AppScreen.dart b/lib/screens/AppScreen.dart new file mode 100644 index 0000000..0ffc5f7 --- /dev/null +++ b/lib/screens/AppScreen.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import 'CategoriesScreen.dart'; +import 'SpaceScreen.dart'; + +// MAIN UI 底部导航栏 +class AppScreen extends StatefulWidget { + const AppScreen({Key? key}) : super(key: key); + + @override + State createState() => _AppScreenState(); +} + +class _AppScreenState extends State { + static const List _widgetOptions = [ + const CategoriesScreen(), + const SpaceScreen(), + ]; + static const _navigationItems = [ + const BottomNavigationBarItem( + icon: Icon(Icons.public), + label: '浏览', + ), + const BottomNavigationBarItem( + icon: Icon(Icons.face), + label: '我的', + ), + ]; + + late int _selectedIndex = 0; + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _selectedIndex, + children: _widgetOptions, + ), + bottomNavigationBar: BottomNavigationBar( + items: _navigationItems, + currentIndex: _selectedIndex, + iconSize: 20, + selectedFontSize: 12, + unselectedFontSize: 12, + onTap: _onItemTapped, + ), + ); + } +} diff --git a/lib/screens/CategoriesScreen.dart b/lib/screens/CategoriesScreen.dart new file mode 100644 index 0000000..f71892f --- /dev/null +++ b/lib/screens/CategoriesScreen.dart @@ -0,0 +1,287 @@ +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_search_bar/flutter_search_bar.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/store/Categories.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/screens/RankingsScreen.dart'; +import 'package:pikapi/screens/SearchScreen.dart'; +import 'package:pikapi/screens/components/ContentError.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'ComicsScreen.dart'; +import 'GamesScreen.dart'; +import 'RandomComicsScreen.dart'; +import 'components/ContentLoading.dart'; +import 'components/Images.dart'; + +// 分类 +class CategoriesScreen extends StatefulWidget { + const CategoriesScreen(); + + @override + State createState() => _CategoriesScreenState(); +} + +class _CategoriesScreenState extends State { + late SearchBar _searchBar = SearchBar( + hintText: '搜索', + inBar: false, + setState: setState, + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => SearchScreen(keyword: value), + ), + ); + } + }, + buildDefaultAppBar: (BuildContext context) { + return AppBar( + title: new Text('分类'), + actions: [ + shadowCategoriesActionButton(context), + _searchBar.getSearchAction(context), + ], + ); + }, + ); + + late Future> _categoriesFuture = _fetch(); + + Future> _fetch() async { + List categories = await method.categories(); + storedCategories = []; + categories.forEach((element) { + if (!element.isWeb) { + storedCategories.add(element.title); + } + }); + return categories; + } + + void _reloadCategories() { + setState(() { + this._categoriesFuture = _fetch(); + }); + } + + @override + void initState() { + shadowCategoriesEvent.subscribe(_onShadowChange); + super.initState(); + } + + @override + void dispose() { + shadowCategoriesEvent.unsubscribe(_onShadowChange); + super.dispose(); + } + + void _onShadowChange(EventArgs? args) { + _reloadCategories(); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var themeBackground = theme.scaffoldBackgroundColor; + var shadeBackground = Color.fromARGB( + 0x11, + 255 - themeBackground.red, + 255 - themeBackground.green, + 255 - themeBackground.blue, + ); + return Scaffold( + appBar: _searchBar.build(context), + body: Container( + color: shadeBackground, + child: FutureBuilder( + future: _categoriesFuture, + builder: + ((BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + _reloadCategories(); + }, + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + return ListView( + children: [ + Container(height: 20), + Wrap( + runSpacing: 20, + alignment: WrapAlignment.spaceAround, + children: _buildChannels(), + ), + Divider(), + Wrap( + runSpacing: 20, + alignment: WrapAlignment.spaceAround, + children: _buildCategories(snapshot.data!), + ), + Container(height: 20), + ], + ); + }), + ), + ), + ); + } + + List _buildCategories(List cList) { + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var blockSize = min / 3; + var imageSize = blockSize - 15; + var imageRs = imageSize / 10; + + List list = []; + + var append = (Widget widget, String title, Function() onTap) { + list.add( + GestureDetector( + onTap: onTap, + child: Container( + width: blockSize, + child: Column( + children: [ + Card( + elevation: .5, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(imageRs)), + child: widget, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(imageRs)), + ), + ), + Container(height: 5), + Center( + child: Text(title), + ), + ], + ), + ), + ), + ); + }; + + append( + buildSvg('lib/assets/books.svg', imageSize, imageSize, margin: 20), + "全分类", + () => _navigateToCategory(null), + ); + + for (var i = 0; i < cList.length; i++) { + var c = cList[i]; + if (c.isWeb) continue; + if (shadowCategories.contains(c.title)) continue; + append( + RemoteImage( + fileServer: c.thumb.fileServer, + path: c.thumb.path, + width: imageSize, + height: imageSize, + ), + c.title, + () => _navigateToCategory(c.title), + ); + } + + return list; + } + + List _buildChannels() { + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var blockSize = min / 3; + var imageSize = blockSize - 15; + var imageRs = imageSize / 10; + + List list = []; + + var append = (Widget widget, String title, Function() onTap) { + list.add( + GestureDetector( + onTap: onTap, + child: Container( + width: blockSize, + child: Column( + children: [ + Card( + elevation: .5, + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(imageRs)), + child: widget, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(imageRs)), + ), + ), + Container(height: 5), + Center( + child: Text(title), + ), + ], + ), + ), + ), + ); + }; + + append( + buildSvg('lib/assets/rankings.svg', imageSize, imageSize, + margin: 20, color: Colors.red.shade700), + "排行榜", + () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => RankingsScreen()), + ); + }, + ); + + append( + buildSvg('lib/assets/random.svg', imageSize, imageSize, + margin: 20, color: Colors.orangeAccent.shade700), + "随机本子", + () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => RandomComicsScreen()), + ); + }, + ); + + append( + buildSvg('lib/assets/gamepad.svg', imageSize, imageSize, + margin: 20, color: Colors.blue.shade500), + "游戏专区", + () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => GamesScreen()), + ); + }, + ); + + return list; + } + + void _navigateToCategory(String? categoryTitle) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComicsScreen(category: categoryTitle), + ), + ); + } +} diff --git a/lib/screens/CleanScreen.dart b/lib/screens/CleanScreen.dart new file mode 100644 index 0000000..bc9543a --- /dev/null +++ b/lib/screens/CleanScreen.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'components/ContentLoading.dart'; + +// 清理 +class CleanScreen extends StatefulWidget { + @override + State createState() => _CleanScreenState(); +} + +class _CleanScreenState extends State { + late bool _cleaning = false; + late String _cleaningMessage = "清理中"; + late String _cleanResult = ""; + + @override + void initState() { + registerEvent(_onMessageChange, "EXPORT"); + super.initState(); + } + + @override + void dispose() { + unregisterEvent(_onMessageChange); + super.dispose(); + } + + void _onMessageChange(String event) { + setState(() { + _cleaningMessage = event; + }); + } + + @override + Widget build(BuildContext context) { + if (_cleaning) { + return Scaffold( + body: ContentLoading(label: _cleaningMessage), + ); + } + return Scaffold( + appBar: AppBar( + title: Text('清理'), + ), + body: ListView( + children: [ + MaterialButton( + onPressed: () async { + try { + setState(() { + _cleaning = true; + }); + await method.clean(); + setState(() { + _cleanResult = "清理成功"; + }); + } catch (e) { + setState(() { + _cleanResult = "清理失败 $e"; + }); + } finally { + setState(() { + _cleaning = false; + }); + } + }, + child: Container( + padding: EdgeInsets.all(20), + child: Text('清理'), + ), + ), + Container( + padding: EdgeInsets.all(8), + child: _cleanResult != "" ? Text(_cleanResult) : Container(), + ) + ], + ), + ); + } +} diff --git a/lib/screens/ComicInfoScreen.dart b/lib/screens/ComicInfoScreen.dart new file mode 100644 index 0000000..a6a5648 --- /dev/null +++ b/lib/screens/ComicInfoScreen.dart @@ -0,0 +1,339 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/ComicsScreen.dart'; +import 'package:pikapi/basic/Navigatior.dart'; +import 'package:pikapi/screens/components/ItemBuilder.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'ComicReaderScreen.dart'; +import 'DownloadConfirmScreen.dart'; +import 'components/ComicCommentList.dart'; +import 'components/ComicDescriptionCard.dart'; +import 'components/ComicInfoCard.dart'; +import 'components/ComicTagsCard.dart'; +import 'components/ContentError.dart'; +import 'components/ContentLoading.dart'; +import 'components/ContinueReadButton.dart'; + +// 漫画详情 +class ComicInfoScreen extends StatefulWidget { + final String comicId; + + const ComicInfoScreen({Key? key, required this.comicId}) : super(key: key); + + @override + State createState() => _ComicInfoScreenState(); +} + +class _ComicInfoScreenState extends State with RouteAware { + late var _tabIndex = 0; + late Future _comicFuture = _loadComic(); + late Future _viewFuture = _loadViewLog(); + late Future> _epListFuture = _loadEps(); + + Future _loadComic() async { + return await method.comicInfo(widget.comicId); + } + + Future> _loadEps() async { + List eps = []; + var page = 0; + late EpPage rsp; + do { + rsp = await method.comicEpPage(widget.comicId, ++page); + eps.addAll(rsp.docs); + } while (rsp.page < rsp.pages); + return eps; + } + + Future _loadViewLog() { + return method.loadView(widget.comicId); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void didPopNext() { + setState(() { + _viewFuture = _loadViewLog(); + }); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _comicFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Scaffold( + appBar: AppBar(), + body: ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + setState(() { + _comicFuture = _loadComic(); + }); + }, + ), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return Scaffold( + appBar: AppBar(), + body: ContentLoading(label: '加载中'), + ); + } + var _comicInfo = snapshot.data!; + var theme = Theme.of(context); + var _tabs = [ + Tab(text: '章节 (${_comicInfo.epsCount})'), + Tab(text: '评论 (${_comicInfo.commentsCount})'), + ]; + var _views = [ + _buildEpWrap(_epListFuture, _comicInfo), + ComicCommentList(_comicInfo.id), + ]; + return DefaultTabController( + length: _tabs.length, + child: Scaffold( + appBar: AppBar( + title: Text(_comicInfo.title), + actions: [ + _buildDownloadAction(_epListFuture, _comicInfo), + ], + ), + body: ListView( + children: [ + ComicInfoCard(_comicInfo, linkItem: true), + ComicTagsCard(_comicInfo.tags), + ComicDescriptionCard(description: _comicInfo.description), + Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + children: [ + Text.rich(TextSpan( + children: [ + WidgetSpan( + child: GestureDetector( + onTap: () { + if (_comicInfo.creator.id != "") { + navPushOrReplace( + context, + (context) => ComicsScreen( + creatorId: _comicInfo.creator.id, + creatorName: _comicInfo.creator.name, + ), + ); + } + }, + onLongPress: () { + confirmCopy( + context, + "${_comicInfo.creator.name}", + ); + }, + child: Text( + "${_comicInfo.creator.name}", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + ), + ), + TextSpan( + text: " ", + style: TextStyle( + fontSize: 14, + color: Colors.grey, + ), + ), + TextSpan( + text: + "( ${formatTimeToDate(_comicInfo.updatedAt)} )", + style: TextStyle( + fontSize: 13, + color: Colors.grey, + ), + ), + ], + )), + GestureDetector( + onTap: () { + if (_comicInfo.chineseTeam != "") { + navPushOrReplace( + context, + (context) => ComicsScreen( + chineseTeam: _comicInfo.chineseTeam, + ), + ); + } + }, + onLongPress: () { + confirmCopy(context, _comicInfo.chineseTeam); + }, + child: Text( + "${_comicInfo.chineseTeam}", + style: TextStyle( + fontSize: 13, + color: Colors.grey, + ), + ), + ), + ], + ), + ), + Container(height: 5), + Container( + height: 40, + color: theme.colorScheme.secondary.withOpacity(.025), + child: TabBar( + tabs: _tabs, + indicatorColor: theme.colorScheme.secondary, + labelColor: theme.colorScheme.secondary, + onTap: (val) async { + setState(() { + _tabIndex = val; + }); + }, + ), + ), + Container(height: 15), + _views[_tabIndex], + Container(height: 5), + ], + ), + ), + ); + }, + ); + } + + Widget _buildDownloadAction( + Future> _epListFuture, + ComicInfo _comicInfo, + ) { + return FutureBuilder( + future: _epListFuture, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + return IconButton( + onPressed: () { + setState(() { + _epListFuture = _loadEps(); + }); + }, + icon: Icon(Icons.sync_problem), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return IconButton(onPressed: () {}, icon: Icon(Icons.sync)); + } + var _epList = snapshot.data!; + return IconButton( + onPressed: () async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadConfirmScreen( + comicInfo: _comicInfo, + epList: _epList.reversed.toList(), + ), + ), + ); + }, + icon: Icon(Icons.download_rounded), + ); + }, + ); + } + + Widget _buildEpWrap(Future> _epListFuture, ComicInfo _comicInfo) { + return ItemBuilder( + future: _epListFuture, + successBuilder: (BuildContext context, AsyncSnapshot> snapshot) { + var _epList = snapshot.data!; + return Column( + children: [ + ContinueReadButton( + viewFuture: _viewFuture, + onChoose: (int? epOrder, int? pictureRank) { + if (epOrder != null && pictureRank != null) { + for (var i in _epList) { + if (i.order == epOrder) { + _push(_comicInfo, _epList, epOrder, pictureRank); + return; + } + } + } else { + _push( + _comicInfo, _epList, _epList.reversed.first.order, null); + return; + } + }, + ), + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.spaceAround, + children: [ + ..._epList.map((e) { + return Container( + child: MaterialButton( + onPressed: () { + _push(_comicInfo, _epList, e.order, null); + }, + color: Colors.white, + child: + Text(e.title, style: TextStyle(color: Colors.black)), + ), + ); + }), + ], + ), + ], + ); + }, + onRefresh: () async { + setState(() { + _epListFuture = _loadEps(); + }); + }, + ); + } + + void _push(ComicInfo comicInfo, List epList, int order, int? rank) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComicReaderScreen( + comicInfo: comicInfo, + epList: epList, + currentEpOrder: order, + initPictureRank: rank, + ), + ), + ); + } +} diff --git a/lib/screens/ComicReaderScreen.dart b/lib/screens/ComicReaderScreen.dart new file mode 100644 index 0000000..d111db2 --- /dev/null +++ b/lib/screens/ComicReaderScreen.dart @@ -0,0 +1,221 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/AutoFullScreen.dart'; +import 'package:pikapi/basic/config/FullScreenUI.dart'; +import 'package:pikapi/basic/config/Quality.dart'; +import 'package:pikapi/basic/config/ReaderDirection.dart'; +import 'package:pikapi/basic/config/ReaderType.dart'; +import 'package:pikapi/screens/components/ContentBuilder.dart'; +import 'components/ImageReader.dart'; + +// 在线阅读漫画 +class ComicReaderScreen extends StatefulWidget { + final ComicInfo comicInfo; + final List epList; + final currentEpOrder; + final int? initPictureRank; + final ReaderType pagerType = gReaderType; + final ReaderDirection pagerDirection = gReaderDirection; + late final bool autoFullScreen; + + ComicReaderScreen({ + Key? key, + required this.comicInfo, + required this.epList, + required this.currentEpOrder, + this.initPictureRank, + bool? autoFullScreen, + }) : super(key: key) { + this.autoFullScreen = autoFullScreen ?? gAutoFullScreen; + } + + @override + State createState() => _ComicReaderScreenState(); +} + +class _ComicReaderScreenState extends State { + late Ep _ep; + late bool _fullScreen = false; + late Future> _future; + int? _lastChangeRank; + bool _replacement = false; + + Future> _load() async { + if (widget.initPictureRank == null) { + await method.storeViewEp(widget.comicInfo.id, _ep.order, _ep.title, 1); + } + List list = []; + var _needLoadPage = 0; + late PicturePage page; + do { + page = await method.comicPicturePageWithQuality( + widget.comicInfo.id, + widget.currentEpOrder, + ++_needLoadPage, + currentQualityCode, + ); + list.addAll(page.docs.map((element) => element.media)); + } while (page.pages > page.page); + if (widget.autoFullScreen) { + setState(() { + SystemChrome.setEnabledSystemUIOverlays([]); + _fullScreen = true; + }); + } + return list; + } + + Future _onPositionChange(int position) async { + _lastChangeRank = position + 1; + return method.storeViewEp( + widget.comicInfo.id, _ep.order, _ep.title, position + 1); + } + + String _nextText = ""; + FutureOr Function() _nextAction = () => null; + + @override + void initState() { + // NEXT + var orderMap = Map(); + widget.epList.forEach((element) { + orderMap[element.order] = element; + }); + if (orderMap.containsKey(widget.currentEpOrder + 1)) { + _nextText = "下一章"; + _nextAction = () { + _replacement = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => ComicReaderScreen( + comicInfo: widget.comicInfo, + epList: widget.epList, + currentEpOrder: widget.currentEpOrder + 1, + autoFullScreen: _fullScreen, + ), + ), + ); + }; + } else { + _nextText = "阅读结束"; + _nextAction = () => Navigator.of(context).pop(); + } + // EP + widget.epList.forEach((element) { + if (element.order == widget.currentEpOrder) { + _ep = element; + } + }); + // INIT + _future = _load(); + addVolumeListen(); + super.initState(); + } + + @override + void dispose() { + if (!_replacement) { + switchFullScreenUI(); + } + delVolumeListen(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return readerKeyboardHolder(_build(context)); + } + + Widget _build(BuildContext context) { + return Scaffold( + appBar: _fullScreen + ? null + : AppBar( + title: Text("${_ep.title} - ${widget.comicInfo.title}"), + actions: [ + IconButton( + onPressed: () async { + await choosePagerDirection(context); + if (widget.pagerDirection != gReaderDirection) { + _reloadReader(); + } + }, + icon: Icon(Icons.grid_goldenratio), + ), + IconButton( + onPressed: () async { + await choosePagerType(context); + if (widget.pagerType != gReaderType) { + _reloadReader(); + } + }, + icon: Icon(Icons.view_day_outlined), + ), + ], + ), + body: ContentBuilder( + future: _future, + onRefresh: () async { + setState(() { + _future = _load(); + }); + }, + successBuilder: + (BuildContext context, AsyncSnapshot> snapshot) { + return ImageReader( + ImageReaderStruct( + images: snapshot.data! + .map((e) => ReaderImageInfo( + e.fileServer, + e.path, + null, + null, + null, + null, + null, + )) + .toList(), + fullScreen: _fullScreen, + onFullScreenChange: _onFullScreenChange, + onNextText: _nextText, + onNextAction: _nextAction, + onPositionChange: _onPositionChange, + initPosition: widget.initPictureRank == null + ? null + : widget.initPictureRank! - 1, + pagerType: widget.pagerType, + pagerDirection: widget.pagerDirection, + ), + ); + }, + ), + ); + } + + Future _onFullScreenChange(bool fullScreen) async { + setState(() { + SystemChrome.setEnabledSystemUIOverlays( + fullScreen ? [] : SystemUiOverlay.values); + _fullScreen = fullScreen; + }); + } + + // 重新加载本页 + void _reloadReader() { + _replacement = true; + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) => ComicReaderScreen( + comicInfo: widget.comicInfo, + epList: widget.epList, + currentEpOrder: widget.currentEpOrder, + initPictureRank: _lastChangeRank ?? widget.initPictureRank, + // maybe null + autoFullScreen: _fullScreen, + ), + )); + } +} diff --git a/lib/screens/ComicsScreen.dart b/lib/screens/ComicsScreen.dart new file mode 100644 index 0000000..8e0b423 --- /dev/null +++ b/lib/screens/ComicsScreen.dart @@ -0,0 +1,158 @@ +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_search_bar/flutter_search_bar.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/basic/store/Categories.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; +import 'package:pikapi/basic/Method.dart'; +import '../basic/Entities.dart'; +import 'SearchScreen.dart'; +import 'components/ComicPager.dart'; + +// 漫画列表 +class ComicsScreen extends StatefulWidget { + final String? category; // 指定分类 + final String? tag; // 指定标签 + final String? creatorId; // 指定上传者 + final String? creatorName; // 上传者名称 (仅显示) + final String? chineseTeam; + + const ComicsScreen({ + Key? key, + this.category, + this.tag, + this.creatorId, + this.creatorName, + this.chineseTeam, + }) : super(key: key); + + @override + State createState() => _ComicsScreenState(); +} + +class _ComicsScreenState extends State { + late SearchBar _categorySearchBar = SearchBar( + hintText: '搜索分类 - ${categoryTitle(widget.category)}', + inBar: false, + setState: setState, + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SearchScreen(keyword: value, category: widget.category), + ), + ); + } + }, + buildDefaultAppBar: (BuildContext context) { + return AppBar( + title: new Text(categoryTitle(widget.category)), + actions: [ + shadowCategoriesActionButton(context), + chooseLayoutAction(context), + _chooseCategoryAction(), + _categorySearchBar.getSearchAction(context), + ], + ); + }, + ); + + @override + void initState() { + shadowCategoriesEvent.subscribe(_onShadowChange); + super.initState(); + } + + @override + void dispose() { + shadowCategoriesEvent.unsubscribe(_onShadowChange); + super.dispose(); + } + + void _onShadowChange(EventArgs? args) { + setState(() { + }); + } + + Widget _chooseCategoryAction() => IconButton( + onPressed: () async { + String? category = await chooseListDialog(context, '请选择分类', [ + categoryTitle(null), + ...filteredList( + storedCategories, + (c) => !shadowCategories.contains(c), + ), + ]); + if (category != null) { + if (category == categoryTitle(null)) { + category = null; + } + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) { + return ComicsScreen( + category: category, + tag: widget.tag, + creatorId: widget.creatorId, + creatorName: widget.creatorName, + chineseTeam: widget.chineseTeam, + ); + }, + )); + } + }, + icon: Icon(Icons.category), + ); + + Future _load(String _currentSort, int _currentPage) { + return method.comics( + _currentSort, + _currentPage, + category: widget.category ?? "", + tag: widget.tag ?? "", + creatorId: widget.creatorId ?? "", + chineseTeam: widget.chineseTeam ?? "", + ); + } + + @override + Widget build(BuildContext context) { + PreferredSizeWidget? appBar; + if (widget.tag == null && + widget.creatorId == null && + widget.chineseTeam == null) { + // 只有只传分类或不传参数时时才开放搜索 + appBar = _categorySearchBar.build(context); + } else { + var title = ""; + if (widget.category != null) { + title += "${widget.category} "; + } + if (widget.tag != null) { + title += "${widget.tag} "; + } + if (widget.creatorName != null) { + title += "${widget.creatorName} "; + } + if (widget.chineseTeam != null) { + title += "${widget.chineseTeam} "; + } + appBar = AppBar( + title: Text(title), + actions: [ + chooseLayoutAction(context), + _chooseCategoryAction(), + ], + ); + } + + return Scaffold( + appBar: appBar, + body: ComicPager( + fetchPage: _load, + ), + ); + } +} diff --git a/lib/screens/CommentScreen.dart b/lib/screens/CommentScreen.dart new file mode 100644 index 0000000..3776a17 --- /dev/null +++ b/lib/screens/CommentScreen.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/components/ComicCommentItem.dart'; +import 'package:pikapi/screens/components/ContentBuilder.dart'; + +class CommentScreen extends StatefulWidget { + final String comicId; + final Comment comment; + + const CommentScreen(this.comicId, this.comment); + + @override + State createState() => _CommentScreenState(); +} + +class _CommentScreenState extends State { + late int _currentPage = 1; + late Future _future = _loadPage(); + + Future _loadPage() { + return method.commentChildren( + widget.comicId, + widget.comment.id, + _currentPage, + ); + } + + Widget _buildChildrenPager() { + return ContentBuilder( + future: _future, + onRefresh: _loadPage, + successBuilder: + (BuildContext context, AsyncSnapshot snapshot) { + var page = snapshot.data!; + return ListView( + children: [ + _buildPrePage(page), + ...page.docs.map((e) => _buildComment(e)), + _buildPostComment(), + _buildNextPage(page), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('评论'), + ), + body: Column( + children: [ + ComicCommentItem(widget.comment), + Container( + height: 3, + color: + (Theme.of(context).textTheme.bodyText1?.color ?? Colors.black) + .withOpacity(.05), + ), + Expanded(child: _buildChildrenPager()) + ], + ), + ); + } + + Widget _buildComment(CommentChild e) { + return ComicCommentItem(e); + } + + Widget _buildPostComment() { + return InkWell( + onTap: () async { + String? text = await inputString(context, '请输入评论内容'); + if (text != null && text.isNotEmpty) { + try { + await method.postChildComment(widget.comment.id, text); + setState(() { + _future = _loadPage(); + widget.comment.commentsCount++; + }); + } catch (e) { + defaultToast(context, "评论失败"); + } + } + }, + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + bottom: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + ), + ), + padding: EdgeInsets.all(30), + child: Center( + child: Text('我有话要讲'), + ), + ), + ); + } + + Widget _buildPrePage(CommentChildrenPage page) { + if (page.page > 1) { + return InkWell( + onTap: () { + setState(() { + _currentPage = page.page - 1; + _future = _loadPage(); + }); + }, + child: Container( + padding: EdgeInsets.all(30), + child: Center( + child: Text('上一页'), + ), + ), + ); + } + return Container(); + } + + Widget _buildNextPage(CommentChildrenPage page) { + if (page.page < page.pages) { + return InkWell( + onTap: () { + setState(() { + _currentPage = page.page + 1; + _future = _loadPage(); + }); + }, + child: Container( + padding: EdgeInsets.all(30), + child: Center( + child: Text('下一页'), + ), + ), + ); + } + return Container(); + } +} diff --git a/lib/screens/DownloadConfirmScreen.dart b/lib/screens/DownloadConfirmScreen.dart new file mode 100644 index 0000000..fcd7db8 --- /dev/null +++ b/lib/screens/DownloadConfirmScreen.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/components/ContentLoading.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'components/ComicInfoCard.dart'; + +// 确认下载 +class DownloadConfirmScreen extends StatefulWidget { + final ComicInfo comicInfo; + final List epList; + + const DownloadConfirmScreen({ + Key? key, + required this.comicInfo, + required this.epList, + }) : super(key: key); + + @override + State createState() => _DownloadConfirmScreenState(); +} + +class _DownloadConfirmScreenState extends State { + DownloadComic? _task; // 之前的下载任务 + List _taskedEps = []; // 已经下载的EP + List _selectedEps = []; // 选中的EP + late Future f = _load(); + + Future _load() async { + _taskedEps.clear(); + _task = await method.loadDownloadComic(widget.comicInfo.id); + if (_task != null) { + var epList = await method.downloadEpList(widget.comicInfo.id); + _taskedEps.addAll(epList.map((e) => e.epOrder)); + } + } + + void _selectAll() { + setState(() { + _selectedEps.clear(); + widget.epList.forEach((element) { + if (!_taskedEps.contains(element.order)) { + _selectedEps.add(element.order); + } + }); + }); + } + + Future _download() async { + // 必须选中才能下载 + if (_selectedEps.isEmpty) { + defaultToast(context, "请选择下载的EP"); + return; + } + // 下载对象 + Map create = { + "id": widget.comicInfo.id, + "createdAt": widget.comicInfo.createdAt, + "updatedAt": widget.comicInfo.updatedAt, + "title": widget.comicInfo.title, + "author": widget.comicInfo.author, + "pagesCount": widget.comicInfo.pagesCount, + "epsCount": widget.comicInfo.epsCount, + "finished": widget.comicInfo.finished, + "categories": json.encode(widget.comicInfo.categories), + "thumbOriginalName": widget.comicInfo.thumb.originalName, + "thumbFileServer": widget.comicInfo.thumb.fileServer, + "thumbPath": widget.comicInfo.thumb.path, + "description": widget.comicInfo.description, + "chineseTeam": widget.comicInfo.chineseTeam, + "tags": json.encode(widget.comicInfo.tags), + }; + // 下载EP列表 + List> list = []; + widget.epList.forEach((element) { + if (_selectedEps.contains(element.order)) { + list.add({ + "comicId": widget.comicInfo.id, + "id": element.id, + "updatedAt": element.updatedAt, + "epOrder": element.order, + "title": element.title, + }); + } + }); + // 如果之前下载过就将EP加入下载 + // 如果之前没有下载过就创建下载 + if (_task != null) { + await method.addDownload(create, list); + } else { + await method.createDownload(create, list); + } + // 退出 + defaultToast(context, "已经加入下载列表"); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("下载 - ${widget.comicInfo.title}"), + ), + body: FutureBuilder( + future: f, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + print(snapshot.error); + print(snapshot.stackTrace); + return Text('error'); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + return ListView( + children: [ + ComicInfoCard(widget.comicInfo), + _buildButtons(), + Wrap( + alignment: WrapAlignment.spaceAround, + runSpacing: 10, + spacing: 10, + children: [ + ...widget.epList.map((e) { + return Container( + padding: EdgeInsets.all(5), + child: MaterialButton( + onPressed: () { + _clickOfEp(e); + }, + color: _colorOfEp(e), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _iconOfEp(e), + Container( + width: 10, + ), + Text(e.title, + style: TextStyle(color: Colors.black)), + ], + ), + ), + ); + }), + ], + ), + ], + ); + }, + ), + ); + } + + Widget _buildButtons() { + var theme = Theme.of(context); + return Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.grey.shade200, + ), + ), + ), + child: Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.spaceAround, + children: [ + MaterialButton( + color: theme.colorScheme.secondary, + textColor: Colors.white, + onPressed: _selectAll, + child: Text('全选'), + ), + MaterialButton( + color: theme.colorScheme.secondary, + textColor: Colors.white, + onPressed: _download, + child: Text('确定下载'), + ), + ], + ), + ); + } + + Color _colorOfEp(Ep e) { + if (_taskedEps.contains(e.order)) { + return Colors.grey.shade300; + } + if (_selectedEps.contains(e.order)) { + return Colors.blueGrey.shade300; + } + return Colors.grey.shade200; + } + + Icon _iconOfEp(Ep e) { + if (_taskedEps.contains(e.order)) { + return Icon(Icons.download_rounded, color: Colors.black); + } + if (_selectedEps.contains(e.order)) { + return Icon(Icons.check_box, color: Colors.black); + } + return Icon(Icons.check_box_outline_blank, color: Colors.black); + } + + void _clickOfEp(Ep e) { + if (_taskedEps.contains(e.order)) { + return; + } + if (_selectedEps.contains(e.order)) { + setState(() { + _selectedEps.remove(e.order); + }); + } else { + setState(() { + _selectedEps.add(e.order); + }); + } + } +} diff --git a/lib/screens/DownloadExportToFileScreen.dart b/lib/screens/DownloadExportToFileScreen.dart new file mode 100644 index 0000000..2eb41f6 --- /dev/null +++ b/lib/screens/DownloadExportToFileScreen.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/DownloadExportToSocketScreen.dart'; + +import 'components/ContentError.dart'; +import 'components/ContentLoading.dart'; +import 'components/DownloadInfoCard.dart'; + +// 导出 +class DownloadExportToFileScreen extends StatefulWidget { + final String comicId; + final String comicTitle; + + DownloadExportToFileScreen({ + required this.comicId, + required this.comicTitle, + }); + + @override + State createState() => _DownloadExportToFileScreenState(); +} + +class _DownloadExportToFileScreenState + extends State { + late DownloadComic _task; + late Future _future = _load(); + late bool exporting = false; + late String exportMessage = "导出中"; + late String exportResult = ""; + + Future _load() async { + _task = (await method.loadDownloadComic(widget.comicId))!; + } + + @override + void initState() { + registerEvent(_onMessageChange, "EXPORT"); + super.initState(); + } + + @override + void dispose() { + unregisterEvent(_onMessageChange); + super.dispose(); + } + + void _onMessageChange(event) { + setState(() { + exportMessage = event; + }); + } + + @override + Widget build(BuildContext context) { + if (exporting) { + return Scaffold( + body: ContentLoading(label: exportMessage), + ); + } + return Scaffold( + appBar: AppBar( + title: Text("导出 - " + widget.comicTitle), + ), + body: FutureBuilder( + future: _future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + setState(() { + _future = _load(); + }); + }); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + return ListView( + children: [ + DownloadInfoCard(task: _task), + Container( + padding: EdgeInsets.all(8), + child: exportResult != "" ? Text(exportResult) : Container(), + ), + Container( + padding: EdgeInsets.all(8), + child: Text('TIPS : 选择一个目录'), + ), + ..._buildExportToFileButtons(), + MaterialButton( + onPressed: () async { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => DownloadExportToSocketScreen( + task: _task, + comicId: widget.comicId, + comicTitle: widget.comicTitle, + ), + ), + ); + }, + child: _buildButtonInner('传输到其他设备'), + ), + ], + ); + }, + ), + ); + } + + List _buildExportToFileButtons() { + if (Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isAndroid) { + return [ + MaterialButton( + onPressed: () async { + String? path = await chooseFolder(context); + print("path $path"); + if (path != null) { + try { + setState(() { + exporting = true; + }); + await method.exportComicDownloadToJPG( + widget.comicId, + path, + ); + setState(() { + exportResult = "导出成功"; + }); + } catch (e) { + setState(() { + exportResult = "导出失败 $e"; + }); + } finally { + setState(() { + exporting = false; + }); + } + } + }, + child: _buildButtonInner('导出到HTML+JPG\n(可直接在相册中打开观看)'), + ), + Container(height: 10), + MaterialButton( + onPressed: () async { + String? path = await chooseFolder(context); + print("path $path"); + if (path != null) { + try { + setState(() { + exporting = true; + }); + await method.exportComicDownload( + widget.comicId, + path, + ); + setState(() { + exportResult = "导出成功"; + }); + } catch (e) { + setState(() { + exportResult = "导出失败 $e"; + }); + } finally { + setState(() { + exporting = false; + }); + } + } + }, + child: _buildButtonInner('导出到HTML.zip\n(可从其他设备导入 / 解压后可阅读)'), + ), + Container(height: 10), + ]; + } + return []; + } + + Widget _buildButtonInner(String text) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Container( + width: constraints.maxWidth, + padding: EdgeInsets.only(top: 15, bottom: 15), + color: (Theme.of(context).textTheme.bodyText1?.color ?? Colors.black) + .withOpacity(.05), + child: Text( + text, + textAlign: TextAlign.center, + ), + ); + }, + ); + } +} diff --git a/lib/screens/DownloadExportToSocketScreen.dart b/lib/screens/DownloadExportToSocketScreen.dart new file mode 100644 index 0000000..e49267f --- /dev/null +++ b/lib/screens/DownloadExportToSocketScreen.dart @@ -0,0 +1,110 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'components/ContentError.dart'; +import 'components/ContentLoading.dart'; +import 'components/DownloadInfoCard.dart'; + +class DownloadExportToSocketScreen extends StatefulWidget { + final DownloadComic task; + final String comicId; + final String comicTitle; + + DownloadExportToSocketScreen({ + required this.task, + required this.comicId, + required this.comicTitle, + }); + + @override + State createState() => _DownloadExportToSocketScreenState(); +} + +class _DownloadExportToSocketScreenState + extends State { + late Future _future = method.exportComicUsingSocket(widget.comicId); + late Future _ipFuture = method.clientIpSet(); + + late String exportMessage = ""; + + @override + void initState() { + registerEvent(_onMessageChange, "EXPORT"); + super.initState(); + } + + @override + void dispose() { + method.exportComicUsingSocketExit(); + unregisterEvent(_onMessageChange); + super.dispose(); + } + + void _onMessageChange(event) { + if (event is String) { + setState(() { + exportMessage = event; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("网络导出 - " + widget.comicTitle), + ), + body: FutureBuilder( + future: _future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + setState(() { + _future = method.exportComicUsingSocket(widget.comicId); + }); + }); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + return ListView( + children: [ + DownloadInfoCard(task: widget.task), + Container( + padding: EdgeInsets.all(8), + child: Column( + children: [ + Text( + 'TIPS : 传输成功之前请不要退出页面, 一次只能导出到一个设备, 两台设备需要在同一网段或无限局域网中, 请另外一台设备输入 IP:端口 , 有一个IP时请选择无限局域网的IP, 通常是192.168开头'), + FutureBuilder( + future: _ipFuture, + builder: (BuildContext context, + AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Text('获取IP失败'); + } + if (snapshot.connectionState != ConnectionState.done) { + return Text('正在获取IP'); + } + return Text('${snapshot.data}'); + }, + ), + Text('端口号:${snapshot.data}'), + Text('$exportMessage'), + ], + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/screens/DownloadImportScreen.dart b/lib/screens/DownloadImportScreen.dart new file mode 100644 index 0000000..de136be --- /dev/null +++ b/lib/screens/DownloadImportScreen.dart @@ -0,0 +1,157 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:filesystem_picker/filesystem_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'components/ContentLoading.dart'; + +// 导入 +class DownloadImportScreen extends StatefulWidget { + @override + State createState() => _DownloadImportScreenState(); +} + +class _DownloadImportScreenState extends State { + bool _importing = false; + String _importMessage = ""; + + @override + void initState() { + registerEvent(_onMessageChange, "EXPORT"); + super.initState(); + } + + @override + void dispose() { + unregisterEvent(_onMessageChange); + super.dispose(); + } + + void _onMessageChange(event) { + if (event is String) { + setState(() { + _importMessage = event; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_importing) { + return Scaffold( + body: ContentLoading(label: _importMessage), + ); + } + + List actions = []; + + if (Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isAndroid) { + actions.add(_fileImportButton()); + } + + actions.add(_networkImportButton()); + + return Scaffold( + appBar: AppBar( + title: Text('导入'), + ), + body: ListView( + children: [ + Container( + padding: EdgeInsets.all(10), + child: Text(_importMessage), + ), + ...actions, + ], + ), + ); + } + + Widget _fileImportButton() { + return MaterialButton( + height: 80, + onPressed: () async { + late String root; + if (Platform.isMacOS) { + root = '/Users'; + } else if (Platform.isWindows) { + root = '/'; + } else if (Platform.isAndroid) { + var p = await Permission.storage.request(); + if (!p.isGranted) { + return; + } + root = '/storage/emulated/0'; + } else { + throw 'error'; + } + String? path = await FilesystemPicker.open( + title: 'Open file', + context: context, + rootDirectory: Directory(root), + fsType: FilesystemType.file, + folderIconColor: Colors.teal, + allowedExtensions: ['.zip'], + fileTileSelectMode: FileTileSelectMode.wholeTile, + ); + if (path != null) { + try { + setState(() { + _importing = true; + }); + await method.importComicDownload(path); + setState(() { + _importMessage = "导入成功"; + }); + } catch (e) { + setState(() { + _importMessage = "导入失败 $e"; + }); + } finally { + setState(() { + _importing = false; + }); + } + } + }, + child: Text('选择zip文件进行导入'), + ); + } + + Widget _networkImportButton() { + return MaterialButton( + height: 80, + onPressed: () async { + var path = await inputString(context, '请输入导出设备提供的地址\n例如 "192.168.1.2:50000"'); + if (path != null) { + try { + setState(() { + _importing = true; + }); + await method.importComicDownloadUsingSocket(path); + setState(() { + _importMessage = "导入成功"; + }); + } catch (e) { + setState(() { + _importMessage = "导入失败 $e"; + }); + } finally { + setState(() { + _importing = false; + }); + } + } + }, + child: Text('从其他设备导入'), + ); + } +} diff --git a/lib/screens/DownloadInfoScreen.dart b/lib/screens/DownloadInfoScreen.dart new file mode 100644 index 0000000..a96bdb0 --- /dev/null +++ b/lib/screens/DownloadInfoScreen.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Navigatior.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'ComicInfoScreen.dart'; +import 'DownloadExportToFileScreen.dart'; +import 'DownloadReaderScreen.dart'; +import 'components/ComicDescriptionCard.dart'; +import 'components/ComicTagsCard.dart'; +import 'components/ContentError.dart'; +import 'components/ContentLoading.dart'; +import 'components/ContinueReadButton.dart'; +import 'components/DownloadInfoCard.dart'; + +// 下载详情 +class DownloadInfoScreen extends StatefulWidget { + final String comicId; + final String comicTitle; + + DownloadInfoScreen({ + required this.comicId, + required this.comicTitle, + }); + + @override + State createState() => _DownloadInfoScreenState(); +} + +class _DownloadInfoScreenState extends State + with RouteAware { + late Future _viewFuture = _loadViewLog(); + late DownloadComic _task; + late List _epList = []; + late Future _future = _load(); + + Future _load() async { + _task = (await method.loadDownloadComic(widget.comicId))!; + _epList = await method.downloadEpList(widget.comicId); + } + + Future _loadViewLog() { + return method.loadView(widget.comicId); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + routeObserver.subscribe(this, ModalRoute.of(context)!); + } + + @override + void didPopNext() { + setState(() { + _viewFuture = _loadViewLog(); + }); + } + + @override + void dispose() { + routeObserver.unsubscribe(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.comicTitle), + actions: [ + IconButton( + onPressed: () async { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadExportToFileScreen( + comicId: widget.comicId, + comicTitle: widget.comicTitle, + ), + ), + ); + }, + icon: Icon(Icons.add_to_home_screen), + ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComicInfoScreen( + comicId: widget.comicId, + ), + ), + ); + }, + icon: Icon(Icons.settings_ethernet_outlined), + ), + ], + ), + body: FutureBuilder( + future: _future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + setState(() { + _future = _load(); + }); + }); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + List tagsDynamic = json.decode(_task.tags); + List tags = tagsDynamic.map((e) => "$e").toList(); + return ListView( + children: [ + DownloadInfoCard(task: _task, linkItem: true), + ComicTagsCard(tags), + ComicDescriptionCard(description: _task.description), + Container(height: 5), + Wrap( + spacing: 10, + runSpacing: 10, + alignment: WrapAlignment.spaceAround, + children: [ + ContinueReadButton( + viewFuture: _viewFuture, + onChoose: (int? epOrder, int? pictureRank) { + if (epOrder != null && pictureRank != null) { + for (var i in _epList) { + if (i.epOrder == epOrder) { + _push(_task, _epList, epOrder, pictureRank); + return; + } + } + } else { + _push(_task, _epList, _epList.first.epOrder, null); + } + }, + ), + ..._epList.map((e) { + return Container( + child: MaterialButton( + onPressed: () { + _push(_task, _epList, e.epOrder, null); + }, + color: Colors.white, + child: Text(e.title, + style: TextStyle(color: Colors.black)), + ), + ); + }), + ], + ), + ], + ); + }, + ), + ); + } + + void _push( + DownloadComic task, + List epList, + int epOrder, + int? rank, + ) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadReaderScreen( + comicInfo: _task, + epList: _epList, + currentEpOrder: epOrder, + initPictureRank: rank, + ), + ), + ); + } +} diff --git a/lib/screens/DownloadListScreen.dart b/lib/screens/DownloadListScreen.dart new file mode 100644 index 0000000..4dc726d --- /dev/null +++ b/lib/screens/DownloadListScreen.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'DownloadImportScreen.dart'; +import 'DownloadInfoScreen.dart'; +import 'components/ContentLoading.dart'; +import 'components/DownloadInfoCard.dart'; + +// 下载列表 +class DownloadListScreen extends StatefulWidget { + @override + State createState() => _DownloadListScreenState(); +} + +class _DownloadListScreenState extends State { + DownloadComic? _downloading; + late bool _downloadRunning = false; + late Future> _f = method.allDownloads(); + + void _onMessageChange(String event){ + print("EVENT"); + print(event); + if (event is String) { + try { + setState(() { + _downloading = DownloadComic.fromJson(json.decode(event)); + }); + } catch (e, s) { + print(e); + print(s); + } + } + } + + @override + void initState() { + registerEvent(_onMessageChange, "DOWNLOAD"); + method + .downloadRunning() + .then((val) => setState(() => _downloadRunning = val)); + super.initState(); + } + + @override + void dispose() { + unregisterEvent(_onMessageChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('下载列表'), + actions: [ + ...(Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isAndroid || + Platform.isIOS) + ? [ + MaterialButton( + minWidth: 0, + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadImportScreen(), + ), + ); + setState(() { + _f = method.allDownloads(); + }); + }, + child: Column( + children: [ + Expanded(child: Container()), + Icon( + Icons.label_important, + size: 18, + color: Colors.white, + ), + Text( + '导入', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + Expanded(child: Container()), + ], + )), + ] + : [], + MaterialButton( + minWidth: 0, + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text('下载任务'), + content: Text( + _downloadRunning ? "暂停下载吗?" : "启动下载吗?", + ), + actions: [ + MaterialButton( + onPressed: () async { + Navigator.pop(context); + }, + child: Text('取消'), + ), + MaterialButton( + onPressed: () async { + Navigator.pop(context); + var to = !_downloadRunning; + // properties.saveDownloading(to); + await method.setDownloadRunning(to); + setState(() { + _downloadRunning = to; + }); + }, + child: Text('确认'), + ), + ], + ); + }, + ); + }, + child: Column( + children: [ + Expanded(child: Container()), + Icon( + _downloadRunning + ? Icons.compare_arrows_sharp + : Icons.schedule_send, + size: 18, + color: Colors.white, + ), + Text( + _downloadRunning ? '下载中' : '暂停中', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + Expanded(child: Container()), + ], + )), + MaterialButton( + minWidth: 0, + onPressed: () async { + await method.resetFailed(); + setState(() { + _f = method.allDownloads(); + }); + defaultToast(context, "所有失败的下载已经恢复"); + }, + child: Column( + children: [ + Expanded(child: Container()), + Icon( + Icons.sync_problem, + size: 18, + color: Colors.white, + ), + Text( + '恢复', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + Expanded(child: Container()), + ], + )), + ], + ), + body: FutureBuilder( + future: _f, + builder: (BuildContext context, + AsyncSnapshot> snapshot) { + if (snapshot.hasError) { + print("${snapshot.error}"); + print("${snapshot.stackTrace}"); + return Center(child: Text('加载失败')); + } + + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + + var data = snapshot.data!; + + if (_downloading != null) { + print(_downloading); + try { + for (var i = 0; i < data.length; i++) { + if (_downloading!.id == data[i].id) { + data[i].copy(_downloading!); + } + } + } catch (e, s) { + print(e); + print(s); + } + } + + return ListView( + children: [ + ...data.map( + (e) => InkWell( + onTap: () { + if (e.deleting) { + return; + } + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => DownloadInfoScreen( + comicId: e.id, + comicTitle: e.title, + ), + ), + ); + }, + onLongPress: () async { + String? action = + await chooseListDialog(context, e.title, ['删除']); + if (action == '删除') { + await method.deleteDownloadComic(e.id); + setState(() => e.deleting = true); + } + }, + child: DownloadInfoCard( + task: e, + downloading: + _downloading != null && _downloading!.id == e.id, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/screens/DownloadReaderScreen.dart b/lib/screens/DownloadReaderScreen.dart new file mode 100644 index 0000000..de54d7e --- /dev/null +++ b/lib/screens/DownloadReaderScreen.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/config/AutoFullScreen.dart'; +import 'package:pikapi/basic/config/FullScreenUI.dart'; +import 'package:pikapi/basic/config/ReaderDirection.dart'; +import 'package:pikapi/basic/config/ReaderType.dart'; +import 'package:pikapi/screens/components/ContentBuilder.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'components/ImageReader.dart'; + +// 阅读下载的内容 +class DownloadReaderScreen extends StatefulWidget { + final DownloadComic comicInfo; + final List epList; + final int currentEpOrder; + final int? initPictureRank; + final ReaderType pagerType = gReaderType; + final ReaderDirection pagerDirection = gReaderDirection; + late final bool autoFullScreen; + + DownloadReaderScreen({ + Key? key, + required this.comicInfo, + required this.epList, + required this.currentEpOrder, + this.initPictureRank, + bool? autoFullScreen, + }) : super(key: key) { + this.autoFullScreen = autoFullScreen ?? gAutoFullScreen; + } + + @override + State createState() => _DownloadReaderScreenState(); +} + +class _DownloadReaderScreenState extends State { + late DownloadEp _ep; + late bool _fullScreen = false; + late List pictures = []; + late Future _future = _load(); + int? _lastChangeRank; + bool _replacement = false; + + Future _load() async { + if (widget.initPictureRank == null) { + await method.storeViewEp(widget.comicInfo.id, _ep.epOrder, _ep.title, 1); + } + pictures.clear(); + for (var ep in widget.epList) { + if (ep.epOrder == widget.currentEpOrder) { + pictures.addAll((await method.downloadPicturesByEpId(ep.id))); + } + } + if (widget.autoFullScreen) { + setState(() { + SystemChrome.setEnabledSystemUIOverlays([]); + _fullScreen = true; + }); + } + } + + Future _onPositionChange(int position) async { + _lastChangeRank = position + 1; + return method.storeViewEp( + widget.comicInfo.id, _ep.epOrder, _ep.title, position + 1); + } + + String _nextText = ""; + FutureOr Function() _nextAction = () => null; + + @override + void initState() { + // NEXT + var orderMap = Map(); + widget.epList.forEach((element) { + orderMap[element.epOrder] = element; + }); + if (orderMap.containsKey(widget.currentEpOrder + 1)) { + _nextText = "下一章"; + _nextAction = () { + _replacement = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => DownloadReaderScreen( + comicInfo: widget.comicInfo, + epList: widget.epList, + currentEpOrder: widget.currentEpOrder + 1, + autoFullScreen: _fullScreen, + ), + ), + ); + }; + } else { + _nextText = "阅读结束"; + _nextAction = () => Navigator.of(context).pop(); + } + // EP + widget.epList.forEach((element) { + if (element.epOrder == widget.currentEpOrder) { + _ep = element; + } + }); + // INIT + _future = _load(); + addVolumeListen(); + super.initState(); + } + + @override + void dispose() { + if (!_replacement) { + switchFullScreenUI(); + } + delVolumeListen(); + super.dispose(); + } + + + @override + Widget build(BuildContext context) { + return readerKeyboardHolder(_build(context)); + } + + Widget _build(BuildContext context) { + return Scaffold( + appBar: _fullScreen + ? null + : AppBar( + title: Text("${_ep.title} - ${widget.comicInfo.title}"), + actions: [ + IconButton( + onPressed: () async { + await choosePagerDirection(context); + if (widget.pagerDirection != gReaderDirection) { + _reloadReader(); + } + }, + icon: Icon(Icons.grid_goldenratio), + ), + IconButton( + onPressed: () async { + await choosePagerType(context); + if (widget.pagerType != gReaderType) { + _reloadReader(); + } + }, + icon: Icon(Icons.view_day_outlined), + ), + ], + ), + body: ContentBuilder( + future: _future, + onRefresh: () async { + setState(() { + _future = _load(); + }); + }, + successBuilder: + (BuildContext context, AsyncSnapshot snapshot) { + return ImageReader( + ImageReaderStruct( + images: pictures + .map((e) => ReaderImageInfo(e.fileServer, e.path, e.localPath, + e.width, e.height, e.format, e.fileSize)) + .toList(), + fullScreen: _fullScreen, + onFullScreenChange: _onFullScreenChange, + onNextText: _nextText, + onNextAction: _nextAction, + onPositionChange: _onPositionChange, + initPosition: widget.initPictureRank == null + ? null + : widget.initPictureRank! - 1, + pagerType: widget.pagerType, + pagerDirection: widget.pagerDirection, + ), + ); + }, + ), + ); + } + + Future _onFullScreenChange(bool fullScreen) async { + setState(() { + SystemChrome.setEnabledSystemUIOverlays( + fullScreen ? [] : SystemUiOverlay.values); + _fullScreen = fullScreen; + }); + } + + // 重新加载本页 + void _reloadReader() { + _replacement = true; + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => DownloadReaderScreen( + comicInfo: widget.comicInfo, + epList: widget.epList, + currentEpOrder: widget.currentEpOrder, + initPictureRank: _lastChangeRank ?? widget.initPictureRank, + // maybe null + autoFullScreen: _fullScreen, + ), + ), + ); + } +} diff --git a/lib/screens/FavouritePaperScreen.dart b/lib/screens/FavouritePaperScreen.dart new file mode 100644 index 0000000..400eb87 --- /dev/null +++ b/lib/screens/FavouritePaperScreen.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Method.dart'; +import '../basic/Entities.dart'; +import 'components/ComicPager.dart'; + +// 收藏的漫画 +class FavouritePaperScreen extends StatefulWidget { + @override + State createState() => _FavouritePaperScreen(); +} + +class _FavouritePaperScreen extends State { + Future _fetch(String _currentSort, int _currentPage) { + return method.favouriteComics(_currentSort, _currentPage); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('收藏'), + ), + body: ComicPager( + fetchPage: _fetch, + ), + ); + } +} diff --git a/lib/screens/FilePhotoViewScreen.dart b/lib/screens/FilePhotoViewScreen.dart new file mode 100644 index 0000000..381e63a --- /dev/null +++ b/lib/screens/FilePhotoViewScreen.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/screens/components/Images.dart'; + +class FilePhotoViewScreen extends StatelessWidget { + final String filePath; + + FilePhotoViewScreen(this.filePath); + + @override + Widget build(BuildContext context) => Scaffold( + body: Stack( + children: [ + PhotoView( + imageProvider: PicaFileImageProvider(filePath), + ), + InkWell( + onTap: () => Navigator.of(context).pop(), + child: Container( + margin: EdgeInsets.only(top: 30), + padding: EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(.75), + borderRadius: BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Icon(Icons.keyboard_backspace, color: Colors.white), + ), + ), + Align( + alignment: Alignment.topRight, + child: InkWell( + onTap: () { + saveImage(filePath, context); + }, + child: Container( + margin: EdgeInsets.only(top: 30), + padding: EdgeInsets.only(left: 4, right: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(.75), + borderRadius: BorderRadius.only( + topRight: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Icon(Icons.save, color: Colors.white), + ), + ), + ), + ], + ), + ); +} diff --git a/lib/screens/GameDownloadScreen.dart b/lib/screens/GameDownloadScreen.dart new file mode 100644 index 0000000..32a933c --- /dev/null +++ b/lib/screens/GameDownloadScreen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/components/ItemBuilder.dart'; + +import 'components/GameTitleCard.dart'; + +class GameDownloadScreen extends StatefulWidget { + final GameInfo info; + + const GameDownloadScreen(this.info, {Key? key}) : super(key: key); + + @override + State createState() => _GameDownloadScreenState(); +} + +class _GameDownloadScreenState extends State { + late Future> _future = + method.downloadGame("${widget.info.androidLinks[0]}"); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text("下载 - ${widget.info.title}"), + ), + body: ListView( + children: [ + GameTitleCard(widget.info), + ItemBuilder( + future: _future, + onRefresh: () async { + setState(() { + _future = + method.downloadGame("${widget.info.androidLinks[0]}"); + }); + }, + successBuilder: + (BuildContext context, AsyncSnapshot> snapshot) { + return Container( + child: Column( + children: [ + Container( + padding: EdgeInsets.all(30), + child: Text('获取到下载链接, 您只需要选择其中一个'), + ), + ...snapshot.data!.map((e) => _copyCard(e)), + ], + ), + ); + }, + ), + ], + ), + ); + } + + Widget _copyCard(String string) { + return InkWell( + onTap: () { + copyToClipBoard(context, string); + }, + child: Row( + children: [ + Expanded( + child: Container( + margin: EdgeInsets.all(10), + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.shade500, + width: .5, + style: BorderStyle.solid, + ), + ), + child: Text(string), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/GameInfoScreen.dart b/lib/screens/GameInfoScreen.dart new file mode 100644 index 0000000..b2dfc4d --- /dev/null +++ b/lib/screens/GameInfoScreen.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/components/ContentError.dart'; +import 'package:pikapi/screens/components/ContentLoading.dart'; +import 'package:pikapi/screens/components/Images.dart'; + +import 'GameDownloadScreen.dart'; +import 'components/GameTitleCard.dart'; + +class GameInfoScreen extends StatefulWidget { + final String gameId; + + const GameInfoScreen(this.gameId); + + @override + State createState() => _GameInfoScreenState(); +} + +class _GameInfoScreenState extends State { + late var _future = method.game(widget.gameId); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return Scaffold( + appBar: AppBar( + title: Text('加载出错'), + ), + body: ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: () async { + setState(() { + _future = method.game(widget.gameId); + }); + }), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return Scaffold( + appBar: AppBar( + title: Text('加载中'), + ), + body: ContentLoading(label: '加载中'), + ); + } + + BorderRadius iconRadius = BorderRadius.all(Radius.circular(6)); + double screenShootMargin = 10; + double screenShootHeight = 200; + double platformMargin = 10; + double platformSize = 25; + TextStyle descriptionStyle = TextStyle(); + + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + var info = snapshot.data!; + return Scaffold( + appBar: AppBar( + title: Text(info.title), + ), + body: ListView( + children: [ + GameTitleCard(info), + Container( + height: platformSize, + margin: EdgeInsets.only(bottom: platformMargin), + child: ListView( + padding: EdgeInsets.only( + left: platformMargin, + right: platformMargin, + ), + scrollDirection: Axis.horizontal, + children: [ + ...info.android + ? [ + Container( + width: platformMargin, + ), + SvgPicture.asset( + 'lib/assets/android.svg', + fit: BoxFit.contain, + width: platformSize, + height: platformSize, + color: Colors.green.shade500, + ), + ] + : [], + ...info.ios + ? [ + Container( + width: platformMargin, + ), + SvgPicture.asset( + 'lib/assets/apple.svg', + fit: BoxFit.contain, + width: platformSize, + height: platformSize, + color: Colors.grey.shade500, + ), + ] + : [], + ], + ), + ), + Container( + margin: EdgeInsets.only( + top: screenShootMargin, + bottom: screenShootMargin, + ), + height: screenShootHeight, + child: ListView( + padding: EdgeInsets.only( + left: screenShootMargin, + right: screenShootMargin, + ), + scrollDirection: Axis.horizontal, + children: info.screenshots + .map((e) => Container( + margin: EdgeInsets.only( + left: screenShootMargin, + right: screenShootMargin, + ), + child: ClipRRect( + borderRadius: iconRadius, + child: RemoteImage( + height: screenShootHeight, + fileServer: e.fileServer, + path: e.path, + ), + ), + )) + .toList(), + ), + ), + Container( + padding: EdgeInsets.all(20), + child: Text(info.description, style: descriptionStyle), + ), + Container( + color: Colors.grey.shade500.withOpacity(.1), + child: MaterialButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GameDownloadScreen(info)), + ); + }, + child: Container( + padding: EdgeInsets.all(30), + child: Text('下载'), + ), + ), + ), + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/screens/GamesScreen.dart b/lib/screens/GamesScreen.dart new file mode 100644 index 0000000..4134cde --- /dev/null +++ b/lib/screens/GamesScreen.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/components/ContentBuilder.dart'; + +import 'GameInfoScreen.dart'; +import 'components/Images.dart'; + +class GamesScreen extends StatefulWidget { + @override + State createState() => _GamesScreenState(); +} + +class _GamesScreenState extends State { + int _currentPage = 1; + late Future _future = _loadPage(); + + Future _loadPage() { + return method.games(_currentPage); + } + + void _onPageChange(int number) { + setState(() { + _currentPage = number; + _future = _loadPage(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('游戏'), + ), + body: ContentBuilder( + future: _future, + onRefresh: _loadPage, + successBuilder: + (BuildContext context, AsyncSnapshot snapshot) { + var page = snapshot.data!; + + List wraps = []; + GameCard? gameCard; + page.docs.forEach((element) { + if (gameCard == null) { + gameCard = GameCard(element); + } else { + wraps.add(Wrap( + children: [GameCard(element), gameCard!], + alignment: WrapAlignment.center, + )); + gameCard = null; + } + }); + if (gameCard != null) { + wraps.add(Wrap( + children: [gameCard!], + alignment: WrapAlignment.center, + )); + } + return Scaffold( + appBar: PreferredSize( + preferredSize: Size.fromHeight(40), + child: Container( + padding: EdgeInsets.only(left: 10, right: 10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: .5, + style: BorderStyle.solid, + color: Colors.grey[200]!, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + InkWell( + onTap: () { + _textEditController.clear(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Card( + child: Container( + child: TextField( + controller: _textEditController, + decoration: new InputDecoration( + labelText: "请输入页数:", + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'\d+')), + ], + ), + ), + ), + actions: [ + MaterialButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text('取消'), + ), + MaterialButton( + onPressed: () { + Navigator.pop(context); + var text = _textEditController.text; + if (text.length == 0 || text.length > 5) { + return; + } + var num = int.parse(text); + if (num == 0 || num > page.pages) { + return; + } + _onPageChange(num); + }, + child: Text('确定'), + ), + ], + ); + }, + ); + }, + child: Row( + children: [ + Text("第 ${page.page} / ${page.pages} 页"), + ], + ), + ), + Row( + children: [ + MaterialButton( + minWidth: 0, + onPressed: () { + if (page.page > 1) { + _onPageChange(page.page - 1); + } + }, + child: Text('上一页'), + ), + MaterialButton( + minWidth: 0, + onPressed: () { + if (page.page < page.pages) { + _onPageChange(page.page + 1); + } + }, + child: Text('下一页'), + ) + ], + ), + ], + ), + ), + ), + body: ListView( + children: [ + ...wraps, + ...page.page < page.pages + ? [ + MaterialButton( + onPressed: () { + _onPageChange(page.page + 1); + }, + child: Container( + padding: EdgeInsets.only(top: 30, bottom: 30), + child: Text('下一页'), + ), + ), + ] + : [], + ], + ), + ); + }, + ), + ); + } +} + +class GameCard extends StatelessWidget { + final GameSimple info; + + GameCard(this.info); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textColor = theme.textTheme.bodyText1!.color!; + var categoriesStyle = TextStyle( + fontSize: 13, + color: textColor.withAlpha(0xCC), + ); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // data.width/data.height = width/ ? + // data.width * ? = width * data.height + // ? = width * data.height / data.width + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var imageWidth = (min - 45 - 40) / 2; + var imageHeight = imageWidth * 280 / 500; + return Card( + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => GameInfoScreen(info.id)), + ); + }, + child: Container( + padding: EdgeInsets.all(10), + child: Container( + width: imageWidth, + child: Column( + children: [ + RemoteImage( + width: imageWidth, + height: imageHeight, + fileServer: info.icon.fileServer, + path: info.icon.path, + ), + Text( + info.title + '\n', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1.4), + strutStyle: StrutStyle(height: 1.4), + ), + Text( + info.publisher, + style: categoriesStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +final TextEditingController _textEditController = + TextEditingController(text: ''); diff --git a/lib/screens/InitScreen.dart b/lib/screens/InitScreen.dart new file mode 100644 index 0000000..843be5a --- /dev/null +++ b/lib/screens/InitScreen.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/config/Address.dart'; +import 'package:pikapi/basic/config/AndroidDisplayMode.dart'; +import 'package:pikapi/basic/config/AutoClean.dart'; +import 'package:pikapi/basic/config/AutoFullScreen.dart'; +import 'package:pikapi/basic/config/ContentFailedReloadAction.dart'; +import 'package:pikapi/basic/config/FullScreenAction.dart'; +import 'package:pikapi/basic/config/FullScreenUI.dart'; +import 'package:pikapi/basic/config/KeyboardController.dart'; +import 'package:pikapi/basic/config/PagerAction.dart'; +import 'package:pikapi/basic/config/Proxy.dart'; +import 'package:pikapi/basic/config/Quality.dart'; +import 'package:pikapi/basic/config/ReaderDirection.dart'; +import 'package:pikapi/basic/config/ReaderType.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/basic/config/Themes.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; +import 'package:pikapi/basic/config/VolumeController.dart'; + +import 'AccountScreen.dart'; +import 'AppScreen.dart'; + +// 初始化界面 +class InitScreen extends StatefulWidget { + @override + State createState() => _InitScreenState(); +} + +class _InitScreenState extends State { + @override + initState() { + _init(); + super.initState(); + } + + Future _init() async { + // 初始化配置文件 + await autoClean(); + await initAddress(); + await initProxy(); + await initQuality(); + await initTheme(); + await initListLayout(); + await initReaderType(); + await initReaderDirection(); + await initAutoFullScreen(); + await initFullScreenAction(); + await initPagerAction(); + await initShadowCategories(); + await initFullScreenUI(); + switchFullScreenUI(); + await initContentFailedReloadAction(); + await initVolumeController(); + await initKeyboardController(); + await initAndroidDisplayMode(); + // 登录, 如果token失效重新登录, 网络不好的时候可能需要1分钟 + if (await method.preLogin()) { + // 如果token或username+password有效则直接进入登录好的界面 + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => AppScreen()), + ); + } else { + // 否则跳转到登录页 + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => AccountScreen()), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color(0xfffffced), + body: ConstrainedBox( + constraints: BoxConstraints.expand(), + child: new Image.asset( + "lib/assets/init.jpg", + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/lib/screens/NetworkSettingsScreen.dart b/lib/screens/NetworkSettingsScreen.dart new file mode 100644 index 0000000..d6626b5 --- /dev/null +++ b/lib/screens/NetworkSettingsScreen.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/screens/components/NetworkSetting.dart'; + +class NetworkSettingsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text('网络设置')), + body: ListView( + children: [ + NetworkSetting(), + ], + ), + ); +} diff --git a/lib/screens/RandomComicsScreen.dart b/lib/screens/RandomComicsScreen.dart new file mode 100644 index 0000000..a867284 --- /dev/null +++ b/lib/screens/RandomComicsScreen.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; + +import 'components/ComicListBuilder.dart'; + +class RandomComicsScreen extends StatefulWidget { + @override + State createState() => _RandomComicsScreenState(); +} + +class _RandomComicsScreenState extends State { + Future> _future = method.randomComics(); + + Future _reload() async { + setState(() { + _future = method.randomComics(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('随机本子'), + actions: [ + chooseLayoutAction(context), + ], + ), + body: ComicListBuilder(_future, _reload), + ); + } +} diff --git a/lib/screens/RankingsScreen.dart b/lib/screens/RankingsScreen.dart new file mode 100644 index 0000000..5f54513 --- /dev/null +++ b/lib/screens/RankingsScreen.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; + +import 'components/ComicListBuilder.dart'; + +class RankingsScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: Text('排行榜'), + actions: [ + chooseLayoutAction(context), + ], + ), + body: DefaultTabController( + length: 3, + child: Column( + children: [ + Container( + height: 40, + color: theme.colorScheme.secondary.withOpacity(.025), + child: TabBar( + indicatorColor: theme.colorScheme.secondary, + labelColor: theme.colorScheme.secondary, + tabs: [ + Tab(text: '天'), + Tab(text: '周'), + Tab(text: '月'), + ], + ), + ), + Expanded( + child: TabBarView( + children: [ + _Leaderboard("H24"), + _Leaderboard("D7"), + _Leaderboard("D30"), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _Leaderboard extends StatefulWidget { + final String type; + + _Leaderboard(this.type); + + @override + State createState() => _LeaderboardState(); +} + +class _LeaderboardState extends State<_Leaderboard> { + late Future> _future = method.leaderboard(widget.type); + + Future _reload() async { + setState(() { + _future = method.leaderboard(widget.type); + }); + } + + @override + Widget build(BuildContext context) { + return ComicListBuilder(_future, _reload); + } +} diff --git a/lib/screens/RegisterScreen.dart b/lib/screens/RegisterScreen.dart new file mode 100644 index 0000000..03d7365 --- /dev/null +++ b/lib/screens/RegisterScreen.dart @@ -0,0 +1,358 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_datetime_picker/flutter_datetime_picker.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/components/NetworkSetting.dart'; + +import 'components/ContentLoading.dart'; + +class RegisterScreen extends StatefulWidget { + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + + late bool _registering = false; + late bool _registerOver = false; + + late String _email = ""; + late String _name = ""; + late String _password = ""; + late String _gender = "bot"; + late String _birthday = "2000-01-01"; + late String _question1 = "问题1"; + late String _answer1 = "回答1"; + late String _question2 = "问题2"; + late String _answer2 = "回答2"; + late String _question3 = "问题3"; + late String _answer3 = "回答3"; + + Future _register() async { + setState(() { + _registering = true; + }); + try { + var mustList = [ + _email, + _name, + _password, + _gender, + _birthday, + _question1, + _answer1, + _question2, + _answer2, + _question3, + _answer3, + ]; + for (var a in mustList) { + if (a.isEmpty) { + throw '请检查表单, 不允许留空'; + } + } + await method.register( + _email, + _name, + _password, + _gender, + _birthday, + _question1, + _answer1, + _question2, + _answer2, + _question3, + _answer3, + ); + await method.setUsername(_email); + await method.setPassword(_password); + await method.clearToken(); + setState(() { + _registerOver = true; + }); + } catch (e) { + alertDialog(context, "注册失败", "$e"); + } finally { + setState(() { + _registering = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_registerOver) { + return Scaffold( + appBar: AppBar( + title: Text('注册成功'), + ), + body: Center( + child: Container( + child: Column( + children: [ + Expanded(child: Container()), + Text('您已经注册成功, 请返回登录'), + Text('账号 : $_email'), + Text('昵称 : $_name'), + Text('密码 : $_password'), + Expanded(child: Container()), + Expanded(child: Container()), + ], + ), + ), + ), + ); + } + if (_registering) { + return Scaffold( + appBar: AppBar(), + body: ContentLoading(label: '注册中'), + ); + } + return Scaffold( + appBar: AppBar(title: Text('注册'), actions: [ + IconButton(onPressed: () => _register(), icon: Icon(Icons.check),), + ],), + body: ListView( + children: [ + Divider(), + ListTile( + title: Text("账号 (不一定是邮箱/登录使用)"), + subtitle: Text(_email == "" ? "未设置" : _email), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '账号', + '请输入账号', + _email, + "", + ); + if (input != null) { + setState(() { + _email = input; + }); + } + }, + ), + ListTile( + title: Text("密码 (8位以上)"), + subtitle: Text(_password == "" ? "未设置" : _password), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '密码', + '请输入密码', + _password, + "", + ); + if (input != null) { + setState(() { + _password = input; + }); + } + }, + ), + ListTile( + title: Text("昵称 (2-50字)"), + subtitle: Text(_name == "" ? "未设置" : _name), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '昵称', + '请输入昵称', + _name, + "", + ); + if (input != null) { + setState(() { + _name = input; + }); + } + }, + ), + ListTile( + title: Text("性别"), + subtitle: Text(_genderText(_gender)), + onTap: () async { + String? result = await showDialog( + context: context, + builder: (BuildContext context) { + return SimpleDialog( + title: Text('选择您的性别'), + children: [ + SimpleDialogOption( + child: Text('扶她'), + onPressed: () { + Navigator.pop(context, 'bot'); + }, + ), + SimpleDialogOption( + child: Text('公'), + onPressed: () { + Navigator.pop(context, 'm'); + }, + ), + SimpleDialogOption( + child: Text('母'), + onPressed: () { + Navigator.pop(context, 'f'); + }, + ), + ], + ) + }, + ); + if (result != null) { + setState(() { + _gender = result; + }); + } + }, + ), + ListTile( + title: Text("生日"), + subtitle: Text(_birthday), + onTap: () async { + DatePicker.showDatePicker( + context, + locale: LocaleType.zh, + currentTime: DateTime.parse(_birthday), + onConfirm: (date) { + setState(() { + _birthday = formatTimeToDate(date.toString()); + }); + } + ); + }, + ), + Divider(), + ListTile( + title: Text("问题1"), + subtitle: Text(_question1 == "" ? "未设置" : _question1), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '问题1', + '请输入问题1', + _question1, + "", + ); + if (input != null) { + setState(() { + _question1 = input; + }); + } + }, + ), + ListTile( + title: Text("回答1"), + subtitle: Text(_answer1 == "" ? "未设置" : _answer1), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '回答1', + '请输入回答1', + _answer1, + "", + ); + if (input != null) { + setState(() { + _answer1 = input; + }); + } + }, + ), + ListTile( + title: Text("问题2"), + subtitle: Text(_question2 == "" ? "未设置" : _question2), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '问题2', + '请输入问题2', + _question2, + "", + ); + if (input != null) { + setState(() { + _question2 = input; + }); + } + }, + ), + ListTile( + title: Text("回答2"), + subtitle: Text(_answer2 == "" ? "未设置" : _answer2), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '回答2', + '请输入回答2', + _answer2, + "", + ); + if (input != null) { + setState(() { + _answer2 = input; + }); + } + }, + ), + ListTile( + title: Text("问题3"), + subtitle: Text(_question3 == "" ? "未设置" : _question3), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '问题3', + '请输入问题3', + _question3, + "", + ); + if (input != null) { + setState(() { + _question3 = input; + }); + } + }, + ), + ListTile( + title: Text("回答3"), + subtitle: Text(_answer3 == "" ? "未设置" : _answer3), + onTap: () async { + String? input = await displayTextInputDialog( + context, + '回答3', + '请输入回答3', + _answer3, + "", + ); + if (input != null) { + setState(() { + _answer3 = input; + }); + } + }, + ), + Divider(), + NetworkSetting(), + Divider(), + ], + ), + ); + } + + String _genderText(String gender) { + switch (gender) { + case 'bot': + return "扶她"; + case "m": + return "公"; + case "f": + return "母"; + default: + return ""; + } + } + +} diff --git a/lib/screens/SearchScreen.dart b/lib/screens/SearchScreen.dart new file mode 100644 index 0000000..d15dce0 --- /dev/null +++ b/lib/screens/SearchScreen.dart @@ -0,0 +1,125 @@ +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_search_bar/flutter_search_bar.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/basic/store/Categories.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; +import 'package:pikapi/basic/Method.dart'; +import '../basic/Entities.dart'; +import 'components/ComicPager.dart'; + +class SearchScreen extends StatefulWidget { + final String keyword; + final String? category; + + const SearchScreen({ + Key? key, + required this.keyword, + this.category, + }) : super(key: key); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + late TextEditingController _textEditController = + TextEditingController(text: widget.keyword); + late SearchBar _searchBar = SearchBar( + hintText: '搜索 ${categoryTitle(widget.category)}', + controller: _textEditController, + inBar: false, + setState: setState, + onSubmitted: (value) { + if (value.isNotEmpty) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => SearchScreen( + keyword: value, + category: widget.category, + ), + ), + ); + } + }, + buildDefaultAppBar: (BuildContext context) { + return AppBar( + title: Text("${categoryTitle(widget.category)} ${widget.keyword}"), + actions: [ + shadowCategoriesActionButton(context), + chooseLayoutAction(context), + _chooseCategoryAction(), + _searchBar.getSearchAction(context), + ], + ); + }, + ); + + Widget _chooseCategoryAction() => IconButton( + onPressed: () async { + String? category = await chooseListDialog(context, '请选择分类', [ + categoryTitle(null), + ...filteredList( + storedCategories, + (c) => !shadowCategories.contains(c), + ), + ]); + if (category != null) { + if (category == categoryTitle(null)) { + category = null; + } + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) { + return SearchScreen( + category: category, + keyword: widget.keyword, + ); + }, + )); + } + }, + icon: Icon(Icons.category), + ); + + Future _fetch(String _currentSort, int _currentPage) { + if (widget.category == null) { + return method.searchComics(widget.keyword, _currentSort, _currentPage); + } else { + return method.searchComicsInCategories( + widget.keyword, + _currentSort, + _currentPage, + [widget.category!], + ); + } + } + + @override + void initState() { + shadowCategoriesEvent.subscribe(_onShadowChange); + super.initState(); + } + + @override + void dispose() { + shadowCategoriesEvent.unsubscribe(_onShadowChange); + super.dispose(); + } + + void _onShadowChange(EventArgs? args) { + setState(() { + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _searchBar.build(context), + body: ComicPager( + fetchPage: _fetch, + ), + ); + } +} diff --git a/lib/screens/SettingsScreen.dart b/lib/screens/SettingsScreen.dart new file mode 100644 index 0000000..34d8daf --- /dev/null +++ b/lib/screens/SettingsScreen.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/config/AndroidDisplayMode.dart'; +import 'package:pikapi/basic/config/AutoClean.dart'; +import 'package:pikapi/basic/config/AutoFullScreen.dart'; +import 'package:pikapi/basic/config/ContentFailedReloadAction.dart'; +import 'package:pikapi/basic/config/FullScreenAction.dart'; +import 'package:pikapi/basic/config/FullScreenUI.dart'; +import 'package:pikapi/basic/config/KeyboardController.dart'; +import 'package:pikapi/basic/config/PagerAction.dart'; +import 'package:pikapi/basic/config/ReaderDirection.dart'; +import 'package:pikapi/basic/config/ReaderType.dart'; +import 'package:pikapi/basic/config/Quality.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/basic/config/VolumeController.dart'; +import 'package:pikapi/screens/components/NetworkSetting.dart'; + +import 'CleanScreen.dart'; + +class SettingsScreen extends StatefulWidget { + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text('设置')), + body: ListView( + children: [ + Divider(), + NetworkSetting(), + Divider(), + ListTile( + title: Text("浏览时的图片质量"), + subtitle: Text(currentQualityName()), + onTap: () async { + await chooseQuality(context); + setState(() {}); + }, + ), + ListTile( + title: Text("阅读器模式"), + subtitle: Text(currentReaderTypeName()), + onTap: () async { + await choosePagerType(context); + setState(() {}); + }, + ), + ListTile( + title: Text("阅读器方向"), + subtitle: Text(currentReaderDirectionName()), + onTap: () async { + await choosePagerDirection(context); + setState(() {}); + }, + ), + ListTile( + title: Text("进入阅读器自动全屏"), + subtitle: Text(autoFullScreenName()), + onTap: () async { + await chooseAutoFullScreen(context); + setState(() {}); + }, + ), + ListTile( + title: Text("进入全屏的方式"), + subtitle: Text(currentFullScreenActionName()), + onTap: () async { + await chooseFullScreenAction(context); + setState(() {}); + }, + ), + ListTile( + title: Text("阅读器音量键翻页(仅安卓)"), + subtitle: Text(volumeControllerName()), + onTap: () async { + await chooseVolumeController(context); + setState(() {}); + }, + ), + ListTile( + title: Text("阅读器键盘翻页(仅PC)"), + subtitle: Text(keyboardControllerName()), + onTap: () async { + await chooseKeyboardController(context); + setState(() {}); + }, + ), + Divider(), + ListTile( + title: Text("封印"), + subtitle: Text(jsonEncode(shadowCategories)), + onTap: () async { + await chooseShadowCategories(context); + setState(() {}); + }, + ), + ListTile( + title: Text("列表页加载方式"), + subtitle: Text(currentPagerActionName()), + onTap: () async { + await choosePagerAction(context); + setState(() {}); + }, + ), + ListTile( + title: Text("全屏UI"), + subtitle: Text(currentFullScreenUIName()), + onTap: () async { + await chooseFullScreenUI(context); + setState(() {}); + }, + ), + ListTile( + title: Text("加载失败时"), + subtitle: Text(currentContentFailedReloadActionName()), + onTap: () async { + await chooseContentFailedReloadAction(context); + setState(() {}); + }, + ), + Divider(), + ListTile( + title: Text("自动清理缓存"), + subtitle: Text(currentAutoCleanSec()), + onTap: () async { + await chooseAutoCleanSec(context); + setState(() {}); + }, + ), + ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => CleanScreen()), + ); + }, + title: Text('清除缓存'), + ), + Divider(), + ListTile( + title: Text("屏幕刷新率(安卓)"), + subtitle: Text(androidDisplayModeName()), + onTap: () async { + await chooseAndroidDisplayMode(context); + setState(() {}); + }, + ), + Divider(), + ], + ), + ); +} diff --git a/lib/screens/SpaceScreen.dart b/lib/screens/SpaceScreen.dart new file mode 100644 index 0000000..8bd829c --- /dev/null +++ b/lib/screens/SpaceScreen.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/config/Themes.dart'; +import 'package:pikapi/screens/AboutScreen.dart'; +import 'package:pikapi/screens/AccountScreen.dart'; +import 'package:pikapi/screens/DownloadListScreen.dart'; +import 'package:pikapi/screens/FavouritePaperScreen.dart'; +import 'package:pikapi/screens/ViewLogsScreen.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'SettingsScreen.dart'; +import 'components/UserProfileCard.dart'; + +// 个人空间页面 +class SpaceScreen extends StatefulWidget { + const SpaceScreen(); + + @override + State createState() => _SpaceScreenState(); +} + +class _SpaceScreenState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('我的'), + actions: [ + IconButton( + onPressed: () async { + bool result = + await confirmDialog(context, '退出登录', '您确认要退出当前账号吗?'); + if (result) { + await method.clearToken(); + await method.setPassword(""); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (context) => AccountScreen()), + ); + } + }, + icon: Icon(Icons.exit_to_app), + ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => AboutScreen()), + ); + }, + icon: Icon(Icons.info_outline), + ), + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => SettingsScreen()), + ); + }, + icon: Icon(Icons.settings), + ), + ], + ), + body: ListView( + children: [ + Divider(), + UserProfileCard(), + Divider(), + ListTile( + onTap: () async { + await chooseTheme(context); + setState(() {}); + }, + title: Text('主题'), + subtitle: Text(currentThemeName()), + ), + Divider(), + ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => FavouritePaperScreen()), + ); + }, + title: Text('我的收藏'), + ), + Divider(), + ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => ViewLogsScreen()), + ); + }, + title: Text('浏览记录'), + ), + Divider(), + ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => DownloadListScreen()), + ); + }, + title: Text('我的下载'), + ), + Divider(), + ], + ), + ); + } +} diff --git a/lib/screens/ViewLogsScreen.dart b/lib/screens/ViewLogsScreen.dart new file mode 100644 index 0000000..15d2467 --- /dev/null +++ b/lib/screens/ViewLogsScreen.dart @@ -0,0 +1,248 @@ + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'ComicInfoScreen.dart'; +import 'components/Images.dart'; + +// 浏览记录 +class ViewLogsScreen extends StatefulWidget { + const ViewLogsScreen(); + + @override + State createState() => _ViewLogsScreenState(); +} + +class _ViewLogsScreenState extends State { + static const _pageSize = 24; + static const _scrollPhysics = AlwaysScrollableScrollPhysics(); // 即使不足一页仍可滚动 + + final _scrollController = ScrollController(); + final _comicList = []; + + var _isLoading = false; // 是否加载中 + var _scrollOvered = false; // 滚动到最后 + var _offset = 0; + + Future _clearAll() async { + if (await confirmDialog( + context, + "您要清除所有浏览记录吗? ", + "将会同时删除浏览进度!", + )) { + await method.clearAllViewLog(); + setState(() { + _comicList.clear(); + _isLoading = false; + _scrollOvered = true; + _offset = 0; + }); + } + } + + Future _clearOnce(String id) async { + if (await confirmDialog( + context, + "您要清除这条浏览记录吗? ", + "将会同时删除浏览进度!", + )) { + await method.deleteViewLog(id); + setState(() { + for (var i = 0; i < _comicList.length; i++) { + if (_comicList[i].id == id) { + _comicList[i] = ViewLogWrapEntity( + _comicList[i].id, + _comicList[i].title, + _comicList[i].fileServer, + _comicList[i].path, + deleted: true, + ); + break; + } + } + }); + } + } + + // 加载一页 + Future _loadPage() async { + setState(() { + _isLoading = true; + }); + try { + var page = await method.viewLogPage(_offset, _pageSize); + if (page.isEmpty) { + _scrollOvered = true; + } else { + _comicList.addAll(page.map((e) => + ViewLogWrapEntity(e.id, e.title, e.thumbFileServer, e.thumbPath))); + } + _offset += _pageSize; + } finally { + setState(() { + _isLoading = false; + }); + } + } + + // 滚动事件 + void _handScroll() { + if (_scrollController.position.pixels + + MediaQuery.of(context).size.height / 2 < + _scrollController.position.maxScrollExtent) { + return; + } + if (_isLoading || _scrollOvered) return; + _loadPage(); + } + + @override + void initState() { + _loadPage(); + super.initState(); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + child: Scaffold( + appBar: AppBar( + title: Text('浏览记录'), + actions: [ + IconButton(onPressed: _clearAll, icon: Icon(Icons.auto_delete)), + ], + ), + body: ListView( + physics: _scrollPhysics, + controller: _scrollController, + children: [ + Container(height: 10), + ViewLogWrap( + onTapComic: _chooseComic, + comics: _comicList, + onDelete: _clearOnce, + ), + ], + ), + ), + onNotification: (scrollNotification) { + if (scrollNotification is ScrollStartNotification) { + _handScroll(); + } + return true; + }, + ); + } + + void _chooseComic(String comicId) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ComicInfoScreen( + comicId: comicId, + ), + ), + ); + } +} + + +class ViewLogWrap extends StatelessWidget { + final Function(String) onTapComic; + final List comics; + final Function(String id) onDelete; + + const ViewLogWrap({ + Key? key, + required this.onTapComic, + required this.comics, + required this.onDelete, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var width = (min - 45) / 4; + return Wrap( + alignment: WrapAlignment.spaceAround, + children: comics.map((e) { + if (e.deleted) { + return Card( + child: Container( + width: width, + child: Column( + children: [ + LayoutBuilder(builder: + (BuildContext context, BoxConstraints constraints) { + return RemoteImage( + width: constraints.maxWidth, + fileServer: e.fileServer, + path: e.path); + }), + Text( + '已删除\n', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1.4), + strutStyle: StrutStyle(height: 1.4), + ), + ], + ), + ), + ); + } else { + return InkWell( + onTap: () { + onTapComic(e.id); + }, + onLongPress: () { + onDelete(e.id); + }, + child: Card( + child: Container( + width: width, + child: Column( + children: [ + LayoutBuilder(builder: + (BuildContext context, BoxConstraints constraints) { + return RemoteImage( + width: constraints.maxWidth, + fileServer: e.fileServer, + path: e.path); + }), + Text( + e.title + '\n', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1.4), + strutStyle: StrutStyle(height: 1.4), + ), + ], + ), + ), + ), + ); + } + }).toList(), + ); + } +} + +class ViewLogWrapEntity { + final String id; + final String title; + final String fileServer; + final String path; + final bool deleted; + + ViewLogWrapEntity(this.id, this.title, this.fileServer, this.path, + {this.deleted = false}); +} diff --git a/lib/screens/components/ComicCommentItem.dart b/lib/screens/components/ComicCommentItem.dart new file mode 100644 index 0000000..c8c49b3 --- /dev/null +++ b/lib/screens/components/ComicCommentItem.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; + +import 'PicaAvatar.dart'; + +class ComicCommentItem extends StatelessWidget { + final Comment comment; + + const ComicCommentItem(this.comment); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var nameStyle = TextStyle(fontWeight: FontWeight.bold); + var levelStyle = TextStyle( + fontSize: 12, color: theme.colorScheme.secondary.withOpacity(.8)); + var connectStyle = + TextStyle(color: theme.textTheme.bodyText1?.color?.withOpacity(.8)); + var datetimeStyle = TextStyle( + color: theme.textTheme.bodyText1?.color?.withOpacity(.6), fontSize: 12); + return Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + bottom: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PicaAvatar(comment.user.avatar), + Container(width: 5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Container( + width: constraints.maxWidth, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.spaceBetween, + children: [ + Text(comment.user.name, style: nameStyle), + Text( + formatTimeToDateTime(comment.createdAt), + style: datetimeStyle, + ), + ], + ), + ); + }, + ), + Container(height: 3), + LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Container( + width: constraints.maxWidth, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.spaceBetween, + children: [ + Text( + "Lv. ${comment.user.level} (${comment.user.title})", + style: levelStyle), + comment.commentsCount > 0 + ? Text.rich(TextSpan(children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon(Icons.message, + size: 13, + color: theme.colorScheme.secondary + .withOpacity(.7)), + ), + WidgetSpan(child: Container(width: 5)), + TextSpan( + text: '${comment.commentsCount}', + style: levelStyle), + ])) + : Container(), + ], + ), + ); + }, + ), + Container(height: 5), + Text(comment.content, style: connectStyle), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/components/ComicCommentList.dart b/lib/screens/components/ComicCommentList.dart new file mode 100644 index 0000000..7515fad --- /dev/null +++ b/lib/screens/components/ComicCommentList.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/CommentScreen.dart'; +import 'package:pikapi/screens/components/ItemBuilder.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'ComicCommentItem.dart'; + +// 漫画的评论列表 +class ComicCommentList extends StatefulWidget { + final String comicId; + + ComicCommentList(this.comicId); + + @override + State createState() => _ComicCommentListState(); +} + +class _ComicCommentListState extends State { + late int _currentPage = 1; + late Future _future = _loadPage(); + + Future _loadPage() { + return method.comments(widget.comicId, _currentPage); + } + + @override + Widget build(BuildContext context) { + return ItemBuilder( + future: _future, + successBuilder: + (BuildContext context, AsyncSnapshot snapshot) { + var page = snapshot.data!; + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildPrePage(page), + ...page.docs.map((e) => _buildComment(e)), + _buildPostComment(), + _buildNextPage(page), + ], + ); + }, + onRefresh: () async => { + setState(() { + _future = _loadPage(); + }) + }, + ); + } + + Widget _buildComment(Comment comment) { + return InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => CommentScreen(widget.comicId, comment), + ), + ); + }, + child: ComicCommentItem(comment), + ); + } + + Widget _buildPostComment() { + return InkWell( + onTap: () async { + String? text = await inputString(context, '请输入评论内容'); + if (text != null && text.isNotEmpty) { + try { + await method.postComment(widget.comicId, text); + setState(() { + _future = _loadPage(); + }); + } catch (e) { + defaultToast(context, "评论失败"); + } + } + }, + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + bottom: BorderSide( + width: .25, + style: BorderStyle.solid, + color: Colors.grey.shade500.withOpacity(.5), + ), + ), + ), + padding: EdgeInsets.all(30), + child: Center( + child: Text('我有话要讲'), + ), + ), + ); + } + + Widget _buildPrePage(CommentPage page) { + if (page.page > 1) { + return InkWell( + onTap: () { + setState(() { + _currentPage = page.page - 1; + _future = _loadPage(); + }); + }, + child: Container( + padding: EdgeInsets.all(30), + child: Center( + child: Text('上一页'), + ), + ), + ); + } + return Container(); + } + + Widget _buildNextPage(CommentPage page) { + if (page.page < page.pages) { + return InkWell( + onTap: () { + setState(() { + _currentPage = page.page + 1; + _future = _loadPage(); + }); + }, + child: Container( + padding: EdgeInsets.all(30), + child: Center( + child: Text('下一页'), + ), + ), + ); + } + return Container(); + } +} diff --git a/lib/screens/components/ComicDescriptionCard.dart b/lib/screens/components/ComicDescriptionCard.dart new file mode 100644 index 0000000..983cada --- /dev/null +++ b/lib/screens/components/ComicDescriptionCard.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +// 漫画的说明 +class ComicDescriptionCard extends StatelessWidget { + final String description; + + ComicDescriptionCard({Key? key, required this.description}) : super(key: key); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Container( + padding: EdgeInsets.only( + top: 5, + bottom: 5, + left: 10, + right: 10, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + child: SelectableText(description, style: _categoriesStyle), + ); + } +} + +const _categoriesStyle = TextStyle( + fontSize: 13, + color: Colors.grey, +); diff --git a/lib/screens/components/ComicInfoCard.dart b/lib/screens/components/ComicInfoCard.dart new file mode 100644 index 0000000..04b2d46 --- /dev/null +++ b/lib/screens/components/ComicInfoCard.dart @@ -0,0 +1,327 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/screens/SearchScreen.dart'; +import 'package:pikapi/basic/Navigatior.dart'; +import '../ComicsScreen.dart'; +import 'Images.dart'; + +// 漫画卡片 +class ComicInfoCard extends StatefulWidget { + final bool linkItem; + final ComicSimple info; + + const ComicInfoCard(this.info, {Key? key, this.linkItem = false}) + : super(key: key); + + @override + State createState() => _ComicInfoCard(); +} + +class _ComicInfoCard extends State { + bool _favouriteLoading = false; + bool _likeLoading = false; + + @override + Widget build(BuildContext context) { + var info = widget.info; + var theme = Theme.of(context); + var view = info is ComicInfo ? info.viewsCount : 0; + bool? like = info is ComicInfo ? info.isLiked : null; + bool? favourite = info is ComicInfo ? (info).isFavourite : null; + return Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + child: Row( + children: [ + Container( + padding: EdgeInsets.only(right: 10), + child: RemoteImage( + fileServer: info.thumb.fileServer, + path: info.thumb.path, + width: imageWidth, + height: imageHeight, + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + widget.linkItem + ? GestureDetector( + onLongPress: () { + confirmCopy(context, info.title); + }, + child: Text(info.title, style: titleStyle), + ) + : Text(info.title, style: titleStyle), + Container(height: 5), + widget.linkItem + ? InkWell( + onTap: () { + navPushOrReplace( + context, + (context) => + SearchScreen(keyword: info.author)); + }, + onLongPress: () { + confirmCopy(context, info.author); + }, + child: Text(info.author, style: authorStyle), + ) + : Text(info.author, style: authorStyle), + Container(height: 5), + Text.rich( + widget.linkItem + ? TextSpan( + children: [ + TextSpan(text: '分类 :'), + ...info.categories.map( + (e) => TextSpan( + children: [ + TextSpan(text: ' '), + TextSpan( + text: e, + recognizer: TapGestureRecognizer() + ..onTap = () => navPushOrReplace( + context, + (context) => ComicsScreen( + category: e, + ), + ), + ), + ], + ), + ), + ], + ) + : TextSpan( + text: "分类 : ${info.categories.join(' ')}"), + style: TextStyle( + fontSize: 13, + color: Theme.of(context) + .textTheme + .bodyText1! + .color! + .withAlpha(0xCC), + ), + ), + Container(height: 5), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 5, + children: [ + ...info.likesCount > 0 + ? [ + iconFavorite, + iconSpacing, + Text( + '${info.likesCount}', + style: iconLabelStyle, + strutStyle: iconLabelStrutStyle, + ), + iconMargin, + ] + : [], + ...(view > 0 + ? [ + iconVisibility, + iconSpacing, + Text( + '$view', + style: iconLabelStyle, + strutStyle: iconLabelStrutStyle, + ), + iconMargin, + ] + : []), + info.epsCount > 0 + ? Row( + children: [ + iconPage, + iconSpacing, + Text( + "${info.epsCount}E / ${info.pagesCount}P", + style: countLabelStyle, + strutStyle: iconLabelStrutStyle, + ), + ], + ) + : Container(), + iconMargin, + ], + ), + ], + ), + ), + Container( + height: imageHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + buildFinished(info.finished), + Expanded(child: Container()), + ...(like == null + ? [] + : [ + _likeLoading + ? IconButton( + color: Colors.pink[400], + onPressed: () {}, + icon: Icon( + Icons.sync, + size: 26, + ), + ) + : IconButton( + color: Colors.pink[400], + onPressed: _changeLike, + icon: Icon( + like + ? Icons.favorite + : Icons.favorite_border, + size: 26, + ), + ), + ]), + ...(favourite == null + ? [] + : [ + _favouriteLoading + ? IconButton( + color: Colors.pink[400], + onPressed: () {}, + icon: Icon( + Icons.sync, + size: 26, + ), + ) + : IconButton( + color: Colors.pink[400], + onPressed: _changeFavourite, + icon: Icon( + favourite + ? Icons.bookmark + : Icons.bookmark_border, + size: 26, + ), + ), + ]), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Future _changeFavourite() async { + setState(() { + _favouriteLoading = true; + }); + try { + var rst = await method.switchFavourite(widget.info.id); + setState(() { + (widget.info as ComicInfo).isFavourite = !rst.startsWith("un"); + }); + } finally { + setState(() { + _favouriteLoading = false; + }); + } + } + + Future _changeLike() async { + setState(() { + _likeLoading = true; + }); + try { + var rst = await method.switchLike(widget.info.id); + setState(() { + (widget.info as ComicInfo).isLiked = !rst.startsWith("un"); + }); + } finally { + setState(() { + _likeLoading = false; + }); + } + } +} + +double imageWidth = 210 / 3.15; +double imageHeight = 315 / 3.15; + +Widget buildFinished(bool comicFinished) { + if (comicFinished) { + return Container( + padding: EdgeInsets.only(left: 8, right: 8), + decoration: BoxDecoration( + color: Colors.orange.shade800, + borderRadius: BorderRadius.circular(30), + ), + child: Text( + "完结", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + height: 1.2, + ), + strutStyle: StrutStyle( + height: 1.2, + ), + ), + ); + } + return Container(); +} + +const double _iconSize = 15; + +final iconFavorite = + Icon(Icons.favorite, size: _iconSize, color: Colors.pink[400]); +final iconDownload = + Icon(Icons.download_rounded, size: _iconSize, color: Colors.pink[400]); +final iconVisibility = + Icon(Icons.visibility, size: _iconSize, color: Colors.pink[400]); + +final iconLabelStyle = TextStyle( + fontSize: 13, + color: Colors.pink.shade400, + height: 1.2, +); +final iconLabelStrutStyle = StrutStyle( + height: 1.2, +); + +final iconPage = + Icon(Icons.ballot_outlined, size: _iconSize, color: Colors.grey); +final countLabelStyle = TextStyle( + fontSize: 13, + color: Colors.grey, + height: 1.2, +); + +final iconMargin = Container(width: 20); +final iconSpacing = Container(width: 5); + +final titleStyle = TextStyle(fontWeight: FontWeight.bold); +final authorStyle = TextStyle( + fontSize: 13, + color: Colors.pink.shade300, +); diff --git a/lib/screens/components/ComicList.dart b/lib/screens/components/ComicList.dart new file mode 100644 index 0000000..d319edd --- /dev/null +++ b/lib/screens/components/ComicList.dart @@ -0,0 +1,327 @@ +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/config/ShadowCategories.dart'; +import 'package:pikapi/basic/config/ListLayout.dart'; + +import 'ComicInfoCard.dart'; +import 'Images.dart'; +import 'LinkToComicInfo.dart'; + +// 漫画列表页 +class ComicList extends StatefulWidget { + final Widget? appendWidget; + final List comicList; + final ScrollController? controller; + + const ComicList(this.comicList, {this.appendWidget, this.controller}); + + @override + State createState() => _ComicListState(); +} + +class _ComicListState extends State { + @override + void initState() { + listLayoutEvent.subscribe(_onLayoutChange); + super.initState(); + } + + @override + void dispose() { + listLayoutEvent.unsubscribe(_onLayoutChange); + super.dispose(); + } + + void _onLayoutChange(EventArgs? args) { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + switch (currentLayout) { + case ListLayout.INFO_CARD: + return _buildInfoCardList(); + case ListLayout.ONLY_IMAGE: + return _buildGridImageWarp(); + case ListLayout.COVER_AND_TITLE: + return _buildGridImageTitleWarp(); + default: + return Container(); + } + } + + Widget _buildInfoCardList() { + return ListView( + controller: widget.controller, + physics: const AlwaysScrollableScrollPhysics(), + children: [ + ...widget.comicList.map((e) { + var shadow = e.categories + .map((e) => shadowCategories.contains(e)) + .reduce((value, element) => value || element); + if (shadow) { + return InkWell( + onTap: () {}, + child: Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Center( + child: Text( + '被封印的本子', + style: TextStyle( + fontSize: 12, + color: (Theme.of(context).textTheme.bodyText1?.color ?? + Colors.black) + .withOpacity(.3), + ), + ), + ), + ), + ); + } + return LinkToComicInfo( + comicId: e.id, + child: ComicInfoCard(e), + ); + }).toList(), + ...widget.appendWidget != null + ? [ + Container( + height: 80, + child: widget.appendWidget, + ), + ] + : [], + ], + ); + } + + Widget _buildGridImageWarp() { + var gap = 3.0; + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var widthAndGap = min / 4; + int rowCap = size.width ~/ widthAndGap; + var width = widthAndGap - gap * 2; + var height = width * coverHeight / coverWidth; + List wraps = []; + List tmp = []; + widget.comicList.forEach((e) { + var shadow = e.categories + .map((e) => shadowCategories.contains(e)) + .reduce((value, element) => value || element); + if (shadow) { + tmp.add( + Container( + padding: EdgeInsets.all(gap), + child: Container( + width: width, + height: height, + color: + (Theme.of(context).textTheme.bodyText1?.color ?? Colors.black) + .withOpacity(.05), + child: Center( + child: Text( + '被封印的本子', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: (Theme.of(context).textTheme.bodyText1?.color ?? + Colors.black) + .withOpacity(.5), + ), + ), + ), + ), + ), + ); + } else { + tmp.add(LinkToComicInfo( + comicId: e.id, + child: Container( + padding: EdgeInsets.all(gap), + child: RemoteImage( + fileServer: e.thumb.fileServer, + path: e.thumb.path, + width: width, + height: height, + ), + ), + )); + } + if (tmp.length == rowCap) { + wraps.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: tmp, + )); + tmp = []; + } + }); + // 追加特殊按钮 + if (widget.appendWidget != null) { + tmp.add(Container( + color: (Theme.of(context).textTheme.bodyText1?.color ?? Color(0)) + .withOpacity(.1), + margin: EdgeInsets.only( + left: (rowCap - tmp.length) * gap, + right: (rowCap - tmp.length) * gap, + top: gap, + bottom: gap, + ), + width: (rowCap - tmp.length) * width, + height: height, + child: widget.appendWidget, + )); + } + // 最后一页没有下一页所有有可能为空 + if (tmp.length > 0) { + wraps.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: tmp, + )); + tmp = []; + } + // 返回 + return ListView( + controller: widget.controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.only(top: gap, bottom: gap), + children: wraps, + ); + } + + Widget _buildGridImageTitleWarp() { + var gap = 3.0; + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var widthAndGap = min / 3; + int rowCap = size.width ~/ widthAndGap; + var width = widthAndGap - gap * 2; + var height = width * coverHeight / coverWidth; + List wraps = []; + List tmp = []; + widget.comicList.forEach((e) { + var shadow = e.categories + .map((e) => shadowCategories.contains(e)) + .reduce((value, element) => value || element); + if (shadow) { + tmp.add( + Container( + padding: EdgeInsets.all(gap), + child: Container( + width: width, + height: height, + color: + (Theme.of(context).textTheme.bodyText1?.color ?? Colors.black) + .withOpacity(.05), + child: Center( + child: Text( + '被封印的本子', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: (Theme.of(context).textTheme.bodyText1?.color ?? + Colors.black) + .withOpacity(.5), + ), + ), + ), + ), + ), + ); + } else { + tmp.add(LinkToComicInfo( + comicId: e.id, + child: Container( + margin: EdgeInsets.all(gap), + width: width, + height: height, + child: Stack( + children: [ + RemoteImage( + fileServer: e.thumb.fileServer, + path: e.thumb.path, + width: width, + height: height, + ), + Align( + alignment: Alignment.bottomCenter, + child: Container( + color: Colors.black.withOpacity(.3), + child: Row( + children: [ + Expanded( + child: Text( + e.title + '\n', + style: TextStyle( + color: Colors.white, + fontSize: 10, + height: 1.2, + ), + strutStyle: StrutStyle(height: 1.2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + ), + ), + )); + } + if (tmp.length == rowCap) { + wraps.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: tmp, + )); + tmp = []; + } + }); + // 追加特殊按钮 + if (widget.appendWidget != null) { + tmp.add(Container( + color: (Theme.of(context).textTheme.bodyText1?.color ?? Color(0)) + .withOpacity(.1), + margin: EdgeInsets.only( + left: (rowCap - tmp.length) * gap, + right: (rowCap - tmp.length) * gap, + top: gap, + bottom: gap, + ), + width: (rowCap - tmp.length) * width, + height: height, + child: widget.appendWidget, + )); + } + // 最后一页没有下一页所有有可能为空 + if (tmp.length > 0) { + wraps.add(Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: tmp, + )); + tmp = []; + } + // 返回 + return ListView( + controller: widget.controller, + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.only(top: gap, bottom: gap), + children: wraps, + ); + } +} diff --git a/lib/screens/components/ComicListBuilder.dart b/lib/screens/components/ComicListBuilder.dart new file mode 100644 index 0000000..cf48b19 --- /dev/null +++ b/lib/screens/components/ComicListBuilder.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/components/ComicList.dart'; +import 'package:pikapi/screens/components/FitButton.dart'; +import 'ContentBuilder.dart'; + +class ComicListBuilder extends StatelessWidget { + final Future> future; + final Future Function() reload; + + ComicListBuilder(this.future, this.reload); + + @override + Widget build(BuildContext context) { + return ContentBuilder( + future: future, + onRefresh: reload, + successBuilder: + (BuildContext context, AsyncSnapshot> snapshot) { + return RefreshIndicator( + onRefresh: reload, + child: ComicList( + snapshot.data!, + appendWidget: FitButton( + onPressed: reload, + text: '刷新', + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/components/ComicPager.dart b/lib/screens/components/ComicPager.dart new file mode 100644 index 0000000..18d15a4 --- /dev/null +++ b/lib/screens/components/ComicPager.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/config/PagerAction.dart'; +import 'package:pikapi/basic/enum/Sort.dart'; +import 'package:pikapi/screens/components/ComicList.dart'; +import 'package:pikapi/screens/components/ContentError.dart'; +import 'package:pikapi/screens/components/FitButton.dart'; +import 'ContentLoading.dart'; + +// 漫画列页 +class ComicPager extends StatelessWidget { + final Future Function(String sort, int page) fetchPage; + + const ComicPager({required this.fetchPage}); + + @override + Widget build(BuildContext context) { + switch (currentPagerAction) { + case PagerAction.CONTROLLER: + return ControllerComicPager(fetchPage: fetchPage); + case PagerAction.STREAM: + return StreamComicPager(fetchPage: fetchPage); + default: + return Container(); + } + } +} + +class ControllerComicPager extends StatefulWidget { + final Future Function(String sort, int page) fetchPage; + + const ControllerComicPager({ + Key? key, + required this.fetchPage, + }) : super(key: key); + + @override + State createState() => _ControllerComicPagerState(); +} + +class _ControllerComicPagerState extends State { + final TextEditingController _textEditController = + TextEditingController(text: ''); + late String _currentSort = SORT_DEFAULT; + late int _currentPage = 1; + late Future _pageFuture; + + Future _load() async { + setState(() { + _pageFuture = widget.fetchPage(_currentSort, _currentPage); + }); + } + + @override + void initState() { + _load(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _pageFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.connectionState == ConnectionState.none) { + return Text('初始化'); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: _load, + ); + } + var comicsPage = snapshot.data!; + return Scaffold( + appBar: _buildAppBar(comicsPage, context), + body: ComicList( + comicsPage.docs, + appendWidget: _buildNextButton(comicsPage), + ), + ); + }, + ); + } + + PreferredSize _buildAppBar(ComicsPage comicsPage, BuildContext context) { + return PreferredSize( + preferredSize: Size.fromHeight(40), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: .5, + style: BorderStyle.solid, + color: Colors.grey[200]!, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container(width: 10), + DropdownButton( + items: items, + value: _currentSort, + onChanged: (String? value) { + if (value != null) { + _currentPage = 1; + _currentSort = value; + _load(); + } + }, + ), + ], + ), + InkWell( + onTap: () { + _textEditController.clear(); + showDialog( + context: context, + builder: (context) { + return AlertDialog( + content: Card( + child: Container( + child: TextField( + controller: _textEditController, + decoration: new InputDecoration( + labelText: "请输入页数:", + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'\d+')), + ], + ), + ), + ), + actions: [ + MaterialButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text('取消'), + ), + MaterialButton( + onPressed: () { + Navigator.pop(context); + var text = _textEditController.text; + if (text.length == 0 || text.length > 5) { + return; + } + var num = int.parse(text); + if (num == 0 || num > comicsPage.pages) { + return; + } + _currentPage = num; + _load(); + }, + child: Text('确定'), + ), + ], + ); + }, + ); + }, + child: Row( + children: [ + Text("第 ${comicsPage.page} / ${comicsPage.pages} 页"), + ], + ), + ), + Row( + children: [ + MaterialButton( + minWidth: 0, + onPressed: () { + if (comicsPage.page > 1) { + _currentPage = comicsPage.page - 1; + _load(); + } + }, + child: Text('上一页'), + ), + MaterialButton( + minWidth: 0, + onPressed: () { + if (comicsPage.page < comicsPage.pages) { + _currentPage = comicsPage.page + 1; + _load(); + } + }, + child: Text('下一页'), + ) + ], + ), + ], + ), + ), + ); + } + + Widget? _buildNextButton(ComicsPage comicsPage) { + if (comicsPage.page < comicsPage.pages) { + return FitButton( + onPressed: () { + _currentPage = comicsPage.page + 1; + _load(); + }, + text: '下一页', + ); + } + } +} + +class StreamComicPager extends StatefulWidget { + final Future Function(String sort, int page) fetchPage; + + const StreamComicPager({ + Key? key, + required this.fetchPage, + }) : super(key: key); + + @override + State createState() => _StreamComicPagerState(); +} + +class _StreamComicPagerState extends State { + final _scrollController = ScrollController(); + late String _currentSort = SORT_DEFAULT; + late int _currentPage = 1; + late int _maxPage = 0; + late List _list = []; + late bool _loading = false; + late bool _over = false; + late bool _error = false; + late Future _pageFuture; + + void _onScroll() { + if (_over || _error || _loading) { + return; + } + if (_scrollController.offset + MediaQuery.of(context).size.height / 2 < + _scrollController.position.maxScrollExtent) { + return; + } + _load(); + } + + Future _load() async { + setState(() { + _pageFuture = _fetch(); + }); + } + + Future _fetch() async { + _error = false; + setState(() { + _loading = true; + }); + try { + var page = await widget.fetchPage(_currentSort, _currentPage); + setState(() { + _currentPage++; + _maxPage = page.pages; + _list.addAll(page.docs); + _over = page.page >= page.pages; + }); + } catch (e, s) { + _error = true; + print("$e\n$s"); + throw e; + } finally { + setState(() { + _loading = false; + }); + } + } + + @override + void initState() { + _load(); + _scrollController.addListener(_onScroll); + super.initState(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: _buildAppBar(context), + body: ComicList( + _list, + controller: _scrollController, + appendWidget: _buildLoadingCell(), + ), + ); + } + + PreferredSize _buildAppBar(BuildContext context) { + return PreferredSize( + preferredSize: Size.fromHeight(40), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: .5, + style: BorderStyle.solid, + color: Colors.grey[200]!, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container(width: 10), + DropdownButton( + items: items, + value: _currentSort, + onChanged: (String? value) { + if (value != null) { + _list = []; + _currentPage = 1; + _currentSort = value; + _load(); + } + }, + ), + ], + ), + Row( + children: [ + Text("已经加载 ${_currentPage - 1} / $_maxPage 页"), + ], + ), + ], + ), + ), + ); + } + + Widget? _buildLoadingCell() { + if (_error) { + return FitButton( + onPressed: () { + setState(() { + _error = false; + }); + _load(); + }, + text: '网络错误 / 点击刷新'); + } + if (_loading) { + return FitButton(onPressed: () {}, text: '加载中'); + } + } +} diff --git a/lib/screens/components/ComicTagsCard.dart b/lib/screens/components/ComicTagsCard.dart new file mode 100644 index 0000000..20db538 --- /dev/null +++ b/lib/screens/components/ComicTagsCard.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/screens/ComicsScreen.dart'; +import 'package:pikapi/basic/Navigatior.dart'; + +// 漫画tag +class ComicTagsCard extends StatelessWidget { + final List tags; + + const ComicTagsCard(this.tags, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Container( + padding: EdgeInsets.only(top: 5, bottom: 5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + child: Wrap( + children: tags.map((e) { + return InkWell( + onTap: () { + navPushOrReplace(context, (context) => ComicsScreen(tag: e)); + }, + child: Container( + padding: EdgeInsets.only( + left: 10, + right: 10, + top: 3, + bottom: 3, + ), + margin: EdgeInsets.only( + left: 5, + right: 5, + top: 3, + bottom: 3, + ), + decoration: BoxDecoration( + color: Colors.pink.shade100, + border: Border.all( + style: BorderStyle.solid, + color: Colors.pink.shade400, + ), + borderRadius: BorderRadius.all(Radius.circular(30)), + ), + child: Text( + e, + style: TextStyle( + color: Colors.pink.shade500, + height: 1.4, + ), + strutStyle: StrutStyle( + height: 1.4, + ), + ), + ), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/screens/components/ContentBuilder.dart b/lib/screens/components/ContentBuilder.dart new file mode 100644 index 0000000..daf96da --- /dev/null +++ b/lib/screens/components/ContentBuilder.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'ContentError.dart'; +import 'ContentLoading.dart'; + +class ContentBuilder extends StatelessWidget { + final Future future; + final Future Function() onRefresh; + final AsyncWidgetBuilder successBuilder; + + const ContentBuilder( + {Key? key, + required this.future, + required this.onRefresh, + required this.successBuilder}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return ContentError( + error: snapshot.error, + stackTrace: snapshot.stackTrace, + onRefresh: onRefresh, + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return ContentLoading(label: '加载中'); + } + return successBuilder(context, snapshot); + }, + ); + } +} diff --git a/lib/screens/components/ContentError.dart b/lib/screens/components/ContentError.dart new file mode 100644 index 0000000..e787de7 --- /dev/null +++ b/lib/screens/components/ContentError.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/config/ContentFailedReloadAction.dart'; +import 'dart:ui'; + +import 'package:pikapi/basic/enum/ErrorTypes.dart'; + +class ContentError extends StatelessWidget { + final Object? error; + final StackTrace? stackTrace; + final Future Function() onRefresh; + + const ContentError({ + Key? key, + required this.error, + required this.stackTrace, + required this.onRefresh, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var type = errorType("$error"); + late String message; + switch (type) { + case ERROR_TYPE_NETWORK: + message = "连接不上啦, 请检查网络"; + break; + default: + message = "啊哦, 被玩坏了"; + break; + } + return LayoutBuilder(builder: (BuildContext context, BoxConstraints constraints) { + print("$error"); + print("$stackTrace"); + var width = constraints.maxWidth; + var height = constraints.maxHeight; + var min = width < height ? width : height; + var iconSize = min / 2.3; + var textSize = min / 16; + var tipSize = min / 20; + var infoSize = min / 30; + if (contentFailedReloadAction == + ContentFailedReloadAction.TOUCH_LOADER) { + return GestureDetector( + onTap: onRefresh, + child: ListView( + children: [ + Container( + height: height, + child: Column( + children: [ + Expanded(child: Container()), + Container( + child: Icon( + Icons.wifi_off_rounded, + size: iconSize, + color: Colors.grey.shade600, + ), + ), + Container(height: min / 10), + Container( + padding: EdgeInsets.only( + left: 30, + right: 30, + ), + child: Text( + message, + style: TextStyle(fontSize: textSize), + textAlign: TextAlign.center, + ), + ), + Text('(点击刷新)', style: TextStyle(fontSize: tipSize)), + Container(height: min / 15), + Text('$error', style: TextStyle(fontSize: infoSize)), + Expanded(child: Container()), + ], + ), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: onRefresh, + child: ListView( + children: [ + Container( + height: height, + child: Column( + children: [ + Expanded(child: Container()), + Container( + child: Icon( + Icons.wifi_off_rounded, + size: iconSize, + color: Colors.grey.shade600, + ), + ), + Container(height: min / 10), + Container( + padding: EdgeInsets.only( + left: 30, + right: 30, + ), + child: Text( + message, + style: TextStyle(fontSize: textSize), + textAlign: TextAlign.center, + ), + ), + Text('(下拉刷新)', style: TextStyle(fontSize: tipSize)), + Container(height: min / 15), + Text('$error', style: TextStyle(fontSize: infoSize)), + Expanded(child: Container()), + ], + ), + ), + ], + ), + ); + },); + } +} diff --git a/lib/screens/components/ContentLoading.dart b/lib/screens/components/ContentLoading.dart new file mode 100644 index 0000000..2cac12d --- /dev/null +++ b/lib/screens/components/ContentLoading.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class ContentLoading extends StatelessWidget { + final String label; + + const ContentLoading({Key? key, required this.label}) : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + var width = constraints.maxWidth; + var height = constraints.maxHeight; + var min = width < height ? width : height; + var theme = Theme.of(context); + return Center( + child: Column( + children: [ + Expanded(child: Container()), + SizedBox( + width: min / 2, + height: min / 2, + child: CircularProgressIndicator( + color: theme.colorScheme.secondary, + backgroundColor: Colors.grey[100], + ), + ), + Container(height: min / 10), + Text(label, style: TextStyle(fontSize: min / 15)), + Expanded(child: Container()), + ], + ), + ); + }, + ); + } +} diff --git a/lib/screens/components/ContinueReadButton.dart b/lib/screens/components/ContinueReadButton.dart new file mode 100644 index 0000000..f598e86 --- /dev/null +++ b/lib/screens/components/ContinueReadButton.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; + +class ContinueReadButton extends StatefulWidget { + final Future viewFuture; + final Function(int? epOrder, int? pictureRank) onChoose; + + const ContinueReadButton({ + Key? key, + required this.viewFuture, + required this.onChoose, + }) : super(key: key); + + @override + State createState() => _ContinueReadButtonState(); +} + +class _ContinueReadButtonState extends State { + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + var width = constraints.maxWidth; + return FutureBuilder( + future: widget.viewFuture, + builder: (BuildContext context, AsyncSnapshot snapshot) { + late void Function() onPressed; + late String text; + if (snapshot.connectionState != ConnectionState.done) { + onPressed = () {}; + text = '加载中'; + } + if (snapshot.data != null && snapshot.data!.lastViewEpOrder > 0) { + onPressed = () => widget.onChoose( + snapshot.data?.lastViewEpOrder, + snapshot.data?.lastViewPictureRank, + ); + text = + '继续阅读 ${snapshot.data?.lastViewEpTitle} P. ${snapshot.data?.lastViewPictureRank}'; + } else { + onPressed = () => widget.onChoose(null, null); + text = '开始阅读'; + } + return Container( + padding: EdgeInsets.only(left: 10, right: 10), + margin: EdgeInsets.only(bottom: 10), + width: width, + child: MaterialButton( + onPressed: onPressed, + child: Row( + children: [ + Expanded( + child: Container( + color: Theme.of(context) + .textTheme + .bodyText1! + .color! + .withOpacity(.05), + padding: EdgeInsets.all(10), + child: Text( + text, + textAlign: TextAlign.center, + ), + ), + ) + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/screens/components/DownloadInfoCard.dart b/lib/screens/components/DownloadInfoCard.dart new file mode 100644 index 0000000..f153c26 --- /dev/null +++ b/lib/screens/components/DownloadInfoCard.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/components/Images.dart'; + +import 'ComicInfoCard.dart'; + +class DownloadInfoCard extends StatelessWidget { + final DownloadComic task; + final bool downloading; + final bool linkItem; + + DownloadInfoCard({ + Key? key, + required this.task, + this.downloading = false, + this.linkItem = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textColor = theme.textTheme.bodyText1!.color!; + var textColorAlpha = textColor.withAlpha(0x33); + var textColorSummary = textColor.withAlpha(0xCC); + var titleStyle = TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + ); + var categoriesStyle = TextStyle( + fontSize: 13, + color: textColorSummary, + ); + var authorStyle = TextStyle( + fontSize: 13, + color: Colors.pink.shade300, + ); + var iconColor = Colors.pink.shade300; + var iconLabelStyle = TextStyle( + fontSize: 13, + color: iconColor, + ); + List categories = json.decode(task.categories); + var categoriesString = categories.map((e) => "$e").join(" "); + return Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + child: Row( + children: [ + Container( + padding: EdgeInsets.only(right: 10), + child: task.thumbLocalPath == "" + ? RemoteImage( + fileServer: task.thumbFileServer, + path: task.thumbPath, + width: imageWidth, + height: imageHeight, + ) + : DownloadImage( + path: task.thumbLocalPath, + width: imageWidth, + height: imageHeight, + ), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + linkItem + ? GestureDetector( + onLongPress: () { + confirmCopy(context, task.title); + }, + child: Text(task.title, style: titleStyle), + ) + : Text(task.title, style: titleStyle), + Container(height: 5), + linkItem + ? GestureDetector( + onLongPress: () { + confirmCopy(context, task.author); + }, + child: Text(task.author, style: authorStyle), + ) + : Text(task.author, style: authorStyle), + Container(height: 5), + Text( + "分类: $categoriesString", + style: categoriesStyle, + ), + Container(height: 5), + Row( + children: [ + Icon( + Icons.download, + size: iconSize, + color: iconColor, + ), + Container(width: 5), + Text( + '下载 ${task.downloadPictureCount} / ${task.selectedPictureCount}', + style: iconLabelStyle, + ), + Container(width: 20), + task.deleting + ? Container( + child: Text('删除中', + style: TextStyle( + color: Color.alphaBlend( + textColor.withAlpha(0x33), + Colors.red.shade500))), + ) + : task.downloadFailed + ? Container( + child: Text('下载失败', + style: TextStyle( + color: Color.alphaBlend( + textColor.withAlpha(0x33), + Colors.red.shade500))), + ) + : task.downloadFinished + ? Container( + child: Text('下载完成', + style: TextStyle( + color: Color.alphaBlend( + textColorAlpha, + Colors.green.shade500))), + ) + : downloading // downloader.downloadingTask() == task.id + ? Container( + child: Text('下载中', + style: TextStyle( + color: Color.alphaBlend( + textColorAlpha, + Colors + .blue.shade500))), + ) + : Container( + child: Text('队列中', + style: TextStyle( + color: Color.alphaBlend( + textColorAlpha, + Colors.lightBlue + .shade500))), + ), + ], + ), + ], + ), + ), + Container( + padding: EdgeInsets.only(left: 8), + height: imageHeight, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + buildFinished(task.finished), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +double imageWidth = 210 / 3.15; +double imageHeight = 315 / 3.15; +double iconSize = 15; diff --git a/lib/screens/components/FitButton.dart b/lib/screens/components/FitButton.dart new file mode 100644 index 0000000..67f625e --- /dev/null +++ b/lib/screens/components/FitButton.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class FitButton extends StatelessWidget { + final void Function() onPressed; + final String text; + + const FitButton({Key? key, required this.onPressed, required this.text}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return SizedBox( + width: constraints.maxWidth, + height: constraints.maxHeight, + child: Container( + padding: EdgeInsets.all(10), + child: MaterialButton( + onPressed: onPressed, + child: Container( + child: Center( + child: Text(text), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/screens/components/GameTitleCard.dart b/lib/screens/components/GameTitleCard.dart new file mode 100644 index 0000000..dc87a84 --- /dev/null +++ b/lib/screens/components/GameTitleCard.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; + +import 'Images.dart'; + +class GameTitleCard extends StatelessWidget { + final GameInfo info; + + const GameTitleCard(this.info); + + @override + Widget build(BuildContext context) { + double iconMargin = 20; + double iconSize = 60; + BorderRadius iconRadius = BorderRadius.all(Radius.circular(6)); + TextStyle titleStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold); + TextStyle publisherStyle = TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 12.5, + ); + TextStyle versionStyle = TextStyle( + fontSize: 12.5, + ); + return Row( + children: [ + Container( + padding: EdgeInsets.all(iconMargin), + child: ClipRRect( + borderRadius: iconRadius, + child: RemoteImage( + width: iconSize, + height: iconSize, + fileServer: info.icon.fileServer, + path: info.icon.path, + ), + ), + ), + Container(width: 10), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(info.title, style: titleStyle), + Text(info.publisher, style: publisherStyle), + Text(info.version, style: versionStyle), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/components/ImageReader.dart b/lib/screens/components/ImageReader.dart new file mode 100644 index 0000000..e5d8657 --- /dev/null +++ b/lib/screens/components/ImageReader.dart @@ -0,0 +1,874 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:another_xlider/another_xlider.dart'; +import 'package:event/event.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:pikapi/basic/Channels.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Cross.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:pikapi/basic/config/FullScreenAction.dart'; +import 'package:pikapi/basic/config/KeyboardController.dart'; +import 'package:pikapi/basic/config/ReaderDirection.dart'; +import 'package:pikapi/basic/config/ReaderType.dart'; +import 'package:pikapi/basic/config/VolumeController.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../FilePhotoViewScreen.dart'; +import 'gesture_zoom_box.dart'; + +import 'Images.dart'; + +/////////////// + +Event<_ReaderControllerEventArgs> _readerControllerEvent = + Event<_ReaderControllerEventArgs>(); + +class _ReaderControllerEventArgs extends EventArgs { + final String key; + + _ReaderControllerEventArgs(this.key); +} + +Widget readerKeyboardHolder(Widget widget) { + if (keyboardController && + (Platform.isWindows || Platform.isMacOS || Platform.isLinux)) { + widget = RawKeyboardListener( + focusNode: FocusNode(), + child: widget, + autofocus: true, + onKey: (event) { + if (event is RawKeyDownEvent) { + if (event.isKeyPressed(LogicalKeyboardKey.arrowUp)) { + _readerControllerEvent.broadcast(_ReaderControllerEventArgs("UP")); + } + if (event.isKeyPressed(LogicalKeyboardKey.arrowDown)) { + _readerControllerEvent + .broadcast(_ReaderControllerEventArgs("DOWN")); + } + } + }, + ); + } + return widget; +} + +void _onVolumeEvent(dynamic args) { + _readerControllerEvent.broadcast(_ReaderControllerEventArgs("$args")); +} + +var _volumeListenCount = 0; + +// 仅支持安卓 +// 监听后会拦截安卓手机音量键 +// 仅最后一次监听生效 +// event可能为DOWN/UP +EventChannel volumeButtonChannel = EventChannel("volume_button"); +StreamSubscription? volumeS; + +addVolumeListen() { + _volumeListenCount++; + if (_volumeListenCount == 1) { + volumeS = + volumeButtonChannel.receiveBroadcastStream().listen(_onVolumeEvent); + } +} + +delVolumeListen() { + _volumeListenCount--; + if (_volumeListenCount == 0) { + volumeS?.cancel(); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +// 对Reader的传参以及封装 + +class ReaderImageInfo { + final String fileServer; + final String path; + final String? downloadLocalPath; + final int? width; + final int? height; + final String? format; + final int? fileSize; + + ReaderImageInfo(this.fileServer, this.path, this.downloadLocalPath, + this.width, this.height, this.format, this.fileSize); +} + +class ImageReaderStruct { + final List images; + final bool fullScreen; + final FutureOr Function(bool fullScreen) onFullScreenChange; + final String onNextText; + final FutureOr Function() onNextAction; + final FutureOr Function(int) onPositionChange; + final int? initPosition; + final ReaderType pagerType; + final ReaderDirection pagerDirection; + + const ImageReaderStruct({ + required this.images, + required this.fullScreen, + required this.onFullScreenChange, + required this.onNextText, + required this.onNextAction, + required this.onPositionChange, + this.initPosition, + required this.pagerType, + required this.pagerDirection, + }); +} + +// + +class ImageReader extends StatelessWidget { + final ImageReaderStruct struct; + + const ImageReader(this.struct); + + @override + Widget build(BuildContext context) { + late Widget reader; + switch (struct.pagerType) { + case ReaderType.WEB_TOON: + reader = _WebToonReader(struct); + break; + case ReaderType.WEB_TOON_ZOOM: + reader = _WebToonZoomReader(struct); + break; + case ReaderType.GALLERY: + reader = _GalleryReader(struct); + break; + default: + reader = Container(); + break; + } + switch (fullScreenAction) { + case FullScreenAction.CONTROLLER: + reader = Stack( + children: [ + reader, + _buildFullScreenController( + struct.fullScreen, + struct.onFullScreenChange, + ), + ], + ); + break; + case FullScreenAction.TOUCH_ONCE: + reader = GestureDetector( + onTap: () => struct.onFullScreenChange(!struct.fullScreen), + child: reader, + ); + break; + } + // + return reader; + } + + Widget _buildFullScreenController( + bool fullScreen, + FutureOr Function(bool fullScreen) onFullScreenChange, + ) { + return Align( + alignment: Alignment.bottomLeft, + child: Material( + color: Color(0x0), + child: Container( + padding: EdgeInsets.only(left: 10, right: 10, top: 4, bottom: 4), + margin: EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(10), + bottomRight: Radius.circular(10), + ), + color: Color(0x88000000), + ), + child: GestureDetector( + onTap: () { + onFullScreenChange(!fullScreen); + }, + child: Icon( + fullScreen ? Icons.fullscreen_exit : Icons.fullscreen_outlined, + size: 30, + color: Colors.white, + ), + ), + ), + ), + ); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class _WebToonReader extends StatefulWidget { + final ImageReaderStruct struct; + + const _WebToonReader(this.struct); + + @override + State createState() => _WebToonReaderState(); +} + +class _WebToonReaderState extends State<_WebToonReader> { + late final List _trueSizes = []; + late final ItemScrollController _itemScrollController; + late final ItemPositionsListener _itemPositionsListener; + late final int _initialPosition; + + var _current = 1; + var _slider = 1; + + void _onCurrentChange() { + var to = _itemPositionsListener.itemPositions.value.first.index + 1; + if (_current != to) { + setState(() { + _current = to; + _slider = to; + if (to - 1 < widget.struct.images.length) { + widget.struct.onPositionChange(to - 1); + } + }); + } + } + + @override + void initState() { + widget.struct.images.forEach((e) { + if (e.downloadLocalPath != null) { + _trueSizes.add(Size(e.width!.toDouble(), e.height!.toDouble())); + } else { + _trueSizes.add(null); + } + }); + _itemScrollController = ItemScrollController(); + _itemPositionsListener = ItemPositionsListener.create(); + _itemPositionsListener.itemPositions.addListener(_onCurrentChange); + if (widget.struct.initPosition != null && + widget.struct.images.length > widget.struct.initPosition!) { + _initialPosition = widget.struct.initPosition!; + } else { + _initialPosition = 0; + } + _readerControllerEvent.subscribe(_onPageControllerEvent); + super.initState(); + } + + @override + void dispose() { + _itemPositionsListener.itemPositions.removeListener(_onCurrentChange); + _readerControllerEvent.unsubscribe(_onPageControllerEvent); + super.dispose(); + } + + void _onPageControllerEvent(_ReaderControllerEventArgs? args) { + if (args != null) { + var event = args.key; + print("EVENT : $event"); + switch ("$event") { + case "UP": + if (_current > 1) { + if (DateTime.now().millisecondsSinceEpoch < _controllerTime) { + return; + } + _controllerTime = DateTime.now().millisecondsSinceEpoch + 400; + _itemScrollController.scrollTo( + index: _current - 2, // 减1 当前position 再减少1 前一个 + duration: Duration(milliseconds: 400), + ); + } + break; + case "DOWN": + if (_current < widget.struct.images.length) { + if (DateTime.now().millisecondsSinceEpoch < _controllerTime) { + return; + } + _controllerTime = DateTime.now().millisecondsSinceEpoch + 400; + _itemScrollController.scrollTo( + index: _current, + duration: Duration(milliseconds: 400), + ); + } + break; + } + } + } + + var _controllerTime = DateTime.now().millisecondsSinceEpoch + 400; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.black, + ), + child: Stack( + children: [ + _buildList(), + ..._buildControllers(), + ], + ), + ); + } + + Widget _buildList() { + var scaffold = Scaffold.of(context); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + // reload _images size + List _images = []; + for (var index = 0; index < widget.struct.images.length; index++) { + late Size renderSize; + if (_trueSizes[index] != null) { + if (widget.struct.pagerDirection == ReaderDirection.TOP_TO_BOTTOM) { + renderSize = Size( + constraints.maxWidth, + constraints.maxWidth * + _trueSizes[index]!.height / + _trueSizes[index]!.width, + ); + } else { + renderSize = Size( + constraints.maxHeight * + _trueSizes[index]!.width / + _trueSizes[index]!.height, + constraints.maxHeight, + ); + } + } else { + if (widget.struct.pagerDirection == ReaderDirection.TOP_TO_BOTTOM) { + renderSize = Size(constraints.maxWidth, constraints.maxWidth / 2); + } else { + // ReaderDirection.LEFT_TO_RIGHT + // ReaderDirection.RIGHT_TO_LEFT + renderSize = + Size(constraints.maxWidth / 2, constraints.maxHeight); + } + } + var currentIndex = index; + var onTrueSize = (Size size) { + setState(() { + _trueSizes[currentIndex] = size; + }); + }; + var e = widget.struct.images[index]; + if (e.downloadLocalPath != null) { + _images.add(_WebToonDownloadImage( + fileServer: e.fileServer, + path: e.path, + localPath: e.downloadLocalPath!, + fileSize: e.fileSize!, + width: e.width!, + height: e.height!, + format: e.format!, + size: renderSize, + onTrueSize: onTrueSize, + )); + } else { + _images.add(_WebToonRemoteImage( + e.fileServer, + e.path, + renderSize, + onTrueSize, + )); + } + } + return ScrollablePositionedList.builder( + initialScrollIndex: _initialPosition, + scrollDirection: + widget.struct.pagerDirection == ReaderDirection.TOP_TO_BOTTOM + ? Axis.vertical + : Axis.horizontal, + reverse: + widget.struct.pagerDirection == ReaderDirection.RIGHT_TO_LEFT, + padding: widget.struct.fullScreen + ? EdgeInsets.only( + top: scaffold.appBarMaxHeight ?? 0, + bottom: scaffold.appBarMaxHeight ?? 0, + ) + : null, + itemScrollController: _itemScrollController, + itemPositionsListener: _itemPositionsListener, + itemCount: widget.struct.images.length + 1, + itemBuilder: (BuildContext context, int index) { + if (widget.struct.images.length == index) { + return _buildNextEp(); + } + return _images[index]; + }, + ); + }, + ); + } + + List _buildControllers() { + if (widget.struct.fullScreen) { + return []; + } + return [ + _buildImageCount(context, "$_current / ${widget.struct.images.length}"), + _buildScrollController( + context, + _current, + _slider, + widget.struct.images.length, + (value) => _slider = value, + () { + if (_slider != _current && _slider > 0) { + _itemScrollController.jumpTo(index: _slider - 1); + } + }, + ), + ]; + } + + Widget _buildNextEp() { + return Container( + padding: EdgeInsets.all(20), + child: MaterialButton( + onPressed: widget.struct.onNextAction, + textColor: Colors.white, + child: Container( + padding: EdgeInsets.only(top: 40, bottom: 40), + child: Text(widget.struct.onNextText), + ), + ), + ); + } +} + +// 来自下载 +class _WebToonDownloadImage extends _WebToonReaderImage { + final String fileServer; + final String path; + final String localPath; + final int fileSize; + final int width; + final int height; + final String format; + + _WebToonDownloadImage({ + required this.fileServer, + required this.path, + required this.localPath, + required this.fileSize, + required this.width, + required this.height, + required this.format, + required Size size, + Function(Size)? onTrueSize, + }) : super(size, onTrueSize); + + @override + Future imageData() async { + if (localPath == "") { + return method.remoteImageData(fileServer, path); + } + var finalPath = await method.downloadImagePath(localPath); + return RemoteImageData.forData( + fileSize, + format, + width, + height, + finalPath, + ); + } +} + +// 来自远端 +class _WebToonRemoteImage extends _WebToonReaderImage { + final String fileServer; + final String path; + + _WebToonRemoteImage( + this.fileServer, + this.path, + Size size, + Function(Size)? onTrueSize, + ) : super(size, onTrueSize); + + @override + Future imageData() async { + return method.remoteImageData(fileServer, path); + } +} + +// 通用 +abstract class _WebToonReaderImage extends StatefulWidget { + final Size size; + final Function(Size)? onTrueSize; + + _WebToonReaderImage(this.size, this.onTrueSize); + + @override + State createState() => _WebToonReaderImageState(); + + Future imageData(); +} + +class _WebToonReaderImageState extends State<_WebToonReaderImage> { + late Future _future = _load(); + + Future _load() { + return widget.imageData().then((value) { + widget.onTrueSize?.call( + Size(value.width.toDouble(), value.height.toDouble()), + ); + return value; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return FutureBuilder( + future: _future, + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasError) { + return GestureDetector( + onLongPress: () async { + String? choose = + await chooseListDialog(context, '请选择', ['重新加载图片']); + switch (choose) { + case '重新加载图片': + setState(() { + _future = _load(); + }); + break; + } + }, + child: buildError(widget.size.width, widget.size.height), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return buildLoading(widget.size.width, widget.size.height); + } + var data = snapshot.data!; + return GestureDetector( + onLongPress: () async { + String? choose = + await chooseListDialog(context, '请选择', ['预览图片', '保存图片']); + switch (choose) { + case '预览图片': + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => FilePhotoViewScreen(data.finalPath), + )); + break; + case '保存图片': + saveImage(data.finalPath, context); + break; + } + }, + child: buildFile( + data.finalPath, + widget.size.width, + widget.size.height, + ), + ); + }, + ); + }, + ); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class _WebToonZoomReader extends _WebToonReader { + const _WebToonZoomReader( + ImageReaderStruct struct, + ) : super(struct); + + @override + State createState() => _WebToonZoomReaderState(); +} + +class _WebToonZoomReaderState extends _WebToonReaderState { + @override + Widget _buildList() { + return GestureZoomBox(child: super._buildList()); + } +} + +/////////////////////////////////////////////////////////////////////////////// + +class _GalleryReader extends StatefulWidget { + final ImageReaderStruct struct; + + _GalleryReader(this.struct); + + @override + State createState() => _GalleryReaderState(); +} + +class _GalleryReaderState extends State<_GalleryReader> { + late int _current = (widget.struct.initPosition ?? 0) + 1; + late int _slider = (widget.struct.initPosition ?? 0) + 1; + late PageController _pageController = + PageController(initialPage: widget.struct.initPosition ?? 0); + + @override + void initState() { + _readerControllerEvent.subscribe(_onPageControllerEvent); + super.initState(); + } + + @override + void dispose() { + _pageController.dispose(); + _readerControllerEvent.unsubscribe(_onPageControllerEvent); + super.dispose(); + } + + void _onPageControllerEvent(_ReaderControllerEventArgs? args) { + if (args != null) { + var event = args.key; + print("EVENT : $event"); + switch ("$event") { + case "UP": + if (_current > 1) { + _pageController.previousPage( + duration: Duration(milliseconds: 400), + curve: Curves.ease, + ); + } + break; + case "DOWN": + if (_current < widget.struct.images.length) { + _pageController.nextPage( + duration: Duration(milliseconds: 400), + curve: Curves.ease, + ); + } + break; + } + } + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + _buildViewer(), + ..._buildControllers(), + ], + ); + } + + Widget _buildViewer() { + return PhotoViewGallery.builder( + scrollDirection: + widget.struct.pagerDirection == ReaderDirection.TOP_TO_BOTTOM + ? Axis.vertical + : Axis.horizontal, + reverse: widget.struct.pagerDirection == ReaderDirection.RIGHT_TO_LEFT, + backgroundDecoration: BoxDecoration(color: Colors.black), + loadingBuilder: (context, event) => LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return buildLoading(constraints.maxWidth, constraints.maxHeight); + }, + ), + pageController: _pageController, + onPageChanged: (value) { + setState(() { + _current = value + 1; + _slider = value + 1; + widget.struct.onPositionChange(value); + }); + }, + itemCount: widget.struct.images.length, + builder: (BuildContext context, int index) { + var item = widget.struct.images[index]; + if (item.downloadLocalPath != null) { + return PhotoViewGalleryPageOptions( + imageProvider: + PicaDownloadFileImageProvider(item.downloadLocalPath!), + errorBuilder: (b, e, s) { + print("$e,$s"); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return buildError( + constraints.maxWidth, constraints.maxHeight); + }, + ); + }, + ); + } + return PhotoViewGalleryPageOptions( + imageProvider: PicaRemoteImageProvider(item.fileServer, item.path), + errorBuilder: (b, e, s) { + print("$e,$s"); + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return buildError(constraints.maxWidth, constraints.maxHeight); + }, + ); + }, + ); + }, + ); + } + + List _buildControllers() { + var controllers = []; + if (!widget.struct.fullScreen) { + controllers.addAll([ + _buildImageCount(context, "$_current / ${widget.struct.images.length}"), + _buildScrollController( + context, + _current, + _slider, + widget.struct.images.length, + (value) => _slider = value, + () { + if (_slider != _current && _slider > 0) { + _pageController.jumpToPage(_slider - 1); + } + }, + ), + ]); + } + if (_current == widget.struct.images.length) { + controllers.add(_buildNextEpController( + widget.struct.onNextAction, + widget.struct.onNextText, + )); + } + return controllers; + } +} + +/////////////////////////////////////////////////////////////////////////////// + +Widget _buildImageCount(BuildContext context, String info) { + return Align( + alignment: Alignment.topRight, + child: Material( + color: Color(0x0), + child: Container( + margin: EdgeInsets.only(top: 10), + padding: EdgeInsets.only(left: 10, right: 10, top: 4, bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + color: Color(0x88000000), + ), + child: GestureDetector( + onTap: () { + // TODO 输入跳转页数 + }, + child: Text("$info", style: TextStyle(color: Colors.white)), + ), + ), + ), + ); +} + +Widget _buildScrollController( + BuildContext context, + int current, + int slider, + int total, + Function(int) onSliderChange, + Function() onSliderDown, +) { + if (total == 0) { + return Container(); + } + var theme = Theme.of(context); + return Align( + alignment: Alignment.centerRight, + child: Material( + color: Color(0x0), + child: Container( + width: 35, + height: 300, + decoration: BoxDecoration( + color: Color(0x66000000), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + ), + padding: EdgeInsets.only(top: 10, bottom: 10, left: 5, right: 6), + child: Center( + child: FlutterSlider( + axis: Axis.vertical, + values: [(slider > total ? total : slider).toDouble()], + max: total.toDouble(), + min: 1, + onDragging: (handlerIndex, lowerValue, upperValue) { + onSliderChange(lowerValue.toInt()); + }, + onDragCompleted: (handlerIndex, lowerValue, upperValue) { + onSliderChange(lowerValue.toInt()); + onSliderDown(); + }, + trackBar: FlutterSliderTrackBar( + inactiveTrackBar: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: Colors.grey.shade300, + ), + activeTrackBar: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: theme.colorScheme.secondary, + ), + ), + step: FlutterSliderStep( + step: 1, + isPercentRange: false, + ), + tooltip: FlutterSliderTooltip(custom: (value) { + double a = value; + return Container( + padding: EdgeInsets.all(5), + color: Colors.white, + child: + Text('${a.toInt()}', style: TextStyle(color: Colors.black)), + ); + }), + ), + ), + ), + ), + ); +} + +Widget _buildNextEpController(Function() next, String text) { + return Align( + alignment: Alignment.bottomRight, + child: Material( + color: Color(0x0), + child: Container( + margin: EdgeInsets.only(bottom: 10), + padding: EdgeInsets.only(left: 10, right: 10, top: 4, bottom: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + bottomLeft: Radius.circular(10), + ), + color: Color(0x88000000), + ), + child: GestureDetector( + onTap: () { + next(); + }, + child: Text(text, style: TextStyle(color: Colors.white)), + ), + ), + ), + ); +} diff --git a/lib/screens/components/Images.dart b/lib/screens/components/Images.dart new file mode 100644 index 0000000..7b7f330 --- /dev/null +++ b/lib/screens/components/Images.dart @@ -0,0 +1,307 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:pikapi/basic/Method.dart'; +import 'package:flutter_svg/svg.dart'; +import 'dart:io'; +import 'dart:ui' as ui show Codec; + +// 从本地加载图片 +class PicaFileImageProvider extends ImageProvider { + final String path; + final double scale; + + PicaFileImageProvider(this.path, {this.scale = 1.0}); + + @override + ImageStreamCompleter load(PicaFileImageProvider key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key), + scale: key.scale, + ); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + Future _loadAsync(PicaFileImageProvider key) async { + assert(key == this); + return PaintingBinding.instance! + .instantiateImageCodec(await File(path).readAsBytes()); + } + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final PicaFileImageProvider typedOther = other; + return path == typedOther.path && scale == typedOther.scale; + } + + @override + int get hashCode => hashValues(path, scale); + + @override + String toString() => '$runtimeType(' + 'path: ${describeIdentity(path)},' + ' scale: $scale' + ')'; +} + +// 从本地加载图片 +class PicaDownloadFileImageProvider + extends ImageProvider { + final String path; + final double scale; + + PicaDownloadFileImageProvider(this.path, {this.scale = 1.0}); + + @override + ImageStreamCompleter load( + PicaDownloadFileImageProvider key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key), + scale: key.scale, + ); + } + + @override + Future obtainKey( + ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + Future _loadAsync(PicaDownloadFileImageProvider key) async { + assert(key == this); + return PaintingBinding.instance!.instantiateImageCodec( + await File(await method.downloadImagePath(path)).readAsBytes()); + } + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final PicaDownloadFileImageProvider typedOther = other; + return path == typedOther.path && scale == typedOther.scale; + } + + @override + int get hashCode => hashValues(path, scale); + + @override + String toString() => '$runtimeType(' + 'path: ${describeIdentity(path)},' + ' scale: $scale' + ')'; +} + +// 从远端加载图片 暂时未使用 (现在都是先获取路径然后再通过file显示) +class PicaRemoteImageProvider extends ImageProvider { + final String fileServer; + final String path; + final double scale; + + PicaRemoteImageProvider(this.fileServer, this.path, {this.scale = 1.0}); + + @override + ImageStreamCompleter load( + PicaRemoteImageProvider key, DecoderCallback decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key), + scale: key.scale, + ); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + Future _loadAsync(PicaRemoteImageProvider key) async { + assert(key == this); + var downloadTo = await method.remoteImageData(fileServer, path); + return PaintingBinding.instance! + .instantiateImageCodec(await File(downloadTo.finalPath).readAsBytes()); + } + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final PicaRemoteImageProvider typedOther = other; + return fileServer == typedOther.fileServer && + path == typedOther.path && + scale == typedOther.scale; + } + + @override + int get hashCode => hashValues(fileServer, path, scale); + + @override + String toString() => '$runtimeType(' + 'fileServer: ${describeIdentity(fileServer)},' + ' path: ${describeIdentity(path)},' + ' scale: $scale' + ')'; +} + +// 下载的图片 +class DownloadImage extends StatefulWidget { + final String path; + final double? width; + final double? height; + + const DownloadImage({ + Key? key, + required this.path, + this.width, + this.height, + }) : super(key: key); + + @override + State createState() => _DownloadImageState(); +} + +class _DownloadImageState extends State { + late Future _future = method.downloadImagePath(widget.path); + + @override + Widget build(BuildContext context) { + return pathFutureImage(_future, widget.width, widget.height); + } +} + +// 远端图片 +class RemoteImage extends StatefulWidget { + final String fileServer; + final String path; + final double? width; + final double? height; + final BoxFit fit; + + const RemoteImage({ + Key? key, + required this.fileServer, + required this.path, + this.width, + this.height, + this.fit = BoxFit.cover, + }) : super(key: key); + + @override + State createState() => _RemoteImageState(); +} + +class _RemoteImageState extends State { + late bool _mock; + late Future _future; + + @override + void initState() { + _mock = + widget.fileServer == "" || widget.fileServer.contains(".xyz/"); + if (!_mock) { + _future = method + .remoteImageData(widget.fileServer, widget.path) + .then((value) => value.finalPath); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (_mock) { + return buildMock(widget.width, widget.height); + } + return pathFutureImage(_future, widget.width, widget.height, + fit: widget.fit); + } +} + +// 通用方法 + +Widget buildSvg(String source, double? width, double? height, + {Color? color, double? margin}) { + return Container( + width: width, + height: height, + padding: margin != null ? EdgeInsets.all(10) : null, + child: Center( + child: SvgPicture.asset( + source, + width: width, + height: height, + color: color, + ), + ), + ); +} + +Widget buildMock(double? width, double? height) { + return Container( + width: width, + height: height, + padding: EdgeInsets.all(10), + child: Center( + child: SvgPicture.asset( + 'lib/assets/unknown.svg', + width: width, + height: height, + color: Colors.grey.shade600, + ), + ), + ); +} + +Widget buildError(double? width, double? height) { + return Image( + image: AssetImage('lib/assets/error.png'), + width: width, + height: height, + ); +} + +Widget buildLoading(double? width, double? height) { + return Container( + width: width, + height: height, + child: Center( + child: Icon( + Icons.downloading, + size: width, + color: Colors.black12, + ), + ), + ); +} + +Widget buildFile(String file, double? width, double? height, + {BoxFit fit = BoxFit.cover}) { + return Image( + image: PicaFileImageProvider(file), + width: width, + height: height, + errorBuilder: (a, b, c) { + print("$b"); + print("$c"); + return buildError(width, height); + }, + fit: fit, + ); +} + +Widget pathFutureImage(Future future, double? width, double? height, + {BoxFit fit = BoxFit.cover}) { + return FutureBuilder( + future: future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + print("${snapshot.error}"); + print("${snapshot.stackTrace}"); + return buildError(width, height); + } + if (snapshot.connectionState != ConnectionState.done) { + return buildLoading(width, height); + } + return buildFile(snapshot.data!, width, height, fit: fit); + }); +} diff --git a/lib/screens/components/ItemBuilder.dart b/lib/screens/components/ItemBuilder.dart new file mode 100644 index 0000000..cfa7000 --- /dev/null +++ b/lib/screens/components/ItemBuilder.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class ItemBuilder extends StatelessWidget { + final Future future; + final AsyncWidgetBuilder successBuilder; + final Future Function() onRefresh; + final double? loadingHeight; + final double? height; + + const ItemBuilder({ + Key? key, + required this.future, + required this.successBuilder, + required this.onRefresh, + this.height, + this.loadingHeight, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + var _maxWidth = constraints.maxWidth; + var _loadingHeight = height ?? loadingHeight ?? _maxWidth / 2; + return FutureBuilder( + future: future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + print("${snapshot.error}"); + print("${snapshot.stackTrace}"); + return InkWell( + onTap: onRefresh, + child: Container( + width: _maxWidth, + height: _loadingHeight, + child: Center( + child: + Icon(Icons.sync_problem, size: _loadingHeight / 1.5), + ), + ), + ); + } + if (snapshot.connectionState != ConnectionState.done) { + return Container( + width: _maxWidth, + height: _loadingHeight, + child: Center( + child: Icon(Icons.sync, size: _loadingHeight / 1.5), + ), + ); + } + return Container( + width: _maxWidth, + height: height, + child: successBuilder(context, snapshot), + ); + }); + }, + ); + } +} diff --git a/lib/screens/components/LinkToComicInfo.dart b/lib/screens/components/LinkToComicInfo.dart new file mode 100644 index 0000000..33f78b3 --- /dev/null +++ b/lib/screens/components/LinkToComicInfo.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Navigatior.dart'; + +import '../ComicInfoScreen.dart'; + +class LinkToComicInfo extends StatelessWidget { + final String comicId; + final Widget child; + + const LinkToComicInfo({ + required this.comicId, + required this.child, + }); + + @override + Widget build(BuildContext context) => InkWell( + onTap: () { + navPushOrReplace( + context, + (context) => ComicInfoScreen(comicId: comicId), + ); + }, + child: child, + ); +} diff --git a/lib/screens/components/NetworkSetting.dart b/lib/screens/components/NetworkSetting.dart new file mode 100644 index 0000000..b0ae695 --- /dev/null +++ b/lib/screens/components/NetworkSetting.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/config/Address.dart'; +import 'package:pikapi/basic/config/Proxy.dart'; + +// 网络设置 +class NetworkSetting extends StatefulWidget { + @override + State createState() => _NetworkSettingState(); +} + +class _NetworkSettingState extends State { + @override + Widget build(BuildContext context) { + return Container( + child: Column( + children: [ + ListTile( + title: Text("分流"), + subtitle: Text(currentAddressName()), + onTap: () async { + await chooseAddress(context); + setState(() {}); + }, + ), + ListTile( + title: Text("代理服务器"), + subtitle: Text(currentProxyName()), + onTap: () async { + await inputProxy(context); + setState(() {}); + }, + ), + ], + ), + ); + } +} diff --git a/lib/screens/components/PicaAvatar.dart b/lib/screens/components/PicaAvatar.dart new file mode 100644 index 0000000..c12c9c1 --- /dev/null +++ b/lib/screens/components/PicaAvatar.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/basic/Method.dart'; +import '../FilePhotoViewScreen.dart'; +import 'Images.dart'; + +const double _avatarMargin = 5; +const double _avatarBorderSize = 1.5; + +// 头像 +class PicaAvatar extends StatefulWidget { + final PicaImage avatarImage; + final double size; + + const PicaAvatar(this.avatarImage, {this.size = 50}); + + @override + State createState() => _PicaAvatarState(); +} + +class _PicaAvatarState extends State { + late Future _future = _load(); + + Future _load() async { + if (widget.avatarImage.fileServer == '') { + return ''; + } + return method + .remoteImageData(widget.avatarImage.fileServer, widget.avatarImage.path) + .then((value) => value.finalPath); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Container( + margin: EdgeInsets.all(_avatarMargin), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: theme.colorScheme.secondary, + style: BorderStyle.solid, + width: _avatarBorderSize, + )), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(widget.size)), + child: _image(), + ), + ); + } + + Widget _image() { + return FutureBuilder( + future: _future, + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) { + return buildError(widget.size, widget.size); + } + if (snapshot.connectionState != ConnectionState.done) { + return buildLoading(widget.size, widget.size); + } + if (snapshot.data == '' || snapshot.data == null) { + return buildMock(widget.size, widget.size); + } + return GestureDetector( + onTap: () { + Navigator.of(context).push(MaterialPageRoute( + builder: (context) => FilePhotoViewScreen(snapshot.data!), + )); + }, + child: buildFile(snapshot.data!, widget.size, widget.size), + ); + }, + ); + } +} diff --git a/lib/screens/components/Recommendation.dart b/lib/screens/components/Recommendation.dart new file mode 100644 index 0000000..adfc4bf --- /dev/null +++ b/lib/screens/components/Recommendation.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/ComicInfoScreen.dart'; +import 'package:pikapi/basic/Method.dart'; + +import 'ItemBuilder.dart'; +import 'Images.dart'; + +// 看过此本子的也在看 +// 一直返回空数组, 所以没有使用 +class Recommendation extends StatefulWidget { + final String comicId; + + const Recommendation({Key? key, required this.comicId}) : super(key: key); + + @override + State createState() => _RecommendationState(); +} + +class _RecommendationState extends State { + late Future> _future = method.recommendation(widget.comicId); + + @override + Widget build(BuildContext context) { + return ItemBuilder( + future: _future, + successBuilder: + (BuildContext context, AsyncSnapshot> snapshot) { + var _comicList = snapshot.data!; + var size = MediaQuery.of(context).size; + var min = size.width < size.height ? size.width : size.height; + var width = (min - 45) / 4; + return Wrap( + alignment: WrapAlignment.spaceAround, + children: _comicList + .map((e) => InkWell( + onTap: () { + var i = 0; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (context) => + ComicInfoScreen(comicId: e.id)), + (route) => i++ < 10); + }, + child: Card( + child: Container( + width: width, + child: Column( + children: [ + LayoutBuilder(builder: (BuildContext context, + BoxConstraints constraints) { + return RemoteImage( + width: width, + fileServer: e.thumb.fileServer, + path: e.thumb.path, + ); + }), + Text( + e.title + '\n', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle(height: 1.4), + strutStyle: StrutStyle(height: 1.4), + ), + ], + ), + ), + ), + )) + .toList(), + ); + }, + onRefresh: () async => + setState(() => _future = method.recommendation(widget.comicId)), + ); + } +} diff --git a/lib/screens/components/UserProfileCard.dart b/lib/screens/components/UserProfileCard.dart new file mode 100644 index 0000000..52ccd29 --- /dev/null +++ b/lib/screens/components/UserProfileCard.dart @@ -0,0 +1,108 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:pikapi/basic/Common.dart'; +import 'package:pikapi/basic/Entities.dart'; +import 'package:pikapi/screens/components/ItemBuilder.dart'; +import 'package:pikapi/screens/components/PicaAvatar.dart'; +import 'package:pikapi/screens/components/Images.dart'; +import 'package:pikapi/basic/Method.dart'; + +// 用户信息卡 +class UserProfileCard extends StatefulWidget { + @override + State createState() => _UserProfileCardState(); +} + +class _UserProfileCardState extends State { + late Future _future = _load(); + + Future _load() async { + var profile = await method.userProfile(); + if (!profile.isPunched) { + await method.punchIn(); + profile.isPunched = true; + defaultToast(context, "自动打卡"); + } + return profile; + } + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var nameStyle = TextStyle(fontWeight: FontWeight.bold); + var levelStyle = TextStyle( + fontSize: 12, color: theme.colorScheme.secondary.withOpacity(.8)); + return ItemBuilder( + future: _future, + onRefresh: () async { + setState(() => _future = method.userProfile()); + }, + height: 150, + successBuilder: + (BuildContext context, AsyncSnapshot snapshot) { + UserProfile profile = snapshot.data!; + return Stack( + children: [ + Container( + child: Stack( + children: [ + Opacity( + opacity: .25, // + child: LayoutBuilder( + builder: + (BuildContext context, BoxConstraints constraints) { + return RemoteImage( + path: profile.avatar.path, + fileServer: profile.avatar.fileServer, + width: constraints.maxWidth, + height: 150, + ); + }, + ), + ), + Positioned.fromRect( + rect: Rect.largest, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Container(), + ), + ), + ], + ), + ), + Container( + height: 150, + child: Column( + children: [ + Expanded(child: Container()), + PicaAvatar(profile.avatar), + Container(width: 18), + Text( + profile.name, + style: nameStyle, + ), + Text( + "Lv. ${profile.level} (${profile.title})", + style: levelStyle, + ), + Expanded(child: Container()), + ], + ), + ) + ], + ); + }, + ); + } +} diff --git a/lib/screens/components/gesture_zoom_box.dart b/lib/screens/components/gesture_zoom_box.dart new file mode 100644 index 0000000..0badbdd --- /dev/null +++ b/lib/screens/components/gesture_zoom_box.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'dart:math'; + +class GestureZoomBox extends StatefulWidget { + final double maxScale; + final double doubleTapScale; + final Widget child; + final Duration duration; + + const GestureZoomBox({ + Key? key, + this.maxScale = 2.0, + this.doubleTapScale = 2.0, + required this.child, + this.duration = const Duration(milliseconds: 200), + }) : assert(maxScale >= 1.0), + assert(doubleTapScale >= 1.0 && doubleTapScale <= maxScale), + super(key: key); + + @override + State createState() { + return _GestureZoomBoxState(); + } +} + +class _GestureZoomBoxState extends State + with TickerProviderStateMixin { + AnimationController? _scaleAnimController; // 缩放动画控制器 + AnimationController? _offsetAnimController; // 偏移动画控制器 + ScaleUpdateDetails? _latestScaleUpdateDetails; // 上次缩放变化数据 + + double _scale = 1.0; // 当前缩放值 + Offset _offset = Offset.zero; // 当前偏移值 + Offset? _doubleTapPosition; // 双击缩放的点击位置 + + bool _isScaling = false; + bool _isDragging = false; + + double _maxDragOver = 100; // 拖动超出边界的最大值 + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..translate(_offset.dx, _offset.dy) + ..scale(_scale, _scale), + child: Listener( + onPointerUp: _onPointerUp, + child: GestureDetector( + onDoubleTap: _onDoubleTap, + onScaleStart: _onScaleStart, + onScaleUpdate: _onScaleUpdate, + onScaleEnd: _onScaleEnd, + child: AbsorbPointer( + absorbing: _scale != 1, + child: widget.child, + ), + ), + ), + ); + } + + @override + void dispose() { + _scaleAnimController?.dispose(); + _offsetAnimController?.dispose(); + super.dispose(); + } + + /// 处理手指抬起事件 [event] + _onPointerUp(PointerUpEvent event) { + _doubleTapPosition = event.localPosition; + } + + /// 处理双击 + _onDoubleTap() { + double targetScale = _scale == 1.0 ? widget.doubleTapScale : 1.0; + _animationScale(targetScale); + if (targetScale == 1.0) { + _animationOffset(Offset.zero); + } + } + + _onScaleStart(ScaleStartDetails details) { + _scaleAnimController?.stop(); + _offsetAnimController?.stop(); + _isScaling = false; + _isDragging = false; + _latestScaleUpdateDetails = null; + } + + /// 处理缩放变化 [details] + _onScaleUpdate(ScaleUpdateDetails details) { + setState(() { + if (details.scale != 1.0) { + _scaling(details); + } else { + _dragging(details); + } + }); + } + + /// 执行缩放 + _scaling(ScaleUpdateDetails details) { + if (_isDragging) { + return; + } + final latestScaleUpdateDetails = _latestScaleUpdateDetails, + size = context.size; + _isScaling = true; + if (latestScaleUpdateDetails == null || size == null) { + _latestScaleUpdateDetails = details; + return; + } + + // 计算缩放比例 + double scaleIncrement = details.scale - latestScaleUpdateDetails.scale; + if (details.scale < 1.0 && _scale > 1.0) { + scaleIncrement *= _scale; + } + if (_scale < 1.0 && scaleIncrement < 0) { + scaleIncrement *= (_scale - 0.5); + } else if (_scale > widget.maxScale && scaleIncrement > 0) { + scaleIncrement *= (2.0 - (_scale - widget.maxScale)); + } + _scale = max(_scale + scaleIncrement, 0.0); + + // 计算缩放后偏移前(缩放前后的内容中心对齐)的左上角坐标变化 + double scaleOffsetX = size.width * (_scale - 1.0) / 2; + double scaleOffsetY = size.height * (_scale - 1.0) / 2; + // 将缩放前的触摸点映射到缩放后的内容上 + double scalePointDX = + (details.localFocalPoint.dx + scaleOffsetX - _offset.dx) / _scale; + double scalePointDY = + (details.localFocalPoint.dy + scaleOffsetY - _offset.dy) / _scale; + // 计算偏移,使缩放中心在屏幕上的位置保持不变 + _offset += Offset( + (size.width / 2 - scalePointDX) * scaleIncrement, + (size.height / 2 - scalePointDY) * scaleIncrement, + ); + + _latestScaleUpdateDetails = details; + } + + /// 执行拖动 + _dragging(ScaleUpdateDetails details) { + if (_isScaling) { + return; + } + final latestScaleUpdateDetails = _latestScaleUpdateDetails, + size = context.size; + _isDragging = true; + if (latestScaleUpdateDetails == null || size == null) { + _latestScaleUpdateDetails = details; + return; + } + + // 计算本次拖动增量 + double offsetXIncrement = (details.localFocalPoint.dx - + latestScaleUpdateDetails.localFocalPoint.dx) * + _scale; + double offsetYIncrement = (details.localFocalPoint.dy - + latestScaleUpdateDetails.localFocalPoint.dy) * + _scale; + // 处理 X 轴边界 + double scaleOffsetX = size.width * (_scale - 1.0) / 2; + if (scaleOffsetX <= 0) { + offsetXIncrement = 0; + } else if (_offset.dx > scaleOffsetX) { + offsetXIncrement *= + (_maxDragOver - (_offset.dx - scaleOffsetX)) / _maxDragOver; + } else if (_offset.dx < -scaleOffsetX) { + offsetXIncrement *= + (_maxDragOver - (-scaleOffsetX - _offset.dx)) / _maxDragOver; + } + // 处理 Y 轴边界 + double scaleOffsetY = + (size.height * _scale - MediaQuery.of(context).size.height) / 2; + if (scaleOffsetY <= 0) { + offsetYIncrement = 0; + } else if (_offset.dy > scaleOffsetY) { + offsetYIncrement *= + (_maxDragOver - (_offset.dy - scaleOffsetY)) / _maxDragOver; + } else if (_offset.dy < -scaleOffsetY) { + offsetYIncrement *= + (_maxDragOver - (-scaleOffsetY - _offset.dy)) / _maxDragOver; + } + + _offset += Offset(offsetXIncrement, offsetYIncrement); + + _latestScaleUpdateDetails = details; + } + + /// 缩放/拖动结束 + _onScaleEnd(ScaleEndDetails details) { + final size = context.size; + if (size == null) { + return; + } + if (_scale < 1.0) { + // 缩放值过小,恢复到 1.0 + _animationScale(1.0); + } else if (_scale > widget.maxScale) { + // 缩放值过大,恢复到最大值 + _animationScale(widget.maxScale); + } + if (_scale <= 1.0) { + // 缩放值过小,修改偏移值,使内容居中 + _animationOffset(Offset.zero); + } else if (_isDragging) { + // 处理拖动超过边界的情况(自动回弹到边界) + double realScale = _scale > widget.maxScale ? widget.maxScale : _scale; + double targetOffsetX = _offset.dx, targetOffsetY = _offset.dy; + // 处理 X 轴边界 + double scaleOffsetX = size.width * (realScale - 1.0) / 2; + if (scaleOffsetX <= 0) { + targetOffsetX = 0; + } else if (_offset.dx > scaleOffsetX) { + targetOffsetX = scaleOffsetX; + } else if (_offset.dx < -scaleOffsetX) { + targetOffsetX = -scaleOffsetX; + } + // 处理 Y 轴边界 + double scaleOffsetY = + (size.height * realScale - MediaQuery.of(context).size.height) / 2; + if (scaleOffsetY < 0) { + targetOffsetY = 0; + } else if (_offset.dy > scaleOffsetY) { + targetOffsetY = scaleOffsetY; + } else if (_offset.dy < -scaleOffsetY) { + targetOffsetY = -scaleOffsetY; + } + if (_offset.dx != targetOffsetX || _offset.dy != targetOffsetY) { + // 启动越界回弹 + _animationOffset(Offset(targetOffsetX, targetOffsetY)); + } else { + // 处理 X 轴边界 + double duration = + (widget.duration.inSeconds + widget.duration.inMilliseconds / 1000); + Offset targetOffset = + _offset + details.velocity.pixelsPerSecond * duration; + targetOffsetX = targetOffset.dx; + if (targetOffsetX > scaleOffsetX) { + targetOffsetX = scaleOffsetX; + } else if (targetOffsetX < -scaleOffsetX) { + targetOffsetX = -scaleOffsetX; + } + // 处理 X 轴边界 + targetOffsetY = targetOffset.dy; + if (targetOffsetY > scaleOffsetY) { + targetOffsetY = scaleOffsetY; + } else if (targetOffsetY < -scaleOffsetY) { + targetOffsetY = -scaleOffsetY; + } + // 启动惯性滚动 + _animationOffset(Offset(targetOffsetX, targetOffsetY)); + } + } + + _isScaling = false; + _isDragging = false; + _latestScaleUpdateDetails = null; + } + + /// 执行动画缩放内容到 [targetScale] + _animationScale(double targetScale) { + _scaleAnimController?.dispose(); + final scaleAnimController = _scaleAnimController = + AnimationController(vsync: this, duration: widget.duration); + Animation anim = Tween(begin: _scale, end: targetScale) + .animate(scaleAnimController); + anim.addListener(() { + setState(() { + _scaling(ScaleUpdateDetails( + focalPoint: _doubleTapPosition!, + localFocalPoint: _doubleTapPosition!, + scale: anim.value, + horizontalScale: anim.value, + verticalScale: anim.value, + )); + }); + }); + anim.addStatusListener((status) { + if (status == AnimationStatus.completed) { + _onScaleEnd(ScaleEndDetails()); + } + }); + scaleAnimController.forward(); + } + + /// 执行动画偏移内容到 [targetOffset] + _animationOffset(Offset targetOffset) { + _offsetAnimController?.dispose(); + final offsetAnimController = _offsetAnimController = + AnimationController(vsync: this, duration: widget.duration); + Animation anim = offsetAnimController + .drive(Tween(begin: _offset, end: targetOffset)); + anim.addListener(() { + setState(() { + _offset = anim.value; + }); + }); + offsetAnimController.fling(); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..81b1a7e --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,116 @@ +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +set(BINARY_NAME "pikapi") +set(APPLICATION_ID "com.example.pikapi") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Configure build options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Application build +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) +apply_standard_settings(${BINARY_NAME}) +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) +add_dependencies(${BINARY_NAME} flutter_assemble) +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..33fd580 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,87 @@ +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..026851f --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,13 @@ +// +// Generated file. Do not edit. +// + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..9bf7478 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,13 @@ +// +// Generated file. Do not edit. +// + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..1fc8ed3 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,16 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000..5868a79 --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,105 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen *screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar *header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "pikapi"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } + else { + gtk_window_set_title(window, "pikapi"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar ***arguments, int *exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject *object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..d2fd377 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,6 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..8236f57 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import url_launcher_macos + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..dade8df --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,40 @@ +platform :osx, '10.11' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..6e9d6ac --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - FlutterMacOS (1.0.0) + - url_launcher_macos (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + +SPEC CHECKSUMS: + FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + url_launcher_macos: 45af3d61de06997666568a7149c1be98b41c95d4 + +PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c + +COCOAPODS: 1.10.1 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..f0afc90 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,632 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 51; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + B7746CD3B58046AB2A30373A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F51BCF4C17559FFBAB4E95D7 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* pikapi.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = pikapi.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 6DDC9F2D722240B8A73326EB /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B0D4B875C41B50DACC24CB89 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F2AF3FFEFCDFF4E0D5A2FFB1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + F51BCF4C17559FFBAB4E95D7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B7746CD3B58046AB2A30373A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 99812F7FCCD1CE46B3B8E505 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* pikapi.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 99812F7FCCD1CE46B3B8E505 /* Pods */ = { + isa = PBXGroup; + children = ( + F2AF3FFEFCDFF4E0D5A2FFB1 /* Pods-Runner.debug.xcconfig */, + B0D4B875C41B50DACC24CB89 /* Pods-Runner.release.xcconfig */, + 6DDC9F2D722240B8A73326EB /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + F51BCF4C17559FFBAB4E95D7 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 410398265F6EBE1BF9230F69 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + B17A392332FC2746FE84EE4D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* pikapi.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0930; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 410398265F6EBE1BF9230F69 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + B17A392332FC2746FE84EE4D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + 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_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.11; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..121ad80 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..d53ef64 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..ccfcb1b Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..946b205 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..7c748cd Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..f14bdd1 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..c9851b8 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..65f15d0 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..78aef7c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..537341a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..eae98c1 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = pikapi + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.pikapi + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2021 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..2722837 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..440c29c --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,360 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + another_xlider: + dependency: "direct main" + description: + name: another_xlider + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" + async: + dependency: transitive + description: + name: async + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + characters: + dependency: transitive + description: + name: characters + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + clipboard: + dependency: "direct main" + description: + name: clipboard + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" + clock: + dependency: transitive + description: + name: clock + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + collection: + dependency: transitive + description: + name: collection + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.3" + event: + dependency: "direct main" + description: + name: event + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + filesystem_picker: + dependency: "direct main" + description: + name: filesystem_picker + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0-nullsafety.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_datetime_picker: + dependency: "direct main" + description: + name: flutter_datetime_picker + url: "https://pub.dartlang.org" + source: hosted + version: "1.5.1" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_search_bar: + dependency: "direct main" + description: + name: flutter_search_bar + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0-dev.1" + flutter_styled_toast: + dependency: "direct main" + description: + name: flutter_styled_toast + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.0" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + url: "https://pub.dartlang.org" + source: hosted + version: "0.22.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: transitive + description: + name: intl + url: "https://pub.dartlang.org" + source: hosted + version: "0.17.0" + isolate: + dependency: "direct main" + description: + name: isolate + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.1" + js: + dependency: transitive + description: + name: js + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.3" + matcher: + dependency: transitive + description: + name: matcher + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.10" + meta: + dependency: transitive + description: + name: meta + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + multi_select_flutter: + dependency: "direct main" + description: + name: multi_select_flutter + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" + path: + dependency: transitive + description: + name: path + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.0" + path_drawing: + dependency: transitive + description: + name: path_drawing + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.1" + pedantic: + dependency: transitive + description: + name: pedantic + url: "https://pub.dartlang.org" + source: hosted + version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + url: "https://pub.dartlang.org" + source: hosted + version: "8.1.6" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.6.1" + petitparser: + dependency: transitive + description: + name: petitparser + url: "https://pub.dartlang.org" + source: hosted + version: "4.1.0" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.12.0" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.1" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0-nullsafety.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + url: "https://pub.dartlang.org" + source: hosted + version: "1.10.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + test_api: + dependency: transitive + description: + name: test_api + url: "https://pub.dartlang.org" + source: hosted + version: "0.3.0" + typed_data: + dependency: transitive + description: + name: typed_data + url: "https://pub.dartlang.org" + source: hosted + version: "1.3.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + url: "https://pub.dartlang.org" + source: hosted + version: "6.0.10" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.4" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + url: "https://pub.dartlang.org" + source: hosted + version: "2.0.2" + vector_math: + dependency: transitive + description: + name: vector_math + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.0" + xml: + dependency: transitive + description: + name: xml + url: "https://pub.dartlang.org" + source: hosted + version: "5.1.2" +sdks: + dart: ">=2.12.0 <3.0.0" + flutter: ">=2.0.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..d688ac4 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,89 @@ +name: pikapi +description: A cross platform comic client. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.12.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + isolate: ^2.1.1 + event: ^2.0.5 + flutter_search_bar: ^3.0.0-dev.1 + flutter_svg: ^0.22.0 + flutter_styled_toast: ^2.0.0 + another_xlider: ^1.0.0 + scrollable_positioned_list: ^0.2.0-nullsafety.0 + permission_handler: ^8.1.4+1 + filesystem_picker: ^2.0.0-nullsafety.0 + url_launcher: ^6.0.9 + clipboard: ^0.1.3 + flutter_datetime_picker: ^1.5.1 + photo_view: ^0.12.0 + multi_select_flutter: ^4.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - lib/assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..367dc2b --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pikapi/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(PikachuApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..2e0fcd5 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,95 @@ +cmake_minimum_required(VERSION 3.15) +project(pikapi LANGUAGES CXX) + +set(BINARY_NAME "pikapi") + +cmake_policy(SET CMP0063 NEW) + +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() + +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..b02c548 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,103 @@ +cmake_minimum_required(VERSION 3.15) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..92772e9 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,12 @@ +// +// Generated file. Do not edit. +// + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..9846246 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,13 @@ +// +// Generated file. Do not edit. +// + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..411af46 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,16 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_windows +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..977e38b --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.15) +project(runner LANGUAGES CXX) + +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "run_loop.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) +apply_standard_settings(${BINARY_NAME}) +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..bd7f0a6 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#else +#define VERSION_AS_NUMBER 1,0,0 +#endif + +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "A new Flutter project." "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "pikapi" "\0" + VALUE "LegalCopyright", "Copyright (C) 2021 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "pikapi.exe" "\0" + VALUE "ProductName", "pikapi" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..41bbc5e --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,64 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(RunLoop* run_loop, + const flutter::DartProject& project) + : run_loop_(run_loop), project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + run_loop_->RegisterFlutterInstance(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + run_loop_->UnregisterFlutterInstance(flutter_controller_->engine()); + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..b663ddd --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,39 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "run_loop.h" +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow driven by the |run_loop|, hosting a + // Flutter view running |project|. + explicit FlutterWindow(RunLoop* run_loop, + const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The run loop driving events for this window. + RunLoop* run_loop_; + + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..49bbeea --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,42 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "run_loop.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + RunLoop run_loop; + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(&run_loop, project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.CreateAndShow(L"pikapi", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + run_loop.Run(); + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/run_loop.cpp b/windows/runner/run_loop.cpp new file mode 100644 index 0000000..2d6636a --- /dev/null +++ b/windows/runner/run_loop.cpp @@ -0,0 +1,66 @@ +#include "run_loop.h" + +#include + +#include + +RunLoop::RunLoop() {} + +RunLoop::~RunLoop() {} + +void RunLoop::Run() { + bool keep_running = true; + TimePoint next_flutter_event_time = TimePoint::clock::now(); + while (keep_running) { + std::chrono::nanoseconds wait_duration = + std::max(std::chrono::nanoseconds(0), + next_flutter_event_time - TimePoint::clock::now()); + ::MsgWaitForMultipleObjects( + 0, nullptr, FALSE, static_cast(wait_duration.count() / 1000), + QS_ALLINPUT); + bool processed_events = false; + MSG message; + // All pending Windows messages must be processed; MsgWaitForMultipleObjects + // won't return again for items left in the queue after PeekMessage. + while (::PeekMessage(&message, nullptr, 0, 0, PM_REMOVE)) { + processed_events = true; + if (message.message == WM_QUIT) { + keep_running = false; + break; + } + ::TranslateMessage(&message); + ::DispatchMessage(&message); + // Allow Flutter to process messages each time a Windows message is + // processed, to prevent starvation. + next_flutter_event_time = + std::min(next_flutter_event_time, ProcessFlutterMessages()); + } + // If the PeekMessage loop didn't run, process Flutter messages. + if (!processed_events) { + next_flutter_event_time = + std::min(next_flutter_event_time, ProcessFlutterMessages()); + } + } +} + +void RunLoop::RegisterFlutterInstance( + flutter::FlutterEngine* flutter_instance) { + flutter_instances_.insert(flutter_instance); +} + +void RunLoop::UnregisterFlutterInstance( + flutter::FlutterEngine* flutter_instance) { + flutter_instances_.erase(flutter_instance); +} + +RunLoop::TimePoint RunLoop::ProcessFlutterMessages() { + TimePoint next_event_time = TimePoint::max(); + for (auto instance : flutter_instances_) { + std::chrono::nanoseconds wait_duration = instance->ProcessMessages(); + if (wait_duration != std::chrono::nanoseconds::max()) { + next_event_time = + std::min(next_event_time, TimePoint::clock::now() + wait_duration); + } + } + return next_event_time; +} diff --git a/windows/runner/run_loop.h b/windows/runner/run_loop.h new file mode 100644 index 0000000..000d362 --- /dev/null +++ b/windows/runner/run_loop.h @@ -0,0 +1,40 @@ +#ifndef RUNNER_RUN_LOOP_H_ +#define RUNNER_RUN_LOOP_H_ + +#include + +#include +#include + +// A runloop that will service events for Flutter instances as well +// as native messages. +class RunLoop { + public: + RunLoop(); + ~RunLoop(); + + // Prevent copying + RunLoop(RunLoop const&) = delete; + RunLoop& operator=(RunLoop const&) = delete; + + // Runs the run loop until the application quits. + void Run(); + + // Registers the given Flutter instance for event servicing. + void RegisterFlutterInstance( + flutter::FlutterEngine* flutter_instance); + + // Unregisters the given Flutter instance from event servicing. + void UnregisterFlutterInstance( + flutter::FlutterEngine* flutter_instance); + + private: + using TimePoint = std::chrono::steady_clock::time_point; + + // Processes all currently pending messages for registered Flutter instances. + TimePoint ProcessFlutterMessages(); + + std::set flutter_instances_; +}; + +#endif // RUNNER_RUN_LOOP_H_ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..c977c4a --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..d19bdbb --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + if (target_length == 0) { + return std::string(); + } + std::string utf8_string; + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..c10f08d --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,245 @@ +#include "win32_window.h" + +#include + +#include "resource.h" + +namespace { + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + FreeLibrary(user32_module); + } +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW | WS_VISIBLE, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + return OnCreate(); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..17ba431 --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,98 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates and shows a win32 window with |title| and position and size using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size to will treat the width height passed in to this function + // as logical pixels and scale to appropriate for the default monitor. Returns + // true if the window was created successfully. + bool CreateAndShow(const std::wstring& title, + const Point& origin, + const Size& size); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_