diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c79a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# Miscellaneous +*.class +*.lock +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +test.dart + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/ + +# Flutter repo-specific +/bin/cache/ +/bin/mingit/ +/dev/benchmarks/mega_gallery/ +/dev/bots/.recipe_deps +/dev/bots/android_tools/ +/dev/docs/doc/ +/dev/docs/flutter.docs.zip +/dev/docs/lib/ +/dev/docs/pubspec.yaml +/dev/integration_tests/**/xcuserdata +/dev/integration_tests/**/Pods +/packages/flutter/coverage/ +version + +# packages file containing multi-root paths +.packages.generated + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +flutter_*.png +linked_*.ds +unlinked.ds +unlinked_spec.ds + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java +**/android/key.properties +*.jks + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# macOS +**/macos/Flutter/GeneratedPluginRegistrant.swift +**/macos/Flutter/Flutter-Debug.xcconfig +**/macos/Flutter/Flutter-Release.xcconfig +**/macos/Flutter/Flutter-Profile.xcconfig + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +!/dev/ci/**/Gemfile.lock diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..4fafb53 --- /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: fabeb2a16f1d008ab8230f450c49141d35669798 + channel: beta + +project_type: app diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b335b5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Aculeasis + +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 new file mode 100644 index 0000000..68d5a88 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +## mdmT2 Config + +Позволяет просматривать логи и выполнять некоторые команды на [mdmTerminal2](https://github.com/Aculeasis/mdmTerminal2). Изменять настройки нельзя. + +## Установка +### Требования + - mdmTerminal2 не ниже 0.15.4 (зависит от версии приложения). + - Android 4.4+ (API 19+). + - Доступ к сети. + + Можно попробовать собрать под другие платформы поддерживаемые флаттером. + +### Установка +Скачать(WIP) и установить нужный apk (вероятно \*-arm64-v8a.apk). Google Play может ругаться на подпись неизвестного разработчика, это нормально. + +### Сборка +- Установить [Flutter](https://flutter.dev/docs/get-started/install). +- Собрать +``` +flutter pub get +flutter build apk --split-per-abi +``` + +## Особенности + +WIP diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0b9e049 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,7 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..269a429 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,87 @@ +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" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +android { + compileSdkVersion 29 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.aculeasis.mdmt2_config" + minSdkVersion 19 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + buildTypes { + release { + // Enables code shrinking, obfuscation, and optimization for only + // your project's release build type. + minifyEnabled true + // Enables resource shrinking, which is performed by the + // Android Gradle plugin. + shrinkResources true + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.release + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..f90c41d --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d611132 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/aculeasis/mdmt2_config/MainActivity.kt b/android/app/src/main/kotlin/com/aculeasis/mdmt2_config/MainActivity.kt new file mode 100644 index 0000000..6fae1ca --- /dev/null +++ b/android/app/src/main/kotlin/com/aculeasis/mdmt2_config/MainActivity.kt @@ -0,0 +1,12 @@ +package com.aculeasis.mdmt2_config + +import androidx.annotation.NonNull; +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugins.GeneratedPluginRegistrant + +class MainActivity: FlutterActivity() { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine); + } +} 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..8403758 --- /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..db77bb4 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..17987b7 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..09d4391 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..d5f1c8d 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..4d6372e 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/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..a554de4 --- /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..f90c41d --- /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..232bc0d --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + 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..38c8d45 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.enableR8=true +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..296b146 --- /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-5.6.2-all.zip diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..5a2f14f --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,15 @@ +include ':app' + +def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() + +def plugins = new Properties() +def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') +if (pluginsFile.exists()) { + pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } +} + +plugins.each { name, path -> + def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() + include ":$name" + project(":$name").projectDir = pluginDirectory +} diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..0f1df0f --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..58e65f9 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..0b2d479 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..0b2d479 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b33a807 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,511 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; 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 = ""; }; + 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B80C3931E831B6300D905FE /* App.framework */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEBA1CF902C7004384FC /* Flutter.framework */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + 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\" thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m 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; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.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; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.aculeasis.mdmt2Config; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.aculeasis.mdmt2Config; + PRODUCT_NAME = "$(TARGET_NAME)"; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.aculeasis.mdmt2Config; + PRODUCT_NAME = "$(TARGET_NAME)"; + 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..59c6d39 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..be0b92e --- /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..59c6d39 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner/AppDelegate.h b/ios/Runner/AppDelegate.h new file mode 100644 index 0000000..a7bb489 --- /dev/null +++ b/ios/Runner/AppDelegate.h @@ -0,0 +1,6 @@ +#import +#import + +@interface AppDelegate : FlutterAppDelegate + +@end diff --git a/ios/Runner/AppDelegate.m b/ios/Runner/AppDelegate.m new file mode 100644 index 0000000..569c492 --- /dev/null +++ b/ios/Runner/AppDelegate.m @@ -0,0 +1,13 @@ +#import "AppDelegate.h" +#import "GeneratedPluginRegistrant.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. + return [super application:application didFinishLaunchingWithOptions:launchOptions]; +} + +@end 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..1950fd8 --- /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..dc9ada4 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..28c6bf0 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..2ccbfd9 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..f091b6b 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..4cde121 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..d0ef06e 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..dcdc230 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..2ccbfd9 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..c8f9ed8 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..a6d6b86 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..a6d6b86 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..75b2d16 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..c4df70d 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..6a84f41 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..d0e1f58 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..d08a4de --- /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..65a94b5 --- /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..497371e --- /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..bbb83ca --- /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..3ef4427 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + mdmt2_config + 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/main.m b/ios/Runner/main.m new file mode 100644 index 0000000..4618607 --- /dev/null +++ b/ios/Runner/main.m @@ -0,0 +1,9 @@ +#import +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/lib/generated_plugin_registrant.dart b/lib/generated_plugin_registrant.dart new file mode 100644 index 0000000..f238637 --- /dev/null +++ b/lib/generated_plugin_registrant.dart @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// ignore: unused_import +import 'dart:ui'; + +import 'package:shared_preferences_web/shared_preferences_web.dart'; +import 'package:url_launcher_web/url_launcher_web.dart'; + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +void registerPlugins(PluginRegistry registry) { + SharedPreferencesPlugin.registerWith(registry.registrarFor(SharedPreferencesPlugin)); + UrlLauncherPlugin.registerWith(registry.registrarFor(UrlLauncherPlugin)); + registry.registerMessageHandler(); +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..3d703ce --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/screens/home.dart'; +import 'package:mdmt2_config/src/servers/servers_controller.dart'; +import 'package:mdmt2_config/src/settings/misc_settings.dart'; +import 'package:mdmt2_config/src/settings/theme_settings.dart'; +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:provider/provider.dart'; + +void main() { + const bool isProduction = bool.fromEnvironment('dart.vm.product'); + if (isProduction) { + debugPrint = (String message, {int wrapWidth}) {}; + } + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => ThemeSettings(), + lazy: false, + ), + ChangeNotifierProvider( + create: (_) => ServersController(), + lazy: false, + ), + Provider( + create: (_) => InstancesController(), + dispose: (_, val) => val.dispose(), + ), + // Синглтон + Provider( + create: (_) => MiscSettings(), + lazy: false, + ) + ], + child: MyApp(), + ), + ); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Consumer( + child: Container(), + builder: (context, theme, child) => theme.isLoaded + ? MaterialApp( + theme: theme.lightTheme, + darkTheme: theme.darkTheme, + home: MainServersPage(), + ) + : child); + } +} diff --git a/lib/src/blocs/finder.dart b/lib/src/blocs/finder.dart new file mode 100644 index 0000000..a821f09 --- /dev/null +++ b/lib/src/blocs/finder.dart @@ -0,0 +1,81 @@ +part of 'package:mdmt2_config/src/upnp/finder.dart'; + +enum _tCMD { start, stop, sortOn, sortOff, send } + +enum FinderStage { wait, processing, work } + +class _CMD { + final _tCMD cmd; + final dynamic error; + _CMD(this.cmd, {this.error}); +} + +abstract class _BLoC { + bool _sort = false; + bool _work = false; + final __inputCMD = StreamController<_CMD>(); + FinderStage _stage = FinderStage.wait; + StreamSubscription __subscription; + + final __outputStream = StreamController>(); + final __outputStatusStream = StreamController(); + + Stream> get data => __outputStream.stream; + Stream get status => __outputStatusStream.stream; + + _BLoC() { + __subscription = __inputCMD.stream.listen((event) { + if (event.cmd == _tCMD.sortOn || event.cmd == _tCMD.sortOff) { + final sort = event.cmd == _tCMD.sortOn ? true : false; + if (sort != _sort) { + _sort = sort; + _sendDataOutput(); + } + return; + } else if (event.cmd == _tCMD.send) { + _sendDataOutput(); + return; + } + + if (_stage == FinderStage.processing) return; + _stage = FinderStage.processing; + + __outputStatusStream.add(_stage); + switch (event.cmd) { + case _tCMD.start: + if (_work) _stopInput(); + _startInput(); + _stage = FinderStage.work; + __outputStatusStream.add(_stage); + break; + case _tCMD.stop: + _stopInput(); + _stage = FinderStage.wait; + if (event.error == null) + __outputStatusStream.add(_stage); + else + __outputStatusStream.addError(event.error); + break; + default: + break; + } + }); + } + + void dispose() { + __subscription.cancel(); + __inputCMD.close(); + __outputStream.close(); + __outputStatusStream.close(); + } + + void _startInput(); + void _stopInput(); + void _sendDataOutput(); + + void start() => __inputCMD.add(_CMD(_tCMD.start)); + void stop({dynamic error}) => __inputCMD.add(_CMD(_tCMD.stop, error: error)); + void send() => __inputCMD.add(_CMD(_tCMD.send)); + set sort(bool value) => __inputCMD.add(_CMD(value ? _tCMD.sortOn : _tCMD.sortOff)); + bool get sort => _sort; +} diff --git a/lib/src/blocs/instances_controller.dart b/lib/src/blocs/instances_controller.dart new file mode 100644 index 0000000..296ecfd --- /dev/null +++ b/lib/src/blocs/instances_controller.dart @@ -0,0 +1,59 @@ +part of 'package:mdmt2_config/src/terminal/instances_controller.dart'; + +enum _tCMD { run, stop, remove, clear, stopAll, clearAll } + +class _CMD { + final _tCMD cmd; + final ServerData target; + final returnInstanceCallback runCallback; + _CMD(this.cmd, {this.target, this.runCallback}); +} + +abstract class _BLoC { + final __stream = StreamController<_CMD>(); + StreamSubscription __subscription; + _BLoC() { + __subscription = __stream.stream.listen((_CMD data) { + switch (data.cmd) { + case _tCMD.run: + _runInput(data.target, result: data.runCallback); + break; + case _tCMD.stop: + _stopInput(data.target); + break; + case _tCMD.clear: + _clearInput(data.target); + break; + case _tCMD.remove: + _removeInput(data.target); + break; + case _tCMD.stopAll: + _stopAllInput(); + break; + case _tCMD.clearAll: + _clearAllInput(); + break; + } + }); + } + + void _clearAllInput(); + void _stopAllInput(); + bool _removeInput(ServerData server, {bool callDispose = true}); + bool _stopInput(ServerData server); + void _clearInput(ServerData server); + void _runInput(ServerData server, {returnInstanceCallback result}); + + void dispose() { + __subscription.cancel(); + __stream.close(); + } + + void clearAll() => __stream.add(_CMD(_tCMD.clearAll)); + void stopAll() => __stream.add(_CMD(_tCMD.stopAll)); + void remove(ServerData server) => __stream.add(_CMD(_tCMD.remove, target: server)); + void stop(ServerData server) => __stream.add(_CMD(_tCMD.stop, target: server)); + void clear(ServerData server) => __stream.add(_CMD(_tCMD.clear, target: server)); + void run(ServerData server, {returnInstanceCallback result}) => + __stream.add(_CMD(_tCMD.run, target: server, runCallback: result)); +} diff --git a/lib/src/blocs/servers_controller.dart b/lib/src/blocs/servers_controller.dart new file mode 100644 index 0000000..08b15f3 --- /dev/null +++ b/lib/src/blocs/servers_controller.dart @@ -0,0 +1,69 @@ +part of 'package:mdmt2_config/src/servers/servers_controller.dart'; + +enum _tCMD { removeAll, upgrade, add, addAlways, remove, insertAlways, relocation } + +class _CMD { + final _tCMD cmd; + final ServerData server1; + final ServerData server2; + final int index1; + final int index2; + _CMD(this.cmd, {this.index1, this.index2, this.server1, this.server2}); +} + +abstract class _BLoC extends ChangeNotifier { + final __stream = StreamController<_CMD>(); + StreamSubscription __subscription; + _BLoC() { + __subscription = __stream.stream.listen((_CMD data) { + switch (data.cmd) { + case _tCMD.removeAll: + _removeAllInput(); + break; + case _tCMD.upgrade: + _upgradeInput(data.server1, data.server2); + break; + case _tCMD.add: + _addInput(data.server1); + break; + case _tCMD.addAlways: + _addAlwaysInput(data.server1); + break; + case _tCMD.remove: + _removeInput(data.server1); + break; + case _tCMD.insertAlways: + _insertAlwaysInput(data.index1, data.server1); + break; + case _tCMD.relocation: + _relocationInput(data.index1, data.index2); + break; + } + }); + } + + void _removeAllInput(); + void _upgradeInput(ServerData oldServer, ServerData server); + bool _addInput(ServerData server); + void _addAlwaysInput(ServerData server); + void _removeInput(ServerData server); + void _insertAlwaysInput(int index, ServerData server); + void _relocationInput(int oldIndex, newIndex); + + void dispose() { + __subscription.cancel(); + __stream.close(); + super.dispose(); + } + + void removeAll() => __stream.sink.add(_CMD(_tCMD.removeAll)); + void upgrade(ServerData oldServer, ServerData server) => + __stream.sink.add(_CMD(_tCMD.upgrade, server1: oldServer, server2: server)); + void add(ServerData server) => __stream.sink.add(_CMD(_tCMD.add, server1: server)); + void addAlways(ServerData server) => __stream.sink.add(_CMD(_tCMD.addAlways, server1: server)); + void remove(ServerData server) => __stream.sink.add(_CMD(_tCMD.remove, server1: server)); + void insertAlways(int index, ServerData server) => + __stream.sink.add(_CMD(_tCMD.insertAlways, server1: server, index1: index)); + void relocation(int oldIndex, newIndex) => + __stream.sink.add(_CMD(_tCMD.relocation, index1: oldIndex, index2: newIndex)); +} diff --git a/lib/src/dialogs.dart b/lib/src/dialogs.dart new file mode 100644 index 0000000..678087f --- /dev/null +++ b/lib/src/dialogs.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:mdmt2_config/src/screens/finder_page.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/upnp/terminal_info.dart'; +import 'package:mdmt2_config/src/widgets.dart'; +import 'package:package_info/package_info.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:validators/validators.dart'; + +Future serverFormDialog( + BuildContext context, + ServerData _srv, + Function(String name) contains, +) async { + final Map controllers = { + 'name': TextEditingController(text: _srv.name), + 'ip': TextEditingController(text: _srv.ip), + 'port': TextEditingController(text: _srv.port.toString()), + 'token': TextEditingController(text: _srv.token), + 'ws_token': TextEditingController(text: _srv.wsToken), + }; + bool logger = _srv.logger, control = _srv.control, totpSalt = _srv.totpSalt; + final key = GlobalKey(); + void finder() { + Navigator.of(context).push(MaterialPageRoute(builder: (context) => FinderPage())).then((value) { + if (value != null && value is TerminalInfo) { + controllers['ip'].text = value.ip; + controllers['port'].text = value.port.toString(); + if (controllers['name'].text == '') controllers['name'].text = '${value.ip}:${value.port}'; + } + }); + } + + return showDialog( + context: context, + builder: (context) => AlertDialog( + actions: [ + if (_srv.name == '') + RaisedButton( + onPressed: finder, + child: Text('Find'), + ), + if (_srv.name == '') + SizedBox( + height: 1, + width: 20, + ), + RaisedButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + RaisedButton( + child: Text('Save'), + onPressed: () { + if (key.currentState.validate()) { + Navigator.of(context).pop(ServerData( + name: controllers['name'].text, + ip: controllers['ip'].text, + port: int.parse(controllers['port'].text), + token: controllers['token'].text, + wsToken: controllers['ws_token'].text, + logger: logger, + control: control, + totpSalt: totpSalt)); + } + }, + ), + ], + content: Form( + key: key, + child: SingleChildScrollView( + child: Column( + children: [ + TextFormField( + decoration: InputDecoration(labelText: 'Name'), + controller: controllers['name'], + autovalidate: true, + maxLength: 20, + validator: (v) { + if (v.isEmpty) return 'Enter server name'; + // Имя не изменилось, в режиме редактирования + if (_srv.name != "" && _srv.name == v) return null; + if (contains(v)) return 'Alredy present'; + return null; + }), + TextFormField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: 'IP'), + controller: controllers['ip'], + autovalidate: true, + validator: (v) { + if (v.isEmpty) return 'Enter server IP'; + if (!isIP(v)) return 'Invalid internet address'; + return null; + }, + ), + TextFormField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: 'Port'), + controller: controllers['port'], + autovalidate: true, + validator: (v) { + final port = int.tryParse(v); + if (port == null) return 'Port is a numeric'; + if (port < 1 || port > 65535) return 'Port must be in 1..65535'; + return null; + }), + textFormFieldPassword( + context, + decoration: InputDecoration(labelText: 'Token'), + controller: controllers['token'], + isVisible: false, + ), + switchListTileTap( + activeColor: Theme.of(context).accentColor, + contentPadding: EdgeInsets.zero, + title: Text('Log'), + value: logger, + onChanged: (newVal) => logger = newVal), + switchListTileTap( + activeColor: Theme.of(context).accentColor, + contentPadding: EdgeInsets.zero, + title: Text('Control'), + value: control, + onChanged: (newVal) => control = newVal), + switchListTileTap( + activeColor: Theme.of(context).accentColor, + contentPadding: EdgeInsets.zero, + title: Text('TOTP Salt'), + value: totpSalt, + onChanged: (newVal) => totpSalt = newVal), + textFormFieldPassword( + context, + decoration: InputDecoration(labelText: 'ws_token'), + controller: controllers['ws_token'], + ), + ], + ), + ), + ), + )); +} + +Future dialogYesNo(BuildContext context, String title, msg, yes, no) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(msg), + actions: [ + FlatButton(onPressed: () => Navigator.of(context).pop(false), child: Text(no)), + FlatButton(onPressed: () => Navigator.of(context).pop(true), child: Text(yes)) + ], + )); +} + +void showAbout(BuildContext context) async { + const HomeURL = 'https://github.com/Aculeasis/mdmt2-config-android'; + final packageInfo = await PackageInfo.fromPlatform(); + EdgeInsets buttonPadding = ButtonTheme.of(context).padding; + buttonPadding = buttonPadding.copyWith(left: .0); + + showAboutDialog( + context: context, + applicationIcon: FlutterLogo(), + applicationName: packageInfo.appName, + applicationLegalese: 'Aculeasis', + applicationVersion: 'Version ${packageInfo.version}+${packageInfo.buildNumber}', + children: [ + Container( + alignment: Alignment.topLeft, + padding: EdgeInsets.only(left: IconTheme.of(context).size + 24.0, right: 24.0, top: 24), + child: FlatButton.icon( + padding: buttonPadding, + onPressed: () async { + if (await canLaunch(HomeURL)) await launch(HomeURL); + }, + icon: Icon(FontAwesomeIcons.github), + label: Text( + 'Open GitHub', + textAlign: TextAlign.center, + )), + ), + ]); +} + +Future uriDialog(BuildContext context, String oldURI) async { + final _controller = TextEditingController(text: oldURI); + final key = GlobalKey(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + actions: [ + RaisedButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + RaisedButton( + child: Text('Send'), + onPressed: () { + Navigator.of(context).pop(_controller.text); + }, + ), + ], + content: Form( + key: key, + child: TextFormField( + decoration: InputDecoration(labelText: 'URI'), + controller: _controller, + ), + ), + ), + ); +} + +Future uIntDialog(BuildContext context, int oldValue, String label) async { + final _controller = TextEditingController(text: oldValue.toString()); + final key = GlobalKey(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + actions: [ + RaisedButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + RaisedButton( + child: Text('Set'), + onPressed: () { + if (key.currentState.validate()) Navigator.of(context).pop(int.tryParse(_controller.text)); + }, + ), + ], + content: Form( + key: key, + child: TextFormField( + textInputAction: TextInputAction.done, + keyboardType: TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration(labelText: label), + controller: _controller, + validator: (value) { + final intValue = int.tryParse(value); + if (intValue == null) return 'Must be unsigned integer'; + if (intValue < 0) return 'Must be greater than -1'; + return null; + }, + ), + ), + ), + ); +} + +Future dialogSelectOne(BuildContext context, List items, + {Map sets, String title, T selected}) async { + final children = items + .map((text) => RadioListTile( + value: sets != null ? sets[text] : null, + groupValue: selected, + title: Text(text), + onChanged: (value) => value == null ? null : Navigator.of(context).pop(value == selected ? null : value))) + .toList(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + contentPadding: EdgeInsets.symmetric(horizontal: 10), + title: title != null ? Text(title) : null, + content: Container( + width: 0, + child: ListView( + children: children, + shrinkWrap: true, + )), + actions: [FlatButton(onPressed: () => Navigator.of(context).pop(), child: Text('Cancel'))], + )); +} + +Widget textFormFieldPassword(BuildContext context, + {TextEditingController controller, bool isVisible = false, InputDecoration decoration}) { + final _isVisible = ValueNotifier(isVisible); + final iconTheme = IconTheme.of(context); + Color iconColor; + iconColor = iconTheme.color; + iconColor = iconTheme.color.withOpacity(iconColor.opacity * .5); + decoration = (decoration ?? InputDecoration()) + .copyWith(contentPadding: EdgeInsets.only(right: iconTheme.size * 2, top: 10, bottom: 10)); + return ValueListenableBuilder( + valueListenable: _isVisible, + builder: (_, isVisible, __) => Stack( + alignment: Alignment.centerRight, + children: [ + TextFormField( + controller: controller, + obscureText: !isVisible, + decoration: decoration, + autovalidate: true, + validator: (v) { + if (v.contains(' ')) return 'Don\'t use spaces!'; + return null; + }, + ), + IconButton( + color: iconColor, + icon: Icon( + isVisible ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => _isVisible.value = !isVisible), + ], + )); +} diff --git a/lib/src/screens/finder_page.dart b/lib/src/screens/finder_page.dart new file mode 100644 index 0000000..80bce03 --- /dev/null +++ b/lib/src/screens/finder_page.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/upnp/finder.dart'; +import 'package:mdmt2_config/src/upnp/terminal_info.dart'; + +class FinderPage extends StatefulWidget { + @override + _FinderPageState createState() => _FinderPageState(); +} + +class _FinderPageState extends State { + final finder = Finder(); + + @override + void initState() { + super.initState(); + finder.start(); + } + + @override + void dispose() { + finder.dispose(); + super.dispose(); + } + + void _popResult(TerminalInfo info) { + Navigator.of(context).pop(info); + } + + Widget _buttonBack() => Container( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text('Back'), + onPressed: () => Navigator.of(context).pop(), + ) + ], + ), + ); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + persistentFooterButtons: [_buttonBack()], + appBar: AppBar( + leading: SizedBox(), + title: Text('SSDP'), + ), + body: _body(), + )); + } + + Widget _body() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [_status(), Expanded(child: _view())], + ); + } + + Widget _status() { + return StreamBuilder( + stream: finder.status, + builder: (_, snapshot) { + debugPrint('${snapshot.data} ${snapshot.connectionState}'); + if (snapshot == null || snapshot.connectionState != ConnectionState.active) return SizedBox(); + if (snapshot.hasError) return _retryBar(text: snapshot.error.toString()); + if (snapshot.data == null || snapshot.data == FinderStage.processing) return SizedBox(); + if (snapshot.data == FinderStage.wait) return _retryBar(); + return Container( + height: 2, + child: LinearProgressIndicator(), + ); + }); + } + + Widget _retryBar({String text}) { + return Column( + verticalDirection: VerticalDirection.up, + mainAxisSize: MainAxisSize.min, + children: [ + if (text != null) Text(text), + RaisedButton( + onPressed: () => finder.start(), + child: Text( + 'Retry', + overflow: TextOverflow.ellipsis, + ), + padding: EdgeInsets.zero, + ) + ], + ); + } + + Widget _view() { + return StreamBuilder>( + stream: finder.data, + builder: (_, snapshot) { + final error = snapshot?.hasError == true ? snapshot.error : null; + return DataTable( + sortColumnIndex: 1, + sortAscending: finder.sort, + columns: [ + DataColumn(label: Text('Terminals', overflow: TextOverflow.ellipsis)), + DataColumn( + label: Text('Fresh', overflow: TextOverflow.ellipsis), + numeric: true, + onSort: (_, value) => finder.sort = !finder.sort) + ], + showCheckboxColumn: false, + rows: [ + if (error != null) + DataRow(cells: [DataCell(Text('$error', overflow: TextOverflow.ellipsis)), DataCell(SizedBox())]) + else + ..._rowsBuild(snapshot?.data) + ]); + }); + } + + List _rowsBuild(List data) { + if (data == null || data.isEmpty) return []; + final subtitleScale = (MediaQuery.of(context).textScaleFactor ?? 1.0) * 0.85; + + return [ + for (var i in data) + DataRow(onSelectChanged: (_) => _popResult(i), cells: [ + DataCell( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${i.ip}:${i.port}', overflow: TextOverflow.ellipsis), + Text( + 'Version: ${i.version}, uptime: ${i.uptime} sec', + overflow: TextOverflow.ellipsis, + textScaleFactor: subtitleScale, + ) + ], + ), + ), + DataCell(Text( + '${i.fresh}%', + overflow: TextOverflow.ellipsis, + )) + ]), + ]; + } +} diff --git a/lib/src/screens/home.dart b/lib/src/screens/home.dart new file mode 100644 index 0000000..c471406 --- /dev/null +++ b/lib/src/screens/home.dart @@ -0,0 +1,359 @@ +import 'package:flutter/cupertino.dart' as cupertino; +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/dialogs.dart'; +import 'package:mdmt2_config/src/screens/running_server/runhing_server.dart'; +import 'package:mdmt2_config/src/screens/settings.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/servers/servers_controller.dart'; +import 'package:mdmt2_config/src/settings/misc_settings.dart'; +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:provider/provider.dart'; + +enum ServerMenu { connect, edit, remove, view, stop, clone, clear } +enum MainMenu { addServer, removeAll, settings, About, stopAll, clearAll } + +class MainServersPage extends StatelessWidget { + void _undoToast(BuildContext context, String msg, Function() undo) { + final scaffold = Scaffold.of(context); + scaffold.hideCurrentSnackBar(); + scaffold.showSnackBar(SnackBar( + content: Text(msg, textAlign: TextAlign.center), + action: SnackBarAction(label: 'Undo!', onPressed: undo), + )); + } + + Widget _buildServersView(BuildContext context, ServersController servers) { + return ReorderableListView( + padding: EdgeInsets.only(top: 10), + children: [ + if (servers.length > 0) + for (var server in servers.loop) + ValueListenableBuilder( + key: ObjectKey(server), + valueListenable: server.states.changeNotify, + builder: (context, __, _) => _buildRow(context, servers, server)) + else + ListTile( + key: UniqueKey(), + leading: Icon(Icons.add), + trailing: Icon(Icons.add), + title: Text( + 'Add new server', + textAlign: TextAlign.center, + ), + onTap: () => _addServer(context, servers), + ) + ], + onReorder: (oldIndex, newIndex) { + debugPrint('* old=$oldIndex, new=$newIndex'); + servers.relocation(oldIndex, newIndex); + }, + ); + } + + Widget _buildRow(BuildContext context, ServersController servers, ServerData server) { + final instances = Provider.of(context, listen: false); + void view({TerminalInstance instance}) { + instance ??= instances[server]; + if (instance != null) + Navigator.of(context) + .push( + MaterialPageRoute(builder: (context) => RunningServerPage(instance, instances.style, server))) + .then((value) => server.states.messagesRead()); + } + + void openPageCallback(TerminalInstance instance) { + if (Provider.of(context, listen: false).openOnRunning) view(instance: instance); + } + + final state = CollectActualServerState(server, instances[server]); + return ListTile( + leading: _serverIcon(context, state, server), + title: Text( + '${server.name}', + maxLines: 1, + ), + onTap: () { + if (state.isControlled) view(); + }, + subtitle: Text( + state.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis + ), + trailing: PopupMenuButton( + itemBuilder: (context) => [ + if (state.isPartialLogger || state.isPartialControl) + PopupMenuItem( + child: Row( + children: [ + Icon(Icons.settings_backup_restore), + VerticalDivider(), + Text('Run ${state.isPartialLogger ? 'Logger' : 'Maintence'}') + ], + ), + value: ServerMenu.connect, + ), + if (state.isEnabled && !(state.isControlled && state.work)) + PopupMenuItem( + child: Row( + children: [ + Icon(Icons.cast_connected), + VerticalDivider(), + Text(state.isControlled ? 'Reconnect' : 'Connect') + ], + ), + value: ServerMenu.connect, + ), + if (state.isControlled) + PopupMenuItem( + child: Row( + children: [Icon(Icons.open_in_new), VerticalDivider(), Text('View')], + ), + value: ServerMenu.view, + ), + if (state.isControlled && state.work) + PopupMenuItem( + child: Row( + children: [Icon(Icons.stop), VerticalDivider(), Text('Stop')], + ), + value: ServerMenu.stop, + ), + if (state.isControlled && !state.work) + PopupMenuItem( + child: Row( + children: [Icon(Icons.clear), VerticalDivider(), Text('Clear')], + ), + value: ServerMenu.clear, + ), + if (!state.isControlled || !state.work) + PopupMenuItem( + child: Row( + children: [Icon(Icons.edit), VerticalDivider(), Text('Edit')], + ), + value: ServerMenu.edit, + ), + PopupMenuItem( + child: Row( + children: [Icon(Icons.content_copy), VerticalDivider(), Text('Clone')], + ), + value: ServerMenu.clone, + ), + if (!state.isControlled || !state.work) + PopupMenuItem( + child: Row( + children: [Icon(Icons.remove_circle_outline), VerticalDivider(), Text('Delete')], + ), + value: ServerMenu.remove, + ), + ], + icon: Icon(Icons.more_vert), + onSelected: (value) { + switch (value) { + case ServerMenu.connect: // connect && reconnect + instances.run(server, result: openPageCallback); + debugPrint('-- Run ${server.name}'); + break; + case ServerMenu.view: + view(); + break; + case ServerMenu.edit: + serverFormDialog(context, server, servers.contains).then((value) => servers.upgrade(server, value)); + break; + case ServerMenu.remove: + final index = servers.indexOf(server.name); + instances.remove(server); + servers.remove(server); + _undoToast(context, 'Removed "${server.name}"', () { + servers.insertAlways(index, server); + }); + break; + case ServerMenu.stop: + instances.stop(server); + break; + case ServerMenu.clone: + servers.addAlways(server.clone()); + break; + case ServerMenu.clear: + instances.clear(server); + break; + } + }, + ), + ); + } + + Widget _serverIcon(BuildContext context, CollectActualServerState state, ServerData server) { + Color color; + final size = Theme.of(context).iconTheme.size ?? 24; + if (!state.isControlled) { + } else if (state.work && state.errors == 0) + color = Colors.green; + else if (!state.work && state.errors == 0) + color = Colors.cyan; + else if (state.errors < state.counts) + color = Colors.yellow; + else + color = Colors.red; + final icon = Icon( + Icons.pets, + color: color, + ); + return Container( + width: size, + height: size, + child: Stack( + children: [ + icon, + _newMessagesIcon(context, server), + ], + ), + ); + } + + Widget _newMessagesIcon(BuildContext context, ServerData server) { + return ValueListenableBuilder( + valueListenable: server.states.unreadMessages, + builder: (_, count, __) { + if (count == 0) return SizedBox(); + count = count < 99 ? count : 99; + return Container( + alignment: Alignment.topRight, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.red[800], + border: Border.all(width: 1, color: Theme.of(context).canvasColor), + ), + constraints: BoxConstraints(minWidth: 13), + child: Padding( + padding: EdgeInsets.zero, + child: Center( + heightFactor: 1.3, + widthFactor: 1.3, + child: Text( + count.toString(), + style: TextStyle(fontSize: 8, color: Colors.white), + ), + ), + ), + ), + ); + }); + } + + Widget _mainPopupMenu( + BuildContext context, ServersController servers, InstancesController instances, InstancesState state) { + return PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: ListTile( + leading: Icon(Icons.add_circle_outline), + title: Text('Add server'), + ), + value: MainMenu.addServer, + ), + PopupMenuItem( + enabled: false, + child: PopupMenuDivider(), + ), + if (state.active == 0 && state.counts > 0) + PopupMenuItem( + child: ListTile(leading: Icon(Icons.clear_all), title: Text('Clear All')), + value: MainMenu.clearAll, + ), + if (state.counts == 0 && servers.length > 0) + PopupMenuItem( + child: ListTile(leading: Icon(Icons.delete_forever), title: Text('Remove all')), + value: MainMenu.removeAll, + ), + if (state.active > 0) + PopupMenuItem( + child: ListTile(leading: Icon(Icons.stop), title: Text('Stop All')), + value: MainMenu.stopAll, + ), + PopupMenuItem( + child: ListTile(leading: Icon(Icons.settings), title: Text('Settings')), + value: MainMenu.settings, + ), + PopupMenuItem( + child: ListTile(leading: Icon(Icons.account_box), title: Text('About')), + value: MainMenu.About, + ), + ], + icon: Icon(Icons.menu), + onSelected: (value) { + switch (value) { + case MainMenu.addServer: + _addServer(context, servers); + break; + case MainMenu.removeAll: + dialogYesNo(context, 'Really?', 'Remove all servers entry? This action cannot be undone.', 'Remove all', + 'Cancel') + .then((value) { + if (value == true) { + instances.clearAll(); + servers.removeAll(); + } + }); + break; + case MainMenu.settings: + //Navigator. + Navigator.of(context).push(cupertino.CupertinoPageRoute(builder: (context) => SettingsPage())); + // settings + break; + case MainMenu.About: + showAbout(context); + break; + case MainMenu.stopAll: + instances.stopAll(); + break; + case MainMenu.clearAll: + instances.clearAll(); + break; + } + }, + ); + } + + Widget _title(InstancesState state) { + if (state.counts == 0) return SizedBox(); + return Text( + '| active: ${state.active}' + '| inactive: ${state.counts - state.active}' + '| closing: ${state.closing} |', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w300)); + } + + _addServer(BuildContext context, ServersController servers) => + serverFormDialog(context, ServerData(), servers.contains).then((value) { + if (value != null) servers.add(value); + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Consumer( + builder: (_, manager, __) => StreamBuilder( + initialData: InstancesState(), stream: manager.stateStream, builder: (_, value) => _title(value.data))), + actions: [ + Consumer2( + builder: (context, servers, manager, child) => servers.isLoaded + ? StreamBuilder( + initialData: InstancesState(), + stream: manager.stateStream, + builder: (_, val) => _mainPopupMenu(context, servers, manager, val.data)) + : child, + child: Container(), + ) + ], + ), + body: SafeArea( + child: Consumer( + builder: (context, servers, child) => servers.isLoaded ? _buildServersView(context, servers) : child, + child: Container(), + )), + ); + } +} diff --git a/lib/src/screens/running_server/backup_list.dart b/lib/src/screens/running_server/backup_list.dart new file mode 100644 index 0000000..0296fea --- /dev/null +++ b/lib/src/screens/running_server/backup_list.dart @@ -0,0 +1,278 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:intl/intl.dart'; +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:mdmt2_config/src/terminal/terminal_client.dart'; +import 'package:mdmt2_config/src/terminal/terminal_control.dart'; +import 'package:mdmt2_config/src/utils.dart'; +import 'package:mdmt2_config/src/widgets.dart'; + +class _Controller { + final InstanceViewState _view; + final TerminalControl _control; + StreamSubscription _subscription; + + final _timeout = Duration(seconds: 20); + + final updateCircle = ValueNotifier(true); + final enableBackupManual = ValueNotifier(false); + final enableRestore = ValueNotifier(false); + final filename = ValueNotifier(''); + final timeoutSignal = ChangeValueNotifier(); + + bool _allowUpdate = false; + bool isTimeout = false; + bool isConnected = true; + Timer _timer; + + _Controller(this._view, this._control) { + updateCircle.addListener(() { + _enableBackupManualSet(); + _enableUpdateSet(); + _enableRestoreSet(); + }); + filename.addListener(() { + _enableRestoreSet(); + }); + } + + void _enableBackupManualSet() => enableBackupManual.value = isConnected && + !updateCircle.value && + !_view.buttons['terminal_stop'].value && + !_view.buttons['manual_backup'].value; + + void _enableUpdateSet() => _allowUpdate = isConnected && !updateCircle.value && !_view.buttons['terminal_stop'].value; + + void _enableRestoreSet() => enableRestore.value = + isConnected && !updateCircle.value && filename.value != '' && !_view.buttons['terminal_stop'].value; + + void _terminalStop() { + _enableBackupManualSet(); + _enableUpdateSet(); + _enableRestoreSet(); + } + + void _update() { + _timer?.cancel(); + _control.executeMe('backup.list'); + updateCircle.value = true; + isTimeout = false; + _timer = Timer(_timeout, () { + isTimeout = true; + timeoutSignal.notifyListeners(); + updateCircle.value = false; + }); + } + + void timerCancel() { + _timer?.cancel(); + //FIXME: setState() or markNeedsBuild() called during build. + WidgetsBinding.instance.addPostFrameCallback((_) => updateCircle.value = false); + } + + void callRestore() => _control.executeMe('backup.restore', data: filename.value); + void callBackup() => _control.executeMe('backup.manual'); + + Future refresh() async { + if (!_allowUpdate) return; + filename.value = ''; + _update(); + return null; + } + + setFilename(String newFilename) { + if (isConnected) filename.value = newFilename; + } + + void initState() { + _subscription = _control.workerNotifyListen((event) { + isConnected = _control.getStage == ConnectStage.controller; + _enableBackupManualSet(); + _enableUpdateSet(); + _enableRestoreSet(); + }); + _view.buttons['terminal_stop'].addListener(_terminalStop); + _view.buttons['manual_backup'].addListener(_enableBackupManualSet); + _update(); + } + + void dispose() { + _subscription?.cancel(); + _view.buttons['manual_backup'].removeListener(_enableBackupManualSet); + _view.buttons['terminal_stop'].removeListener(_terminalStop); + _timer?.cancel(); + } +} + +class BackupSelectsPage extends StatefulWidget { + final TerminalControl control; + final InstanceViewState view; + + BackupSelectsPage(this.control, this.view, {Key key}) : super(key: key); + + @override + _BackupSelectsPageState createState() => _BackupSelectsPageState(); +} + +class _BackupSelectsPageState extends State { + final _scaffoldKey = GlobalKey(); + StreamSubscription _subscription; + _Controller controller; + + @override + void initState() { + super.initState(); + controller = _Controller(widget.view, widget.control); + WidgetsBinding.instance.addPostFrameCallback((_) => _subscription = widget.control.streamToads.listen((event) { + debugPrint(event); + return seeOkToast(null, event, scaffold: _scaffoldKey.currentState); + })); + controller.initState(); + } + + @override + void dispose() { + _subscription?.cancel(); + controller.dispose(); + super.dispose(); + } + + Widget _buttonsBottom() => Container( + width: MediaQuery.of(context).size.width, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ValueListenableBuilder( + valueListenable: controller.enableBackupManual, + builder: (_, enabled, __) { + return FlatButton(child: Text('Backup'), onPressed: enabled ? controller.callBackup : null); + }), + _buttonCancelRestore() + ], + )); + + Widget _buttonCancelRestore() => ButtonBar( + children: [ + FlatButton( + child: Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + ValueListenableBuilder( + valueListenable: controller.enableRestore, + builder: (_, enabled, __) => FlatButton( + child: Text('Restore'), + onPressed: enabled + ? () { + controller.callRestore(); + Navigator.of(context).pop(); + } + : null, + )) + ], + ); + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Scaffold( + key: _scaffoldKey, + persistentFooterButtons: [_buttonsBottom()], + appBar: AppBar( + leading: SizedBox(), + title: Text('Backups'), + ), + body: _body(), + )); + } + + Widget _body() { + final emptyBox = SizedBox(); + return Stack( + fit: StackFit.expand, + children: [ + StreamBuilder( + initialData: null, + stream: widget.control.streamBackupList, + builder: (_, AsyncSnapshot> snapshot) => ValueListenableBuilder( + valueListenable: controller.timeoutSignal, + builder: (_, __, ___) => RefreshIndicator( + child: _view(snapshot), + onRefresh: controller.refresh, + notificationPredicate: (v) => controller.isConnected && v.depth == 0, + ))), + ValueListenableBuilder( + valueListenable: controller.updateCircle, + builder: (_, isUpdated, child) => isUpdated ? child : emptyBox, + child: _await(context), + ) + ], + ); + } + + Widget _view(AsyncSnapshot> snapshot) { + if (controller.isTimeout) return _text('Timeout'); + if (snapshot.connectionState == ConnectionState.waiting) return _text('Loading...'); + + if (snapshot.hasError) { + controller.timerCancel(); + return _text('${snapshot.error}'); + } + if (snapshot.connectionState == ConnectionState.active) { + controller.timerCancel(); + return snapshot.data == null ? _text('Transfer error') : _list(snapshot.data); + } + controller.timerCancel(); + return _text('Internal error: ${snapshot.connectionState}'); + } + + Widget _await(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + color: Theme.of(context).canvasColor.withOpacity(.4), + constraints: BoxConstraints.expand(), + child: Text('')), + Align( + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(), + ) + ], + ); + } + + Widget _text(String text) { + return Stack( + fit: StackFit.expand, + children: [ + Center( + child: Text( + text, + overflow: TextOverflow.ellipsis, + ), + ), + ListView() + ], + ); + } + + Widget _list(List backups) { + return ValueListenableBuilder( + valueListenable: controller.filename, + builder: (_, selected, __) => ListView.builder( + itemCount: backups.length, + shrinkWrap: true, + itemBuilder: (_, index) { + if (index >= backups.length) return null; + return RadioListTile( + value: backups[index].filename, + title: Text(DateFormat('yyyy.MM.dd HH:mm:ss.SSS').format(backups[index].time)), + subtitle: Text(backups[index].filename), + groupValue: selected, + onChanged: controller.isConnected ? controller.setFilename : (_) {}); + }), + ); + } +} diff --git a/lib/src/screens/running_server/controller.dart b/lib/src/screens/running_server/controller.dart new file mode 100644 index 0000000..a62bc20 --- /dev/null +++ b/lib/src/screens/running_server/controller.dart @@ -0,0 +1,499 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/dialogs.dart'; +import 'package:mdmt2_config/src/screens/running_server/backup_list.dart'; +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:mdmt2_config/src/terminal/terminal_client.dart'; +import 'package:mdmt2_config/src/terminal/terminal_control.dart'; +import 'package:mdmt2_config/src/utils.dart'; +import 'package:mdmt2_config/src/widgets.dart'; + +class ControllerView extends StatefulWidget { + final TerminalControl control; + final InstanceViewState view; + + ControllerView(this.control, this.view, {Key key}) : super(key: key); + + @override + _ControllerViewState createState() => _ControllerViewState(); +} + +class _ControllerViewState extends State { + final _subscriptions = >{}; + bool _isConnected; + + @override + void dispose() { + for (var subscription in _subscriptions.values) subscription.cancel(); + _subscriptions.clear(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _isConnected = widget.control.getStage == ConnectStage.controller; + _subscriptions['toads'] = widget.control.streamToads.listen((event) => seeOkToast(context, event)); + _subscriptions['connected'] = widget.control.workerNotifyListen((event) { + final isConnected = widget.control.getStage == ConnectStage.controller; + debugPrint(' isConnected=$isConnected'); + if (isConnected != _isConnected) { + setState(() => _isConnected = isConnected); + } + }); + } + + _openBackupListPage(BuildContext context) { + _subscriptions.remove('toads')?.cancel(); + Navigator.of(context) + .push(MaterialPageRoute(builder: (context) => BackupSelectsPage(widget.control, widget.view))) + .then( + (_) => _subscriptions['toads'] = widget.control.streamToads.listen((event) => seeOkToast(context, event))); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.control.reconnect.isActive, + builder: (context, restarting, child) { + if (!_isConnected && restarting) + return Stack( + fit: StackFit.expand, + children: [ + child, + Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ) + ], + ); + return child; + }, + child: _main(context), + ); + } + + Widget _main(BuildContext context) { + return SingleChildScrollView( + child: SafeArea( + child: Container( + margin: EdgeInsets.fromLTRB(5, 5, 5, 15), + child: Column( + children: [ + divider('Status'), + terminalStatusLine(), + divider('Creating models'), + Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: creatingModels2Line(context), + ), + divider('TTS/ASK/VOICE'), + fieldForTAVLine(widget.view, (cmd, msg) => widget.control.executeMe(cmd, data: msg), _isConnected), + divider('Music'), + musicLine(), + divider('Maintenance'), + maintenance2Line(), + ], + ), + )), + ); + } + + Widget maintenance2Line() { + return Table( + columnWidths: {for (int i = 1; i < 6; i += 2) i: FractionColumnWidth(0.02)}, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [maintenanceLine1(), maintenanceLine2()], + ); + } + + TableRow maintenanceLine1() { + return TableRow(children: [ + RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected ? () => widget.control.executeMe('ping') : null, + child: Text('Ping'), + ), + SizedBox(), + ValueListenableBuilder( + valueListenable: widget.view.buttons['terminal_stop'], + builder: (_, busy, __) => RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected && !busy + ? () => dialogYesNo(context, 'Reload server?', '', 'Reload', 'Cancel').then((value) { + if (value) widget.control.executeMe('maintenance.reload'); + }) + : null, + child: Text('Reload'), + )), + SizedBox(), + ValueListenableBuilder( + valueListenable: widget.view.buttons['terminal_stop'], + builder: (_, busy, __) => RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected && !busy + ? () => dialogYesNo(context, 'Stop server?', '', 'Stop', 'Cancel').then((value) { + if (value) widget.control.executeMe('maintenance.stop'); + }) + : null, + child: Text('Stop'), + )), + SizedBox(), + ValueListenableBuilder( + valueListenable: widget.view.listener, + builder: (_, isOn, __) { + final onOff = isOn ? 'OFF' : 'ON'; + return RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected ? () => widget.control.executeMe('listener', data: onOff.toLowerCase()) : null, + child: Text('Listen $onOff'), + ); + }), + ]); + } + + TableRow maintenanceLine2() { + return TableRow(children: [ + ValueListenableBuilder( + valueListenable: widget.view.buttons['manual_backup'], + builder: (_, busy, __) => RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected && !busy ? () => widget.control.executeMe('backup.manual') : null, + child: Text('Backup'), + )), + SizedBox(), + ValueListenableBuilder( + valueListenable: widget.view.buttons['terminal_stop'], + builder: (context, busy, __) => RaisedButton( + padding: EdgeInsets.all(2), + onPressed: _isConnected && !busy ? () => _openBackupListPage(context) : null, + child: Text('Restore*'), + )), + SizedBox(), + SizedBox(), // + SizedBox(), + SizedBox(), // + ]); + } + + Widget terminalStatusLine() { + return Row( + children: [ + Text('Volume:'), + VolumeSlider( + widget.view.volume, + (newVal) => widget.control.executeMe('volume', data: newVal), + enabled: _isConnected, + ), + talkStatus(), + recordStatus() + ], + ); + } + + Widget talkStatus() { + return ValueListenableBuilder( + valueListenable: widget.view.buttons['talking'], + builder: (_, value, __) { + value &= _isConnected; + return SizedBox( + width: 26.0, + height: 26.0, + child: IconButton( + onPressed: value ? () {} : null, + padding: EdgeInsets.zero, + icon: Icon(value ? Icons.volume_up : Icons.volume_off), + ), + ); + }); + } + + Widget recordStatus() { + return ValueListenableBuilder( + valueListenable: widget.view.buttons['record'], + builder: (_, value, __) { + value &= _isConnected; + return SizedBox( + width: 26.0, + height: 26.0, + child: IconButton( + onPressed: value ? () {} : null, + padding: EdgeInsets.zero, + icon: Icon(value ? Icons.mic : Icons.mic_off), + ), + ); + }); + } + + Widget musicLine() { + return ValueListenableBuilder( + valueListenable: widget.view.musicStatus, + builder: (_, status, __) { + final enable = _isConnected && status != MusicStatus.error && status != MusicStatus.nope; + String label; + IconData icon; + String cmd = 'pause'; + if (status == MusicStatus.play) { + label = 'Puase'; + icon = Icons.pause; + } else if (status == MusicStatus.pause) { + label = 'Play'; + icon = Icons.play_arrow; + } else if (status == MusicStatus.stop) { + label = 'Replay'; + icon = Icons.replay; + cmd = 'play'; + } else { + icon = Icons.error; + label = status.toString().split('.').last; + label = label[0].toUpperCase() + label.substring(1); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RaisedButton.icon( + //elevation: 1, + onPressed: enable ? () => widget.control.executeMe(cmd) : null, + icon: Icon(icon), + label: Text(label)), + VolumeSlider( + widget.view.musicVolume, + (newVal) => widget.control.executeMe('mvolume', data: newVal), + enabled: enable, + ), + SizedBox( + width: 30, + height: 30, + child: RaisedButton( + onPressed: enable + ? () { + uriDialog(context, widget.view.musicURI).then((value) { + if (value == null) return; + widget.view.musicURI = value; + if (_isConnected) widget.control.executeMe('play', data: value); + }); + } + : null, + child: Icon(Icons.replay), + padding: EdgeInsets.zero), + ) + ], + ); + }); + } + + Widget creatingModels2Line(BuildContext context) { + return Table( + columnWidths: { + 0: FractionColumnWidth(.18), + 2: FractionColumnWidth(.015), + 4: FractionColumnWidth(.16), + 5: FractionColumnWidth(0.08) + }, + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + children: [makeModelLine(context), makeSampleLine()], + ); + } + + TableRow makeModelLine(BuildContext context) { + return TableRow(children: [ + Text('Model:'), + ValueListenableBuilder( + valueListenable: widget.view.buttons['model_compile'], + builder: (_, busy, __) => RaisedButton( + child: Text('Compile'), + onPressed: _isConnected && !busy + ? () => widget.control.executeMe('rec', data: 'compile_${widget.view.modelIndex.value}_0') + : null)), + SizedBox(), + RaisedButton( + child: Text('Remove'), + onPressed: _isConnected + ? () => dialogYesNo(context, 'Remove model #${widget.view.modelIndex.value}?', '', 'Remove', 'Cancel') + .then((value) { + if (value) widget.control.executeMe('rec', data: 'del_${widget.view.modelIndex.value}_0'); + }) + : null), + SizedBox(), + dropdownButtonInt(widget.view.modelIndex, 6) + ]); + } + + TableRow makeSampleLine() { + String target() => '${widget.view.modelIndex.value}_${widget.view.sampleIndex.value}'; + return TableRow(children: [ + Text('Sample:'), + ValueListenableBuilder( + valueListenable: widget.view.buttons['sample_record'], + builder: (_, busy, __) => RaisedButton( + child: Text('Record'), + onPressed: + _isConnected && !busy ? () => widget.control.executeMe('rec', data: 'rec_${target()}') : null)), + SizedBox(), + RaisedButton( + child: Text('Play'), + onPressed: _isConnected ? () => widget.control.executeMe('rec', data: 'play_${target()}') : null), + SizedBox(), + dropdownButtonInt(widget.view.sampleIndex, 3), + ]); + } +} + +class VolumeSlider extends StatefulWidget { + final ValueNotifier position; + final void Function(int) onChange; + final bool enabled; + + VolumeSlider(this.position, this.onChange, {this.enabled = false, Key key}) : super(key: key); + + @override + _VolumeSliderState createState() => _VolumeSliderState(); +} + +class _VolumeSliderState extends State { + // Отправляем с задержкой т.е. слайдер глючный и может выдать изменение сразу после начала использования + static const throttleToSend = Duration(milliseconds: 200); + Timer toSendTimer; + double position; + + @override + void initState() { + super.initState(); + _rebuild(); + widget.position.addListener(_rebuild); + } + + @override + void dispose() { + toSendTimer?.cancel(); + widget.position.removeListener(_rebuild); + super.dispose(); + } + + void _rebuild() { + setState(() { + position = widget.position.value.toDouble(); + if (position < 0) + position = 0; + else if (position > 100) position = 100; + }); + } + + @override + Widget build(BuildContext context) { + final enabled = widget.enabled && widget.position.value >= 0 && widget.position.value <= 100; + return Slider( + value: position, + onChanged: enabled + ? (newValue) { + toSendTimer?.cancel(); + if ((position - newValue).abs() < 1.0) return; + setState(() => position = newValue); + } + : null, + max: 100, + min: 0, + divisions: 100, + label: '${position.round()}', + onChangeStart: (_) => toSendTimer?.cancel(), + onChangeEnd: (newValue) { + toSendTimer?.cancel(); + if ((position - widget.position.value).abs() < 1.0) return; + toSendTimer = Timer(throttleToSend, () { + widget.onChange(newValue.truncate()); + }); + }, + ); + } +} + +Widget divider(String text, {rightFlex = 10}) => Padding( + padding: EdgeInsets.only(top: 15, bottom: 5), + child: Row( + children: [ + Expanded( + child: Divider(), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text( + text, + ), + ), + Expanded(flex: rightFlex, child: Divider()) + ], + ), + ); + +Widget dropdownButtonInt(ValueNotifier value, int count) { + return ValueListenableBuilder( + valueListenable: value, + builder: (_, _value, __) => DropdownButton( + autofocus: true, + isExpanded: true, + isDense: true, + value: _value, + items: [ + for (int i = 1; i <= count; i++) + DropdownMenuItem( + child: Text('$i'), + value: i, + ) + ], + onChanged: (newVal) => value.value = newVal)); +} + +Widget fieldForTAVLine(InstanceViewState state, Function(String cmd, String msg) onSend, bool isConnected) { + final TextEditingController _controller = TextEditingController(text: state.textTAV); + final _notifier = ChangeValueNotifier(); + _send() { + onSend(state.modeTAV, state.textTAV); + if (state.modeTAV != 'VOICE') _controller.text = ''; + } + + _controller.addListener(() { + if (_controller.text == state.textTAV) return; + final reBuild = state.modeTAV != 'VOICE' && (_controller.text == '' || state.textTAV == ''); + state.textTAV = _controller.text; + if (reBuild) _notifier.notifyListeners(); + }); + return ValueListenableBuilder( + valueListenable: _notifier, + builder: (_, __, ___) { + final toSend = !isConnected || (state.modeTAV != 'VOICE' && state.textTAV == '') ? null : _send; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButton( + itemHeight: 56, + items: ['TTS', 'ASK', 'VOICE'].map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + value: state.modeTAV, + onChanged: (val) { + state.modeTAV = val; + _notifier.notifyListeners(); + }, + ), + Expanded( + child: TextField( + textInputAction: TextInputAction.send, + maxLines: 1, + onEditingComplete: toSend, + autofocus: false, + controller: _controller, + enabled: state.modeTAV != 'VOICE' && isConnected, + ), + ), + IconButton( + onPressed: toSend, + icon: Icon(toSend != null ? Icons.send : Icons.do_not_disturb_on), + ) + ], + ); + }); +} diff --git a/lib/src/screens/running_server/runhing_server.dart b/lib/src/screens/running_server/runhing_server.dart new file mode 100644 index 0000000..1f6b1a4 --- /dev/null +++ b/lib/src/screens/running_server/runhing_server.dart @@ -0,0 +1,418 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:mdmt2_config/src/screens/running_server/controller.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/settings/log_style.dart'; +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:mdmt2_config/src/terminal/log.dart'; +import 'package:mdmt2_config/src/terminal/terminal_control.dart'; + +class RunningServerPage extends StatefulWidget { + final TerminalInstance instance; + final LogStyle baseStyle; + final ServerData server; + + RunningServerPage(this.instance, this.baseStyle, this.server, {Key key}) : super(key: key); + + @override + _RunningServerPage createState() => _RunningServerPage(); +} + +class _RunningServerPage extends State with SingleTickerProviderStateMixin { + TabController _tabController; + + @override + void dispose() { + super.dispose(); + _tabController.dispose(); + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this, initialIndex: widget.instance.view.pageIndex.value); + _tabController.addListener(() { + widget.instance.view.pageIndex.value = _tabController.index; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: AppBar( + actions: _actions(), + bottom: TabBar(controller: _tabController, tabs: [ + Text('Log'), + Text('Control'), + ]), + )), + body: TabBarView(controller: _tabController, children: [ + _oneTab(context), + _twoTab(context), + ]), + ); + } + + _actions() { + return [ + IconButton( + icon: Icon(Icons.import_export), + onPressed: () { + final index = widget.instance.view.pageIndex.value; + if (index == 0) + widget.instance.view.logExpanded.value = !widget.instance.view.logExpanded.value; + else if (index == 1) widget.instance.view.controlExpanded.value = !widget.instance.view.controlExpanded.value; + }, + ), + ]; + } + + Widget _oneTab(BuildContext context) { + return Stack( + children: [ + Container( + constraints: BoxConstraints.expand(), + child: widget.instance?.log != null + ? LogListView(widget.instance.log, widget.instance.view, widget.server) + : _disabledBody(), + ), + ValueListenableBuilder( + valueListenable: widget.instance.view.logExpanded, + builder: (_, expanded, child) => expanded ? child : SizedBox(), + child: Container( + color: Colors.black.withOpacity(.5), + child: widget.instance?.log != null ? _loggerSettings() : _disabledTop(), + )), + ], + ); + } + + Widget _twoTab(BuildContext context) { + return Column( + children: [ + ValueListenableBuilder( + valueListenable: widget.instance.view.controlExpanded, + builder: (_, expanded, child) => !expanded + ? child + : Expanded( + child: Container( + child: + widget.instance?.control != null ? _controllerSettings(widget.instance.control) : _disabledTop(), + )), + child: Expanded( + child: widget.instance?.control != null + ? ControllerView(widget.instance.control, widget.instance.view) + : _disabledBody(), + ), + ) + ], + ); + } + + Widget _loggerSettings() { + return ValueListenableBuilder( + valueListenable: widget.instance.view.style, + builder: (context, _, __) => Column( + mainAxisSize: MainAxisSize.min, + children: [_loggerSettings1(), _loggerSettings2(), Divider(), _loggerSettings3(), Divider()], + )); + } + + Widget _loggerSettings2() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + verticalDirection: VerticalDirection.down, + children: [ + ValueListenableBuilder( + valueListenable: widget.baseStyle, + builder: (_, __, ___) => _loggerFlatButton( + 'Save', + widget.instance.view.style.isEqual(widget.baseStyle) + ? null + : () { + widget.baseStyle + ..upgrade(widget.instance.view.style) + ..saveAll(); + }), + ), + _loggerFlatButton( + 'Default', + widget.instance.view.style.isEqual(LogStyle()) + ? null + : () { + final def = LogStyle(); + widget.instance.view.style.upgrade(def); + }) + ], + ); + } + + Widget _loggerSettings3() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + verticalDirection: VerticalDirection.down, + children: [ + ValueListenableBuilder( + valueListenable: widget.server.states.unreadMessages, + builder: (_, __, ___) => _loggerFlatButton( + 'Clear log', + widget.instance.log.isNotEmpty ? widget.instance.log.clear : null, + ), + ) + ], + ); + } + + Widget _loggerFlatButton(String text, Function onPress) { + return FlatButton( + padding: EdgeInsets.zero, + color: Colors.deepPurpleAccent.withOpacity(.5), + textColor: Colors.white, + disabledTextColor: Colors.grey, + onPressed: onPress, + child: Text(text), + ); + } + + Widget _loggerSettings1() { + String capitalize(LogLevel l) { + final s = l.toString().split('.').last; + return s[0].toUpperCase() + s.substring(1); + } + + final style = widget.instance.view.style; + return Row(children: [ + Expanded( + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: _drawButton( + context, + 'level: ${capitalize(style.lvl)}', + ), + itemBuilder: (context) => [ + for (LogLevel l in LogLevel.values) + if (l != style.lvl) + PopupMenuItem( + child: Text(capitalize(l)), + value: l, + ) + ], + onSelected: (value) => style.lvl = value)), + Expanded( + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: _drawButton(context, 'font: ${style.fontSize}'), + itemBuilder: (context) => [ + for (int i = 2; i < 28; i += 4) + if (i != style.fontSize) + PopupMenuItem( + child: Text('$i'), + value: i, + ) + ], + onSelected: (value) => style.fontSize = value)), + Expanded( + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: _drawButton(context, 'time: ${style.timeFormat}'), + itemBuilder: (context) => [ + for (String l in timeFormats.keys) + if (l != style.timeFormat) + PopupMenuItem( + child: Text('$l'), + value: l, + ) + ], + onSelected: (value) => style.timeFormat = value)), + Expanded( + child: PopupMenuButton( + padding: EdgeInsets.zero, + icon: _drawButton(context, 'source: ${style.callLvl < LogStyle.callers.length ? style.callLvl : 'All'}'), + itemBuilder: (context) => [ + // С нуля + for (int i = 0; i < LogStyle.callers.length + 1; i++) + if (i != style.callLvl) + PopupMenuItem( + child: Text('${i < LogStyle.callers.length ? i : 'All'}'), + value: i, + ) + ], + onSelected: (value) => style.callLvl = value)) + ]); + } + + Widget _controllerSettings(TerminalControl control) { + return Center( + child: Text('WIP'), + ); + } +} + +class LogListView extends StatefulWidget { + final Log log; + final InstanceViewState view; + final ServerData server; + + LogListView(this.log, this.view, this.server, {Key key}) : super(key: key); + + @override + _LogListViewState createState() => _LogListViewState(); +} + +class _LogListViewState extends State with SingleTickerProviderStateMixin { + ScrollController _logScroll; + AnimationController _animationController; + Animation _animation; + + @override + void dispose() { + _logScroll.dispose(); + _animationController.stop(canceled: true); + _animationController.dispose(); + widget.view.backButton.value = ButtonDisplaySignal.hide; + super.dispose(); + } + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: InstanceViewState.fadeInButtonTime, + reverseDuration: InstanceViewState.fadeOutButtonTime); + _animation = CurvedAnimation(parent: _animationController, curve: Curves.linear, reverseCurve: Curves.linear); + + _logScroll = ScrollController(initialScrollOffset: widget.view.logScrollPosition, keepScrollOffset: false); + WidgetsBinding.instance.addPostFrameCallback((_) => widget.view.scrollCallback(_logScroll)); + _logScroll.addListener(() => widget.view.scrollCallback(_logScroll)); + } + + @override + Widget build(BuildContext context) { + return Container( + color: LogStyle.backgroundColor, + child: SafeArea( + child: Scrollbar( + controller: _logScroll, + child: Stack( + fit: StackFit.expand, + children: [ + ValueListenableBuilder( + valueListenable: widget.view.style, + builder: (context, _, __) => ValueListenableBuilder( + valueListenable: widget.server.states.unreadMessages, + builder: (context, _, __) => Container( + margin: EdgeInsets.only(right: 5, left: 5, bottom: 10), + child: ListView.builder( + controller: _logScroll, + reverse: true, + shrinkWrap: true, + itemCount: widget.log.length, + padding: EdgeInsets.zero, + itemBuilder: (context, i) { + if (i >= widget.log.length) return null; + return _buildLogLineView(widget.view.style, widget.log[i]); + }), + ))), + Align( + alignment: Alignment.bottomRight, + child: ValueListenableBuilder( + valueListenable: widget.view.backButton, + builder: (_, status, child) { + switch (status) { + case ButtonDisplaySignal.hide: + _animationController.reset(); + return SizedBox(); + case ButtonDisplaySignal.fadeIn: + _animationController.forward(); + return child; + case ButtonDisplaySignal.fadeOut: + _animationController.reverse(); + return child; + } + return SizedBox(); + }, + child: Padding( + padding: EdgeInsets.all(10), + child: FadeTransition( + opacity: _animation, + child: FloatingActionButton( + backgroundColor: Theme.of(context).highlightColor, + foregroundColor: LogStyle.backgroundColor.withOpacity(.75), + onPressed: () => widget.view.scrollBack(_logScroll), + child: Transform.rotate( + angle: math.pi, + child: Icon(Icons.navigation), + ), + )), + ), + ), + ) + ], + )), + ), + ); + } + + Widget _buildLogLineView(LogStyle style, LogLine line) { + final calls = [ + for (int p = 0; p < line.callers.length && (style.callLvl == 3 || p < style.callLvl); p++) + TextSpan(text: line.callers[p], style: LogStyle.callers[p] ?? LogStyle.callers[LogStyle.callers.length - 1]), + ]; + //FIXME: Баг, бесконечно обновляется. + //return SelectableText.rich( + return RichText( + text: TextSpan(style: style.base, children: [ + if (style.timeFormat != 'None') ...[ + TextSpan(text: DateFormat(timeFormats[style.timeFormat]).format(line.time), style: LogStyle.time), + TextSpan(text: ' '), + ], + ...[ + for (int i = 0; i < calls.length; i++) ...[calls[i], TextSpan(text: i < calls.length - 1 ? '->' : ': ')] + ], + TextSpan(text: line.msg, style: LogStyle.msg[line.lvl]) + ]), + ); + } +} + +Widget _disabledTop() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Divider( + color: Colors.transparent, + ), + Center(child: Text('Disabled')), + Divider( + color: Colors.transparent, + ), + ], + ); +} + +Widget _disabledBody({Color backgroundColor}) { + return Container( + color: backgroundColor, + child: Center( + child: Text('Disabled'), + ), + ); +} + +Widget _drawButton(BuildContext context, String text) { + return Container( + padding: EdgeInsets.all(2), + child: Text( + text, + style: TextStyle(color: Colors.white), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: Colors.deepPurpleAccent, width: 3))), + ); +} diff --git a/lib/src/screens/settings.dart b/lib/src/screens/settings.dart new file mode 100644 index 0000000..bd4259f --- /dev/null +++ b/lib/src/screens/settings.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/dialogs.dart'; +import 'package:mdmt2_config/src/settings/misc_settings.dart'; +import 'package:mdmt2_config/src/settings/theme_settings.dart'; +import 'package:mdmt2_config/src/utils.dart'; +import 'package:mdmt2_config/src/widgets.dart'; +import 'package:provider/provider.dart'; + +class SettingsPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + final reconnectTile = ChangeValueNotifier(); + final settings = Provider.of(context, listen: false); + return Scaffold( + appBar: AppBar( + title: Text('Settings'), + ), + body: Padding( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + _themeSelector(context), + switchListTileTap( + activeColor: Theme.of(context).accentColor, + title: Text('Open at Run'), + subtitle: Text('Auto opening the server page at run'), + value: settings.openOnRunning, + onChanged: (newVal) => settings.openOnRunning = newVal), + ValueListenableBuilder( + valueListenable: reconnectTile, + builder: (_, __, ___) => ListTile( + title: Text('Reconnect after reboot'), + subtitle: Text(settings.autoReconnectAfterReboot > 0 + ? 'after ${settings.autoReconnectAfterReboot} seconds' + : 'disabled'), + onTap: () => uIntDialog(context, settings.autoReconnectAfterReboot, 'Delay [0: disabled]') + .then((value) { + if (value != null) { + settings.autoReconnectAfterReboot = value; + reconnectTile.notifyListeners(); + } + }), + )), + ], + )), + ); + } + + Widget _themeSelector(BuildContext context) { + final theme = Provider.of(context, listen: false); + return ListTile( + title: Text('Theme'), + subtitle: Text('Selected: ${theme.theme}'), + onTap: () => dialogSelectOne(context, ThemeSettings.list, + title: 'Choose theme', + selected: theme.theme, + sets: {for (var item in ThemeSettings.list) item: item}).then((value) { + debugPrint(' * Set theme: ${value.toString()}'); + theme.theme = value; + }), + ); + } +} diff --git a/lib/src/servers/server_data.dart b/lib/src/servers/server_data.dart new file mode 100644 index 0000000..a37dda2 --- /dev/null +++ b/lib/src/servers/server_data.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class ServerDataStates { + final changeNotify = ChangeValueNotifier(); + final unreadMessages = ValueNotifier(0); + + void notifyListeners() => changeNotify.notifyListeners(); + void messagesNew() => unreadMessages.value += 1; + void messagesRead() => unreadMessages.value = 0; + void messagesClear() { + if (unreadMessages.value != 0) + messagesRead(); + else + // ignore: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member + unreadMessages.notifyListeners(); + } +} + +class _Name { + static const _prefix = 'srv_d'; + static String index(int index) => '${_prefix}_$index'; +} + +class _N { + static const name = '1'; + static const token = '2'; + static const wsToken = '3'; + static const ip = '4'; + static const port = '5'; + static const logger = '6'; + static const control = '7'; + static const totpSalt = '8'; +} + +Future loadServerData(int index) async { + SharedPreferences p = await SharedPreferences.getInstance(); + final name = _Name.index(index); + ServerData result; + try { + result = ServerData.fromJson(jsonDecode(p.getString(name))); + } catch (e) { + debugPrint(' * Error loading ServerData $index: $e'); + return null; + } + if (result.name == null || + result.name == '' || + result.token == null || + result.wsToken == null || + result.ip == null || + result.port == null || + result.logger == null || + result.control == null || + result.totpSalt == null) { + debugPrint(' * Wrong ServerData $index'); + return null; + } + return result; +} + +Future removeServerData(int index) async { + await SharedPreferences.getInstance() + ..remove(_Name.index(index)); + debugPrint(' * remove id $index'); +} + +class ServerData { + final states = ServerDataStates(); + String name, token, wsToken, ip; + int _port; + bool logger, control, totpSalt; + ServerData( + {this.name = '', + this.token = '', + this.wsToken = 'token_is_unset', + this.ip = '127.0.0.1', + int port = 7999, + this.logger = true, + this.control = false, + this.totpSalt = false}) { + this.port = port; + } + + ServerData.fromJson(Map json) + : name = json[_N.name], + token = json[_N.token], + wsToken = json[_N.wsToken], + ip = json[_N.ip], + _port = json[_N.port], + logger = json[_N.logger], + control = json[_N.control], + totpSalt = json[_N.totpSalt]; + + Map toJson() => { + _N.name: name, + _N.token: token, + _N.wsToken: wsToken, + _N.ip: ip, + _N.port: port, + _N.logger: logger, + _N.control: control, + _N.totpSalt: totpSalt + }; + + Future saveServerData(int index) async { + await SharedPreferences.getInstance() + ..setString(_Name.index(index), jsonEncode(this)); + } + + int get port => _port; + set port(int val) { + if (val < 1 || val > 65535) throw ('Wrong port'); + _port = val; + } + + String get uri => '$ip:${_port.toString()}'; + String get title => '$name [$uri]'; + + bool upgrade(ServerData o) { + if (isEqual(o) || o.name == '') return false; + _upgrade(o); + states.notifyListeners(); + return true; + } + + void _upgrade(ServerData o) { + name = o.name; + token = o.token; + wsToken = o.wsToken; + ip = o.ip; + port = o.port; + logger = o.logger; + control = o.control; + totpSalt = o.totpSalt; + } + + bool isEqual(ServerData o) { + return name == o.name && + token == o.token && + wsToken == o.wsToken && + ip == o.ip && + port == o.port && + logger == o.logger && + control == o.control && + totpSalt == o.totpSalt; + } + + ServerData clone() => ServerData().._upgrade(this); +} diff --git a/lib/src/servers/servers_controller.dart b/lib/src/servers/servers_controller.dart new file mode 100644 index 0000000..461cb75 --- /dev/null +++ b/lib/src/servers/servers_controller.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'package:mdmt2_config/src/blocs/servers_controller.dart'; + +class ServersController extends _BLoC { + static final serverDataCount = '_srvs__count'; + // Данные серверов, порядок важен + final _servers = []; + // Индексы, для быстрого поиска по имени. + final _indices = {}; + + int get length => _servers.length; + ServerData operator [](int index) => _servers[index]; + Iterable get loop => _servers; + bool containsByObj(ServerData element) => contains(element.name); + bool contains(String name) => _indices.containsKey(name); + int indexOf(String name) => _indices[name] ?? -1; + bool isLoaded = false; + + ServersController() { + _loadAll(); + } + + @override + void dispose() { + super.dispose(); + } + + void _removeAllInput() { + _removeAll(); + if (_servers.isNotEmpty) { + _servers.clear(); + notifyListeners(); + } + _indices.clear(); + } + + void _upgradeInput(ServerData oldServer, ServerData server) { + final oldName = oldServer?.name; + final index = indexOf(oldName); + if (server == null || index == -1 || !_servers[index].upgrade(server)) return; + if (oldName != server.name) { + _indices.remove(oldName); + _rebuildIndex(start: index, length: index + 1); + } + _servers[index].saveServerData(index); + } + + bool _addInput(ServerData server) { + if (server.name != '' && !contains(server.name)) { + _add(server); + _saveAll(start: _servers.length - 1); + notifyListeners(); + return true; + } + return false; + } + + void _addAlwaysInput(ServerData server) { + if (!_addInput(server)) { + server.name = _newUniqueName(server.name); + _addInput(server); + } + } + + void _add(ServerData server, {bool rebuild = true}) { + _servers.add(server); + if (rebuild) _rebuildIndex(start: _servers.length - 1); + } + + void _removeInput(ServerData server) { + final name = server?.name; + final index = indexOf(name); + if (index > -1 && index < _servers.length) { + _removeByIndex(index); + if (_servers.isNotEmpty) { + removeServerData(_servers.length).then((_) => _saveAll(start: index)); + } else + _removeAll(length: 1); + notifyListeners(); + } + } + + void _rebuildIndex({int start = 0, length}) { + length ??= _servers.length; + for (int i = start; i < length; i++) _indices[_servers[i].name] = i; + assert(_indices.length == _servers.length); + } + + void _removeByIndex(int index, {bool rebuild = true}) { + _indices.remove(_servers[index].name); + _servers.removeAt(index); + if (rebuild) _rebuildIndex(start: index); + } + + bool _insetIn(int index, ServerData server) { + if (!contains(server.name) && server.name != '') { + _insert(index, server); + _saveAll(start: index); + notifyListeners(); + return true; + } + return false; + } + + void _insertAlwaysInput(int index, ServerData server) { + if (!_insetIn(index, server)) { + server.name = _newUniqueName(server.name); + _insetIn(index, server); + } + } + + String _newUniqueName(String oldName) { + for (int p = 1; p < 9999999; p++) { + final newName = '$oldName-$p'; + if (!contains(newName)) return newName; + } + throw new Exception('NEVER!'); + } + + void _insert(int index, ServerData server, {bool rebuild = true}) { + _servers.insert(index, server); + if (rebuild) _rebuildIndex(start: index); + } + + void _relocationInput(int oldIndex, newIndex) { + newIndex = newIndex >= _servers.length ? _servers.length - 1 : newIndex; + if (_servers.length < 2 || oldIndex >= _servers.length || oldIndex < 0 || newIndex < 0 || oldIndex == newIndex) + return; + final item = _servers[oldIndex]; + // Не перестраиваем индексы, сделаем это потом + _removeByIndex(oldIndex, rebuild: false); + if (newIndex >= _servers.length) + _add(item, rebuild: false); + else + _insert(newIndex, item, rebuild: false); + + int start = newIndex, length = oldIndex + 1; + if (newIndex > oldIndex) { + start = oldIndex; + length = newIndex + 1; + } + debugPrint('* start=$start, length=$length'); + _rebuildIndex(start: start, length: length); + _saveAll(start: start, length: length); + notifyListeners(); + } + + _saveAll({int start = 0, length}) async { + final p = await SharedPreferences.getInstance(); + if (length == null) { + length = _servers.length; + p.setInt(serverDataCount, length); + } + for (int i = start; i < length; i++) await _servers[i].saveServerData(i); + debugPrint(' * _saveAll ${length - start}'); + } + + _loadAll() async { + final p = await SharedPreferences.getInstance(); + final length = p.getInt(serverDataCount) ?? 0; + for (int i = 0; i < length; i++) { + ServerData value = await loadServerData(i); + if (value != null && !contains(value.name)) { + _servers.add(value); + _indices[value.name] = _servers.length - 1; + } else + debugPrint(' **** LoadAll error on load $i'); + } + if (_servers.length != length) _saveAll(); + isLoaded = true; + notifyListeners(); + debugPrint(' * LoadAll $length'); + } + + _removeAll({int start = 0, length}) async { + final p = await SharedPreferences.getInstance(); + length ??= _servers.length; + final newLength = _servers.length - (length - start); + for (int i = start; i < length; i++) removeServerData(i); + if (newLength > 0) + await p.setInt(serverDataCount, newLength); + else + await p.remove(serverDataCount); + debugPrint(' * _removeAll ${length - start}, new $newLength'); + } +} diff --git a/lib/src/settings/log_style.dart b/lib/src/settings/log_style.dart new file mode 100644 index 0000000..425e105 --- /dev/null +++ b/lib/src/settings/log_style.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/terminal/log.dart'; +import 'package:mdmt2_config/src/utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const timeFormats = { + 'Full': 'yyyy.MM.dd HH:mm:ss.SSS', + 'Compact': 'yy.MM.dd HH:mm:ss', + 'Minimal': 'HH:mm:ss', + 'None': null, +}; + +class _Keys { + static const lvl = 'style_lvl'; + static const fontSize = 'style_fontSize'; + static const timeFormat = 'style_timeFormat'; + static const callLvl = 'style_callLvl'; +} + +class LogStyle extends ChangeValueNotifier { + bool _noNotify = false; + LogLevel _lvl = LogLevel.debug; + String _timeFormat = 'Compact'; + int _callLvl = 3; + TextStyle _base = TextStyle(color: Colors.white, fontSize: 14); + + static const Color backgroundColor = Colors.black; + static final msg = { + LogLevel.debug: TextStyle(color: Colors.grey), + LogLevel.info: TextStyle(color: Colors.green), + LogLevel.warn: TextStyle(color: Colors.yellow), + LogLevel.error: TextStyle(color: Colors.red), + LogLevel.critical: TextStyle(color: Colors.purpleAccent), + LogLevel.system: TextStyle(color: Colors.orange[900]), + }; + static final callers = { + 0: TextStyle(color: Colors.cyan, fontWeight: FontWeight.w600), + 1: TextStyle(color: Colors.cyan[800], fontWeight: FontWeight.w300), + 2: TextStyle(color: Colors.cyan[900], fontWeight: FontWeight.w300), + }; + static const TextStyle time = TextStyle(color: Colors.white70); + + LogLevel get lvl => _lvl; + String get timeFormat => _timeFormat; + int get fontSize => _base.fontSize.floor(); + TextStyle get base => _base; + int get callLvl => _callLvl; + + set lvl(LogLevel val) { + if (val == _lvl) return; + _lvl = val; + notifyListeners(); + } + + set timeFormat(String val) { + if (val == _timeFormat) return; + _timeFormat = val; + notifyListeners(); + } + + set fontSize(int size) { + if (size == fontSize) return; + _base = _base.copyWith(fontSize: size.floorToDouble()); + notifyListeners(); + } + + set callLvl(int s) { + if (s != _callLvl && s > -1 && s <= callers.length) { + _callLvl = s; + notifyListeners(); + } + } + + bool upgrade(LogStyle o) { + if (isEqual(o)) return false; + _noNotify = true; + _upgrade(o); + _noNotify = false; + notifyListeners(); + return true; + } + + void _upgrade(LogStyle o) { + lvl = o.lvl; + fontSize = o.fontSize; + timeFormat = o.timeFormat; + callLvl = o.callLvl; + } + + @override + void notifyListeners() { + if (!_noNotify) super.notifyListeners(); + } + + bool isEqual(LogStyle o) => + lvl == o.lvl && fontSize == o.fontSize && timeFormat == o.timeFormat && callLvl == o.callLvl; + + LogStyle clone() => LogStyle().._upgrade(this); + + void loadAll() { + SharedPreferences.getInstance().then((p) { + _noNotify = true; + final _lvl = p.getInt(_Keys.lvl); + if (_lvl != null && _lvl > -1 && _lvl < LogLevel.values.length) lvl = LogLevel.values[_lvl]; + + fontSize = p.getInt(_Keys.fontSize) ?? fontSize; + + final _timeFormat = p.getString(_Keys.timeFormat); + if (timeFormats.containsKey(_timeFormat)) timeFormat = _timeFormat; + + callLvl = p.getInt(_Keys.callLvl) ?? callLvl; + _noNotify = false; + }); + } + + saveAll() async { + await SharedPreferences.getInstance() + ..setInt(_Keys.lvl, lvl.index) + ..setInt(_Keys.fontSize, fontSize) + ..setString(_Keys.timeFormat, timeFormat) + ..setInt(_Keys.callLvl, callLvl); + } +} diff --git a/lib/src/settings/misc_settings.dart b/lib/src/settings/misc_settings.dart new file mode 100644 index 0000000..2a40bd3 --- /dev/null +++ b/lib/src/settings/misc_settings.dart @@ -0,0 +1,52 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class _Keys { + static const openOnRunning = 'ms_oor'; + static const autoReconnectAfterReboot = 'arar'; +} + +class MiscSettings { + // Открывать запущенный сервер + bool _openOnRunning = false; + // Переподключаться после ребута, сек. 0 - отключено. + int _autoReconnectAfterReboot = 10; + static final MiscSettings _instance = MiscSettings._(); + + factory MiscSettings() => _instance; + + MiscSettings._() { + _loadAll(); + } + + get autoReconnectAfterReboot => _autoReconnectAfterReboot; + set autoReconnectAfterReboot(int value) { + if (value != _autoReconnectAfterReboot) { + _autoReconnectAfterReboot = value; + _saveInt(_Keys.autoReconnectAfterReboot, _autoReconnectAfterReboot); + } + } + + get openOnRunning => _openOnRunning; + set openOnRunning(bool value) { + if (value != _openOnRunning) { + _openOnRunning = value; + _saveBool(_Keys.openOnRunning, _openOnRunning); + } + } + + _saveBool(String key, bool value) async { + final p = await SharedPreferences.getInstance(); + p.setBool(key, value); + } + + _saveInt(String key, int value) async { + final p = await SharedPreferences.getInstance(); + p.setInt(key, value); + } + + _loadAll() async { + final p = await SharedPreferences.getInstance(); + _openOnRunning = p.getBool(_Keys.openOnRunning) ?? _openOnRunning; + _autoReconnectAfterReboot = p.getInt(_Keys.autoReconnectAfterReboot) ?? _autoReconnectAfterReboot; + } +} diff --git a/lib/src/settings/theme_settings.dart b/lib/src/settings/theme_settings.dart new file mode 100644 index 0000000..442a8d3 --- /dev/null +++ b/lib/src/settings/theme_settings.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final _lightTheme = ThemeData.light().copyWith( + snackBarTheme: + ThemeData.light().snackBarTheme.copyWith(backgroundColor: ColorScheme.light().onSurface.withOpacity(.6))); + +final _darkTheme = ThemeData.dark().copyWith( + snackBarTheme: + ThemeData.dark().snackBarTheme.copyWith(backgroundColor: ColorScheme.dark().onSurface.withOpacity(.6))); + +ThemeData _buildAmoledTheme() => _darkTheme.copyWith( + toggleableActiveColor: Colors.redAccent, + dividerColor: Colors.deepOrange, + highlightColor: Colors.indigo.withOpacity(0.5), + cardColor: Colors.grey[900], + scaffoldBackgroundColor: Colors.black, + canvasColor: Colors.black, + primaryColor: Colors.black, + accentColor: Colors.redAccent, + dialogBackgroundColor: Colors.grey[900], + backgroundColor: Colors.black, + buttonTheme: _darkTheme.buttonTheme.copyWith(buttonColor: Colors.redAccent, textTheme: ButtonTextTheme.primary), + ); + +class ThemeSettings extends ChangeNotifier { + static const _app_theme = 'app_theme'; + static final _themesMap = { + 'System': null, + 'Light': _lightTheme, + 'Dark': _darkTheme, + 'Amoled': _buildAmoledTheme(), + 'Pink': ThemeData(primarySwatch: Colors.pink), + }; + String _theme = 'Amoled'; + bool isLoaded = false; + + ThemeSettings() { + _loadTheme(); + } + + set theme(String newTheme) { + if (_setTheme(newTheme)) notifyListeners(); + } + + bool _setTheme(String newTheme) { + if (_theme != newTheme && _themesMap.containsKey(newTheme)) { + _theme = newTheme; + _saveTheme(); + return true; + } + return false; + } + + String get theme => _theme ?? 'System'; + ThemeData get lightTheme => _themesMap[_theme] ?? _themesMap['Light']; + ThemeData get darkTheme => _themesMap[_theme] ?? _themesMap['Dark']; + + _loadTheme() async { + SharedPreferences perf = await SharedPreferences.getInstance(); + _setTheme(perf.getString(_app_theme)); + isLoaded = true; + notifyListeners(); + } + + _saveTheme() async { + final p = await SharedPreferences.getInstance(); + p.setString(_app_theme, _theme); + } + + static Iterable get list => _themesMap.keys.toList(); +} diff --git a/lib/src/terminal/instances_controller.dart b/lib/src/terminal/instances_controller.dart new file mode 100644 index 0000000..d25f3af --- /dev/null +++ b/lib/src/terminal/instances_controller.dart @@ -0,0 +1,357 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/settings/log_style.dart'; +import 'package:mdmt2_config/src/settings/misc_settings.dart'; +import 'package:mdmt2_config/src/terminal/log.dart'; +import 'package:mdmt2_config/src/terminal/terminal_client.dart'; +import 'package:mdmt2_config/src/terminal/terminal_control.dart'; +import 'package:mdmt2_config/src/terminal/terminal_logger.dart'; +import 'package:mdmt2_config/src/utils.dart'; + +part 'package:mdmt2_config/src/blocs/instances_controller.dart'; + +class CollectActualServerState { + String subtitle = ''; + int errors = 0; + int counts = 0; + int works = 0; + bool isControlled = false; + bool work = false; + bool isEnabled = false; + bool isPartialLogger = false; + bool isPartialControl = false; + + CollectActualServerState(ServerData server, TerminalInstance terminal) { + isEnabled = server.logger || server.control; + isControlled = terminal != null; + if (!isControlled) return; + String loggerStr = 'disabled', controlStr = loggerStr; + work = terminal.work; + if (terminal.logger != null) { + counts++; + if (terminal.logger.hasCriticalError) errors++; + if (terminal.loggerWork) works++; + loggerStr = terminal.logger.getStage == ConnectStage.logger + ? 'work' + : terminal.logger.getStage.toString().split('.').last; + isPartialLogger = work && !terminal.loggerWork && server.logger; + } + if (terminal.control != null) { + counts++; + if (terminal.control.hasCriticalError) errors++; + if (terminal.controlWork) works++; + controlStr = terminal.control.getStage == ConnectStage.controller + ? 'work' + : terminal.control.getStage.toString().split('.').last; + isPartialControl = work && !terminal.controlWork && server.control; + } + assert(!(isPartialControl && isPartialLogger)); + + final all = errors == 0 ? 'Ok' : errors == counts ? 'Error' : 'Partial'; + subtitle = '[${server.uri}] $all ($works/$counts).\n' + 'Log: $loggerStr. ' + 'Control: $controlStr.'; + } +} + +enum ButtonDisplaySignal { fadeIn, hide, fadeOut } +enum MusicStatus { play, pause, stop, nope, error } + +class InstanceViewState { + static const backIndent = 200; + static const hideButtonAfter = Duration(seconds: 2); + static const fadeOutButtonTime = Duration(milliseconds: 600); + static const fadeInButtonTime = Duration(milliseconds: 200); + Timer hideButtonTimer, fadeOutButtonTimer; + final backButton = ValueNotifier(ButtonDisplaySignal.hide); + + double logScrollPosition = .0; + + final LogStyle style; + + final logExpanded = ValueNotifier(false); + final controlExpanded = ValueNotifier(false); + final pageIndex = ValueNotifier(0); + + // TTS, ask, voice + String modeTAV = 'TTS'; + String textTAV = 'Hello world!'; + + // model + final modelIndex = ValueNotifier(1); + final sampleIndex = ValueNotifier(1); + + //listener OnOff + final listener = ValueNotifier(false); + + final buttons = { + 'talking': ResettableBoolNotifier(Duration(seconds: 60)), + 'record': ResettableBoolNotifier(Duration(seconds: 30)), + 'manual_backup': ResettableBoolNotifier(Duration(seconds: 20)), + 'model_compile': ResettableBoolNotifier(Duration(seconds: 60)), + 'sample_record': ResettableBoolNotifier(Duration(seconds: 30)), + 'terminal_stop': ResettableBoolNotifier(Duration(seconds: 90)), + }; + + final musicStatus = ValueNotifier(MusicStatus.error); + + //volume + final volume = ValueNotifier(-1); + final musicVolume = ValueNotifier(-1); + + // play:uri + String musicURI = ''; + + InstanceViewState(this.style); + + void reset() { + for (var btn in buttons.values) btn.reset(); + } + + void dispose() { + _stopAnimation(); + for (var btn in buttons.values) btn.dispose(); + style.dispose(); + } + + void scrollBack(ScrollController sc) => + sc.animateTo(sc.position.minScrollExtent, duration: Duration(milliseconds: 200), curve: Curves.easeOut); + + void scrollCallback(ScrollController sc) { + if (logScrollPosition == sc.offset) return; + logScrollPosition = sc.offset; + final isVisible = sc.offset > backIndent && sc.position.maxScrollExtent - sc.offset > backIndent; + if (isVisible) { + _stopAnimation(); + hideButtonTimer = Timer(hideButtonAfter, () { + backButton.value = ButtonDisplaySignal.fadeOut; + fadeOutButtonTimer = Timer(fadeOutButtonTime, () => backButton.value = ButtonDisplaySignal.hide); + }); + } else if (backButton.value == ButtonDisplaySignal.fadeOut) { + _stopAnimation(); + fadeOutButtonTimer = Timer(fadeOutButtonTime, () => backButton.value = ButtonDisplaySignal.hide); + } + backButton.value = isVisible ? ButtonDisplaySignal.fadeIn : ButtonDisplaySignal.fadeOut; + } + + _stopAnimation() { + fadeOutButtonTimer?.cancel(); + hideButtonTimer?.cancel(); + } +} + +class Reconnect { + final isActive = ValueNotifier(false); + Duration _duration; + Timer _timer; + final Function() callback; + Reconnect(this.callback); + + void close() { + isActive.value = false; + _timer?.cancel(); + } + + void activate() { + _timer?.cancel(); + _duration = null; + final delay = MiscSettings().autoReconnectAfterReboot; + if (callback != null && delay > 0) { + debugPrint('RECONNECT activate'); + _duration = Duration(seconds: delay); + isActive.value = true; + } + } + + void start() { + if (_duration != null) { + debugPrint('RECONNECT start'); + _timer = Timer(_duration, _onActivate); + _duration = null; + } + } + + _onActivate() { + debugPrint('RECONNECT complit'); + isActive.value = false; + _timer = null; + callback(); + } +} + +class TerminalInstance { + TerminalLogger logger; + TerminalControl control; + Log log; + InstanceViewState view; + Reconnect reconnect; + + TerminalInstance(this.logger, this.control, this.log, this.view, this.reconnect); + + void close() { + reconnect.close(); + logger?.sendClose(); + control?.sendClose(); + } + + void dispose() { + reconnect.close(); + logger?.dispose(); + control?.dispose(); + log?.dispose(); + view?.dispose(); + } + + bool get loggerWork => logger != null && logger.getStage != ConnectStage.wait; + bool get controlWork => control != null && control.getStage != ConnectStage.wait; + bool get work => loggerWork || controlWork; +} + +class InstancesState { + int counts = 0, active = 0, closing = 0; +} + +class InstancesController extends _BLoC { + final style = LogStyle()..loadAll(); + final _state = InstancesState(); + final _instances = {}; + final StreamController _startStopChange = StreamController.broadcast(); + final StreamController _stateStream = StreamController.broadcast(); + + InstancesController() { + _startStopChange.stream.listen((event) { + event.server.states.notifyListeners(); + if (event.signal == WorkingStatChange.connecting) + _state.active++; + else if (event.signal == WorkingStatChange.closing) + _state.closing++; + else if (event.signal == WorkingStatChange.disconnected || + event.signal == WorkingStatChange.disconnectedOnError) { + _state.active--; + _state.closing--; + if (_instances[event.server]?.work == false) _instances[event.server]?.reconnect?.start(); + } else + return; + _sendState(); + }); + } + + void dispose() { + _startStopChange.close(); + _stateStream.close(); + _clearAllInput(); + style.dispose(); + super.dispose(); + } + + void _sendState() { + _stateStream.add(_state); + } + + TerminalInstance operator [](ServerData s) => _instances[s]; + bool contains(ServerData server) => _instances.containsKey(server); + Stream get stateStream => _stateStream.stream; + + void _clearAllInput() { + for (var server in _instances.keys.toList()) _clearInput(server); + } + + void _stopAllInput() { + for (var server in _instances.keys) _stopInput(server); + } + + bool _removeInput(ServerData server, {bool callDispose = true}) { + if (!_stopInput(server)) return false; + final inst = _instances.remove(server); + final diff = (inst.logger != null ? 1 : 0) + (inst.control != null ? 1 : 0); + _state.counts -= diff; + if (callDispose) { + if (diff > 0) _sendState(); + inst.dispose(); + } + server.states.notifyListeners(); + return true; + } + + bool _stopInput(ServerData server) { + if (!_instances.containsKey(server)) return false; + _instances[server].close(); + return true; + } + + void _clearInput(ServerData server) { + if (!_instances.containsKey(server) || _instances[server].work) return; + _removeInput(server); + } + + void _runInput(ServerData server, {returnInstanceCallback result}) { + if (_instances.containsKey(server)) + _upgradeInstance(server); + else + _makeInstance(server); + final instance = _instances[server]; + _runInstance(instance?.logger); + _runInstance(instance?.control); + + if (instance != null) result(instance); + } + + void _runInstance(TerminalClient inst) { + if (inst?.getStage == ConnectStage.wait) { + inst.sendRun(); + } + } + + _makeInstance(ServerData server, {TerminalInstance instance}) { + instance ??= TerminalInstance(null, null, null, InstanceViewState(style.clone()), Reconnect(() => run(server))); + int incCounts = 0; + if (server.logger) { + instance.log ??= Log(instance.view.style, server.states); + if (instance.logger == null) + instance.logger = TerminalLogger(server, _startStopChange, instance.log); + else + instance.logger.setLog = instance.log; + incCounts++; + } else { + instance.logger?.dispose(); + instance.logger = null; + instance.log?.dispose(); + instance.log = null; + } + if (server.control) { + if (instance.control == null) + instance.control = TerminalControl(server, _startStopChange, instance.log, instance.view, instance.reconnect); + else + instance.control.setLog = instance.log; + incCounts++; + } else { + instance.control?.dispose(); + instance.control = null; + } + + if ((instance.logger ?? instance.control) != null) { + _instances[server] = instance; + server.states.notifyListeners(); + _state.counts += incCounts; + if (incCounts > 0) _sendState(); + server.states.notifyListeners(); + } else + instance.dispose(); + } + + void _upgradeInstance(ServerData server) { + final instance = _instances[server]; + instance.reconnect.close(); + if (instance.work) return debugPrint(' ***Still running ${server.name}'); + if (((instance.logger != null) != server.logger || (instance.control != null) != server.control) && + _removeInput(server, callDispose: false)) { + debugPrint(' ***Re-make ${server.name}'); + _makeInstance(server, instance: instance); + } else + debugPrint(' ***Nope ${server.name}'); + } +} + +typedef returnInstanceCallback = void Function(TerminalInstance instance); diff --git a/lib/src/terminal/log.dart b/lib/src/terminal/log.dart new file mode 100644 index 0000000..321987f --- /dev/null +++ b/lib/src/terminal/log.dart @@ -0,0 +1,103 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/settings/log_style.dart'; + +enum LogLevel { debug, info, warn, error, critical, system } + +final _logLvlMap = { + 'DEBUG': LogLevel.debug, + 'INFO ': LogLevel.info, + 'WARN ': LogLevel.warn, + 'ERROR': LogLevel.error, + 'CRIT ': LogLevel.critical, + 'REMOTE': LogLevel.system, +}; + +class LogLine { + final List callers; + final String msg; + final LogLevel lvl; + final DateTime time; + LogLine(this.callers, this.msg, this.lvl, this.time); + + LogLine.fromJson(Map json) + : callers = List.from(json['callers']), + msg = json['msg'], + lvl = _logLvlMap[json['lvl']], + time = timeToDateTime(json['time']); + + static DateTime timeToDateTime(double time) => DateTime.fromMillisecondsSinceEpoch((time * 1000).toInt()); +} + +class Log { + final ServerDataStates _serverStates; + final LogStyle _style; + static const maxLength = 1000; + final _log = ListQueue(); + final _actualLog = ListQueue(); + + LogLevel _lvl; + + Log(this._style, this._serverStates) { + _lvl = _style.lvl; + _style.addListener(_setLvl); + } + + void dispose() { + debugPrint('DISPOSE LOG'); + _style.removeListener(_setLvl); + _serverStates.messagesRead(); + } + + LogLine operator [](int index) => _actualLog.elementAt(index); + int get length => _actualLog.length; + //LogLevel get lvl => _lvl; + bool get isNotEmpty => _actualLog.length > 0; + + void clear() { + if (!isNotEmpty) return; + _log.clear(); + _actualLog.clear(); + _serverStates.messagesClear(); + } + + _setLvl() { + if (_style.lvl != _lvl) { + _lvl = _style.lvl; + _rebuildActual(); + } + } + + bool _addActual(LogLine line) { + if (line.lvl.index < _lvl.index) return false; + if (_actualLog.length >= maxLength) _actualLog.removeLast(); + _actualLog.addFirst(line); + _serverStates.messagesNew(); + return true; + } + + bool _add(LogLine line) { + if (_log.length >= maxLength) _log.removeLast(); + _log.addFirst(line); + return _addActual(line); + } + + bool addFromJson(dynamic line) { + LogLine logLine; + try { + logLine = LogLine.fromJson(jsonDecode(line)); + } catch (e) { + logLine = LogLine(null, 'Recive broken logline "$line": $e', LogLevel.system, DateTime.now()); + } + return _add(logLine); + } + + bool addSystem(String msg, {List callers}) => _add(LogLine(callers, msg, LogLevel.system, DateTime.now())); + + _rebuildActual() => _actualLog + ..clear() + ..addAll(_log.where((element) => element.lvl.index >= _lvl.index)); +} diff --git a/lib/src/terminal/terminal_client.dart b/lib/src/terminal/terminal_client.dart new file mode 100644 index 0000000..eb205a4 --- /dev/null +++ b/lib/src/terminal/terminal_client.dart @@ -0,0 +1,497 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/material.dart'; +import 'package:mdmt2_config/src/servers/server_data.dart'; +import 'package:mdmt2_config/src/terminal/log.dart'; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/status.dart' as status; + +const mAuthorization = 'authorization'; +const mAuthorizationTOTP = 'authorization.totp'; +const mDuplex = 'upgrade duplex'; + +const DEEP_DEBUG = false; + +enum ConnectStage { wait, blocked, connected, sendAuth, sendDuplex, logger, controller, happy, closing } + +enum WorkingStatChange { connecting, connected, closing, disconnected, disconnectedOnError } + +enum WorkingMode { logger, controller } + +class WorkingNotification { + final ServerData server; + final WorkingStatChange signal; + WorkingNotification(this.server, this.signal); +} + +class AsyncRequest { + final String method; + final AsyncResponseHandler handler; + final int timestamp; + AsyncRequest(this.method, this.handler) : timestamp = DateTime.now().millisecondsSinceEpoch; +} + +class InternalCommand { + final String cmd; + final dynamic data; + InternalCommand(this.cmd, this.data); +} + +class Error { + final int code; + final String message; + + Error(this.code, this.message); + + Error.fromJson(Map json) + : code = json['code'], + message = json['message']; + + Map toJson() => { + 'code': code, + 'message': message, + }; + + @override + String toString() => '{code: $code, message: "$message"}'; +} + +class Result { + final bool isMissing; + T value; + + Result(this.value, {this.isMissing = false}); + + @override + String toString() => '${isMissing ? "missing" : value}'; +} + +class Response { + final Result result; + final Error error; + final String id; + + Response(this.result, this.error, this.id); + + Response.fromJson(Map json) + : result = Result(json['result'], isMissing: !json.containsKey('result')), + error = json.containsKey('error') ? Error.fromJson(json['error']) : null, + id = json['id']; + + @override + String toString() => '{id: "$id",${result.isMissing ? '' : ' result: "$result",'}' + '${error == null ? '' : ' error: "$error'}'; +} + +class Request { + final String method; + final dynamic params; + final String id; + + Request(this.method, {this.params, this.id}); + + Map toJson() => { + 'method': method, + if (params is List || params is Map) 'params': params, + if (id != null) 'id': id + }; + Request.fromJson(Map json) + : method = json['method'], + params = json['params'], + id = json['id']; + + @override + String toString() { + return '{method: "$method", params: "$params", id: "$id"}'; + } +} + +enum WorkSignalsType { close, run, selfClose } + +class WorkSignals { + final WorkSignalsType type; + final dynamic error; + // У сокета нельзя узнать статус, когда он умирает то подвисает при закрытии + // тогда будем закрывать его немного иначе. + final isDead; + WorkSignals(this.type, this.error, this.isDead); +} + +class AsyncResponseHandler { + final void Function(String method, Response response) handler; + final void Function(String method, Error error) errorHandler; + AsyncResponseHandler(this.handler, this.errorHandler); +} + +abstract class TerminalClient { + static const TIMEOUT = 30; + final StreamController _workerNotifyGlobal; + final StreamController _workerNotifyLocal = StreamController.broadcast(); + final StreamController _workSignal = StreamController(); + final ServerData server; + final WorkingMode mode; + @protected + Log log; + final String name; + IOWebSocketChannel _channel; + StreamSubscription _listener; + @protected + ConnectStage stage = ConnectStage.wait; + bool hasCriticalError = false; + set setLog(Log newLog) => log = newLog; + ConnectStage get getStage => stage; + + // макс пул отправленных + static const maxAsyncRequestsPool = 100; + // Для отправленных запросов, обработаем когда получим. + final _asyncRequests = {}; + // Тут будут обработчики + final _asyncResponseHandlers = {}; + final _requestHandlers = {}; + // Регистрация обработчика, Response и Error всегда корректны. + @protected + void addResponseHandler(String method, + {void Function(String method, Response response) handler, + void Function(String method, Error error) errorHandler}) => + _asyncResponseHandlers[method] = AsyncResponseHandler(handler, errorHandler); + // Для входящих запросов (уведомления) + @protected + void addRequestHandler(String method, void Function(Request request) handler) => _requestHandlers[method] = handler; + + TerminalClient(this.server, this.mode, this._workerNotifyGlobal, {this.log, this.name}) { + _workSignal.stream.listen((event) { + if (event.type == WorkSignalsType.close || event.type == WorkSignalsType.selfClose) { + // уже закрыли или закрываем + if (isClosing) + return; + else { + stage = ConnectStage.closing; + hasCriticalError = event.error != null; + _close(event, event.isDead); + } + } else if (event.type == WorkSignalsType.run) + // Уже работает или запускается + if (!isClosing) + return; + else { + stage = ConnectStage.blocked; + _run(); + } + }); + void _criticalError(String method, Error error) => + sendSelfClose(error: '$method error ${error.code}: ${error.message}'); + void _authHandler(String method, Response response) { + if (stage == ConnectStage.sendAuth) { + pPrint('$method SUCCESS: ${response.result}'); + _sendPostAuth(); + } else { + final msg = '$method error: unexpected message in $stage: $response'; + sendSelfClose(error: msg); + } + } + + addResponseHandler(mAuthorization, handler: _authHandler, errorHandler: _criticalError); + addResponseHandler(mAuthorizationTOTP, handler: _authHandler, errorHandler: _criticalError); + addResponseHandler(mDuplex, handler: (method, response) { + if (stage == ConnectStage.sendDuplex) { + stage = ConnectStage.controller; + pPrint('Controller online: ${response.result}'); + _sendWorkNotify(WorkingStatChange.connected); + onOk(); + } else { + final msg = '$method error: unexpected message in $stage: $response'; + sendSelfClose(error: msg); + } + }, errorHandler: _criticalError); + } + + StreamSubscription workerNotifyListen(void onData(WorkingStatChange event), + {Function onError, void onDone(), bool cancelOnError}) => + _workerNotifyLocal.stream.listen(onData, onError: onError, onDone: onDone, cancelOnError: cancelOnError); + + // Закрывем сокет. Можно передать ошибку, тогда сокет будет закрыт с ошибкой. + sendClose({dynamic error, bool isDead = false}) => _workSignal.add(WorkSignals(WorkSignalsType.close, error, isDead)); + @protected + sendSelfClose({dynamic error, bool isDead = false}) => + _workSignal.add(WorkSignals(WorkSignalsType.selfClose, error, isDead)); + // Запускаем сокет, если он еще не запущен + sendRun() => _workSignal.add(WorkSignals(WorkSignalsType.run, null, false)); + + _run() { + hasCriticalError = false; + _sendWorkNotify(WorkingStatChange.connecting); + toSysLog('Start connecting to ${server.uri}...'); + try { + _channel = IOWebSocketChannel.connect( + 'ws://${server.uri}', + ); + } catch (e) { + stage = ConnectStage.happy; + sendSelfClose(error: e, isDead: true); + toSysLog('Internal Error! $e'); + return; + } + stage = ConnectStage.connected; + _listener = _channel.stream.listen((dynamic message) { + _parse(message); + pPrint('recived $message'); + }, onDone: () { + sendSelfClose(); + pPrint('ws channel closed'); + }, onError: (error) { + sendSelfClose(error: error, isDead: true); + pPrint('ws error $error'); + }, cancelOnError: true // cancelOnError + ); + + if (server.wsToken != '') _send(server.wsToken); + if (server.token != '') { + stage = ConnectStage.sendAuth; + _sendAuth(server.token); + } else { + _sendPostAuth(); + } + } + + _close(WorkSignals event, bool isDead) async { + if (_channel != null) { + final _close = _channel.sink.close; + _channel = null; + _listener?.cancel(); + _sendWorkNotify(WorkingStatChange.closing); + dynamic timeoutError; + try { + await _close(status.normalClosure).timeout(Duration(seconds: isDead ? 1 : TIMEOUT)); + } on TimeoutException catch (e) { + timeoutError = e; + } + onClose(event.error ?? timeoutError, event.type); + _sendWorkNotify((event.error ?? timeoutError) != null + ? WorkingStatChange.disconnectedOnError + : WorkingStatChange.disconnected); + if (event.error != null) { + if (hasCriticalError) timeoutError = null; + toSysLog('Connecting error: ${event.error}'); + } + String msg = 'Connection close'; + if (timeoutError != null) msg = '$msg by timeout(${TerminalClient.TIMEOUT} sec, socket stuck)'; + toSysLog('$msg.'); + } + stage = ConnectStage.wait; + } + + bool get isClosing => stage == ConnectStage.wait || stage == ConnectStage.closing || stage == ConnectStage.blocked; + + void dispose() { + _workSignal.close(); + _workerNotifyLocal.close(); + _channel?.sink?.close(); + debugPrint('DISPOSE $name'); + } + + _send(dynamic data) async { + if (isWork) { + pPrint('send $data'); + _channel.sink.add(data); + } + } + + @protected + callJRPC(String method, {dynamic params, AsyncResponseHandler handler, bool isNotify = false}) { + String id; + if ((_asyncResponseHandlers.containsKey(method) || handler != null) && !isNotify) { + id = _makeRandomId(); + _addAsyncRequest(id, method, handler); + } + _send(jsonEncode(Request(method, params: params, id: id))); + } + + _addAsyncRequest(String id, method, AsyncResponseHandler handler) { + if (_asyncRequests.length > maxAsyncRequestsPool) { + toSysLog('WARNING! AsyncRequestsPool overloaded! Remove half.'); + final pool = _asyncRequests.keys.toList() + ..sort((a, b) => _asyncRequests[a].timestamp - _asyncRequests[b].timestamp); + for (int i = 0; i < (pool.length / 2).ceil(); i++) { + _asyncRequests.remove(pool[i]); + } + } + _asyncRequests[id] = AsyncRequest(method, handler); + } + + bool get isWork => _channel != null && _channel.closeCode == null; + bool get isHandshake => + stage == ConnectStage.sendAuth || stage == ConnectStage.sendDuplex || stage == ConnectStage.connected; + + _sendWorkNotify(WorkingStatChange signal) { + if (_workerNotifyGlobal.hasListener) _workerNotifyGlobal.add(WorkingNotification(server, signal)); + if (_workerNotifyLocal.hasListener) _workerNotifyLocal.add(signal); + } + + _sendAuth(String token) { + if (server.totpSalt) { + final timeTime = DateTime.now().toUtc().millisecondsSinceEpoch / 1000; + callJRPC(mAuthorizationTOTP, params: {'hash': _makeHashWithTOTP(token, timeTime), 'timestamp': timeTime}); + } else + callJRPC(mAuthorization, params: ['${sha512.convert(utf8.encode(token))}']); + } + + _parse(dynamic msg) async { + if (stage == ConnectStage.logger) { + return await onLogger(msg); + } + dynamic result; + try { + result = jsonDecode(msg); + } on FormatException catch (e) { + pPrint('Json parsing error $msg :: $e'); + // Ошибка во время рукопожатия недопустима. + if (isHandshake) { + sendSelfClose(error: 'Handshake error: "$e"'); + } + return; + } + if (!(result is List)) result = [result]; + for (var line in result) { + Map jsonRPC; + Request request; + Response response; + + try { + jsonRPC = line; + } catch (e) { + pPrint('Wrong JSON-RPC "$msg": $e'); + continue; + } + line = null; + + if (jsonRPC.containsKey('method')) { + try { + request = Request.fromJson(jsonRPC); + } catch (e) { + pPrint('Wrong JSON-RPC request "$msg": $e'); + continue; + } + } else { + try { + response = Response.fromJson(jsonRPC); + } catch (e) { + pPrint('Wrong JSON-RPC response "$msg": $e'); + continue; + } + } + jsonRPC = null; + + if (request != null) { + _parseRequest(request); + } else if (!_parseResponse(response)) { + return; + } + } + } + + bool _parseResponse(Response response) { + final asyncRequest = _asyncRequests.remove(response.id); + final handler = asyncRequest?.handler ?? _asyncResponseHandlers[asyncRequest?.method]; + final error = _responseErrorProcessing(response, asyncRequest, handler); + if (error != null) { + pPrint('Recive error id=${response.id}, error: $error'); + if (isHandshake) { + sendSelfClose(error: '$error'); + return false; + } + if (handler?.errorHandler != null) handler.errorHandler(asyncRequest?.method, response.error); + } else { + if (handler?.handler != null) handler.handler(asyncRequest?.method, response); + } + return true; + } + + Error _responseErrorProcessing(Response response, AsyncRequest asyncRequest, AsyncResponseHandler handler) { + String msg; + int code = -999; + if (response.error != null) { + if (!response.result.isMissing) { + msg = 'Wrong JSON-RPC response: error and result present!'; + } else { + msg = response.error.message ?? 'missing'; + code = response.error.code; + } + } else if (response.error == null && response.result.isMissing) { + msg = 'Wrong JSON-RPC response: error and result missing!'; + } else if (response.id == null) { + msg = 'Wrong JSON-RPC response: result is present and id=null'; + } else if (asyncRequest == null) { + msg = 'Unregistered response: "$response"'; + } else if (handler == null) { + msg = 'Unsupported response id=${response.id}: "$response"'; + } + return msg != null ? Error(code, msg) : null; + } + + void _parseRequest(Request request) { + final handler = _requestHandlers[request.method]; + final error = _requestErrorProcessing(request, handler); + if (error != null) + pPrint(error); + else + handler(request); + } + + String _requestErrorProcessing(Request request, void Function(Request) handler) { + String msg; + if (request.method == null || request.method == '') { + msg = 'Wrong JSON-RPC request, method missing: $request'; + } else if (request.params == null) { + msg = 'Wrong JSON-RPC request, params missing: $request'; + } else if (handler == null) { + msg = 'Received unsupported request: $request'; + } + return msg; + } + + _sendPostAuth() { + if (mode == WorkingMode.logger) { + stage = ConnectStage.logger; + callJRPC('remote_log', params: ['json'], isNotify: true); + _sendWorkNotify(WorkingStatChange.connected); + onOk(); + } else if (mode == WorkingMode.controller) { + stage = ConnectStage.sendDuplex; + callJRPC(mDuplex, params: {'notify': false}); + } else { + stage = ConnectStage.happy; + _sendWorkNotify(WorkingStatChange.connected); + } + } + + @protected + pPrint(String msg) { + if (DEEP_DEBUG) debugPrint('* ${DateTime.now().toUtc().millisecondsSinceEpoch} ${server.uri}~$msg'); + } + + // Будет вызываться пока stage == ConnectStage.logger + // Получает сообщения напрямую, без обработки, для логгера + onLogger(dynamic msg); + + // Подключились. + onOk(); + + // Отключились (до отправки сигналов) + onClose(dynamic error, WorkSignalsType type); + + @protected + toSysLog(String msg) => log?.addSystem(msg, callers: ['(=゚・゚=)', name]); +} + +String _makeRandomId() { + final data = '${DateTime.now().microsecondsSinceEpoch}${math.Random().nextDouble()}'; + return '${md5.convert(utf8.encode(data))}'; +} + +String _makeHashWithTOTP(String token, double timeTime, {int interval = 2}) { + int salt = (timeTime.roundToDouble() / interval).truncate(); + return '${sha512.convert(utf8.encode(token + salt.toString()))}'; +} diff --git a/lib/src/terminal/terminal_control.dart b/lib/src/terminal/terminal_control.dart new file mode 100644 index 0000000..657b501 --- /dev/null +++ b/lib/src/terminal/terminal_control.dart @@ -0,0 +1,250 @@ +import 'dart:async'; + +import 'package:mdmt2_config/src/terminal/instances_controller.dart'; +import 'package:mdmt2_config/src/terminal/log.dart'; +import 'package:mdmt2_config/src/terminal/terminal_client.dart'; + +class BackupLine { + final String filename; + final DateTime time; + BackupLine(this.filename, this.time); +} + +enum _ReconnectStage { no, maybe, really } + +class TerminalControl extends TerminalClient { + static const musicStateMap = { + 'pause': MusicStatus.pause, + 'play': MusicStatus.play, + 'stop': MusicStatus.stop, + 'disabled': MusicStatus.nope, + }; + static const allowValueCMD = {'tts', 'ask', 'rec', 'listener', 'volume', 'mvolume', 'backup.restore'}; + static const allowEmptyCMD = { + 'voice', + 'maintenance.reload', + 'maintenance.stop', + 'pause', + 'backup.manual', + 'backup.list' + }; + + final subscribeTo = [ + 'backup', + 'listener', + 'volume', + 'music_volume', + 'music_status', + ]; + + final _seeInToads = StreamController.broadcast(); + final _sendBackupList = StreamController>.broadcast(); + final _externalStreamCMD = StreamController.broadcast(); + final InstanceViewState view; + final Reconnect reconnect; + _ReconnectStage _reconnectStage = _ReconnectStage.no; + + TerminalControl(server, _stopNotifyStream, log, this.view, this.reconnect) + : super(server, WorkingMode.controller, _stopNotifyStream, log: log, name: 'Controller') { + subscribeTo.addAll(view.buttons.keys); + _externalStreamCMD.stream.listen((event) => _externalCMD(event.cmd.toLowerCase(), event.data)); + _addHandlers(); + } + + Stream get streamToads => _seeInToads.stream; + Stream> get streamBackupList => _sendBackupList.stream; + + // Для внешних вызовов + executeMe(String cmd, {dynamic data}) => _externalStreamCMD.add(InternalCommand(cmd, data)); + + @override + void dispose() { + _sendBackupList.close(); + _seeInToads.close(); + _externalStreamCMD.close(); + super.dispose(); + } + + @override + onLogger(dynamic msg) => sendSelfClose(error: 'FIXME onLogger'); + @override + onClose(error, type) { + if (error == null && _reconnectStage == _ReconnectStage.really && type == WorkSignalsType.selfClose) + reconnect.activate(); + _reconnectStage = _ReconnectStage.no; + } + + @override + onOk() { + _reconnectStage = _ReconnectStage.no; + view.reset(); + callJRPC('get', + params: ['listener', 'volume', 'mvolume', 'mstate'], + handler: AsyncResponseHandler((_, response) { + final listener = _getFromMap('listener', response); + view.listener.value = listener ?? view.listener.value; + + view.volume.value = _volumeSanitize(_getFromMap('volume', response)); + view.musicVolume.value = _volumeSanitize(_getFromMap('mvolume', response)); + view.musicStatus.value = _mStateSanitize(_getFromMap('mstate', response)); + }, null)); + + callJRPC('subscribe', + params: subscribeTo, + handler: AsyncResponseHandler((_, response) { + bool state; + try { + state = response.result.value; + } catch (e) { + pPrint('Wrong subscribe result: $e'); + } + if (state) { + for (String cmd in subscribeTo) addRequestHandler('notify.$cmd', _handleNotify); + } + }, null)); + } + + void _callToast(String msg) { + if (_seeInToads.hasListener) _seeInToads.add(msg); + } + + void _externalCMD(String cmd, dynamic data) { + bool wrongData() => data == null || (data is String && data == ''); + dynamic params; + if (cmd == 'ping') { + params = {'time': DateTime.now().microsecondsSinceEpoch}; + } else if (cmd == 'play') { + if (!wrongData()) params = ['$data']; + } else if (allowValueCMD.contains(cmd)) { + if (wrongData()) + return _callToast('What did you want?'); + else + params = ['$data']; + } else if (!allowEmptyCMD.contains(cmd)) { + return _callToast('Unknown command: "$cmd"'); + } + if (stage == ConnectStage.controller) callJRPC(cmd, params: params); + } + + _addHandlers() { + void _error(String method, Error error) => _callToast('"$method" error: $error'); + addResponseHandler('ping', handler: (_, response) { + int time = DateTime.now().microsecondsSinceEpoch; + try { + time = time - response.result.value['time']; + } catch (e) { + final msg = 'Ping parsing error: $e'; + pPrint(msg); + _callToast(msg); + return; + } + _callToast('Ping ${(time / 1000).toStringAsFixed(2)} ms'); + }, errorHandler: _error); + addResponseHandler('rec', errorHandler: _error); + addResponseHandler('backup.list', + handler: (_, response) { + final files = []; + try { + for (Map file in response.result.value) { + final String filename = file['filename']; + final double timestamp = file['timestamp']; + if (filename == null || filename == '') throw 'Empty filename in $file'; + if (timestamp == null) throw 'Empty timestamp in $file'; + files.add(BackupLine(filename, LogLine.timeToDateTime(timestamp))); + } + } catch (e) { + final msg = 'backup.list parsing error: $e'; + _sendBackupList.addError(msg); + pPrint(msg); + return; + } + files.sort((a, b) => b.time.microsecondsSinceEpoch - a.time.microsecondsSinceEpoch); + if (files.isEmpty) { + _sendBackupList.addError('No backups'); + } else { + _sendBackupList.add(files); + } + }, + errorHandler: (_, error) => _sendBackupList.addError('backup.list error: $error')); + addResponseHandler('backup.restore', handler: (_, response) { + _reconnectStage = _ReconnectStage.maybe; + final filename = _getFromMap('filename', response) ?? '.. ambiguous result'; + _callToast('Restoring started from $filename'); + }, errorHandler: _error); + addResponseHandler('maintenance.reload', handler: (_, __) => _reconnectStage = _ReconnectStage.maybe); + } + + _handleNotify(Request request) { + final list = request.method.split('.')..removeAt(0); + final method = list.join('.'); + if (request.id != null) { + pPrint('Wrong notification: $request'); + return; + } + if (view.buttons.containsKey(method)) { + final value = _getFirstArg(request); + if (value != null) { + if (method == 'terminal_stop' && _reconnectStage == _ReconnectStage.maybe) + _reconnectStage = _ReconnectStage.really; + view.buttons[method].value = value; + } + + return; + } + switch (method) { + case 'backup': + final value = (_getFirstArg(request) ?? 'error').toUpperCase(); + _callToast('Backup: $value'); + break; + case 'listener': + view.listener.value = _getFirstArg(request) ?? view.listener.value; + break; + case 'volume': + view.volume.value = _volumeSanitize(_getFirstArg(request)); + break; + case 'music_volume': + view.musicVolume.value = _volumeSanitize(_getFirstArg(request)); + break; + case 'music_status': + view.musicStatus.value = _mStateSanitize(_getFirstArg(request)); + break; + default: + pPrint('Unknown notification $method: $request'); + break; + } + } + + int _volumeSanitize(int volume) { + if (volume == null) return -1; + if (volume < 0) + volume = -1; + else if (volume > 100) volume = 100; + return volume; + } + + MusicStatus _mStateSanitize(String mState) => musicStateMap[mState] ?? MusicStatus.error; + + T _getFromMap(String key, Response response) { + T result; + dynamic error; + try { + result = response.result.value[key]; + } catch (e) { + error = e; + } + if (result == null) pPrint('Error parsing $response: $error'); + return result; + } + + T _getFirstArg(Request request) { + T result; + dynamic error; + try { + result = request.params['args'][0]; + } catch (e) { + error = e; + } + if (result == null) pPrint('Error parsing $request: $error'); + return result; + } +} diff --git a/lib/src/terminal/terminal_logger.dart b/lib/src/terminal/terminal_logger.dart new file mode 100644 index 0000000..588b388 --- /dev/null +++ b/lib/src/terminal/terminal_logger.dart @@ -0,0 +1,13 @@ +import 'package:mdmt2_config/src/terminal/terminal_client.dart'; + +class TerminalLogger extends TerminalClient { + TerminalLogger(server, _stopNotifyStream, log) + : super(server, WorkingMode.logger, _stopNotifyStream, log: log, name: 'Logger'); + + @override + onLogger(dynamic msg) => log.addFromJson(msg); + @override + onOk() {} + @override + onClose(_, __) {} +} diff --git a/lib/src/upnp/finder.dart b/lib/src/upnp/finder.dart new file mode 100644 index 0000000..0c7581b --- /dev/null +++ b/lib/src/upnp/finder.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:upnp/upnp.dart'; +import 'package:mdmt2_config/src/upnp/terminal_info.dart'; + +part 'package:mdmt2_config/src/blocs/finder.dart'; + +class Finder extends _BLoC { + static const ST = 'urn:schemas-upnp-org:service:mdmTerminal2'; + static const searchInterval = Duration(seconds: 15); + static const autoResendInterval = Duration(seconds: 5); + // _search может упасть с эксепшеном который нельзя отловить. + // Если долго нет результата просто остановим поиск + static const mayBeDeadInterval = Duration(seconds: 30); + Timer _periodicResendTimer, _periodicSearchTimer, _mayBeDead; + + StreamSubscription _subscribe; + final _discoverer = DeviceDiscoverer(); + +// final _terminals = [ +// TerminalInfo.direct( +// '1111', '127.0.0.1', 1111, '1', 100, (DateTime.now().millisecondsSinceEpoch / 1000).round() + 1000), +// TerminalInfo.direct( +// '2222', '127.0.0.2', 2222, '2', 200, (DateTime.now().millisecondsSinceEpoch / 1000).round() - 2000), +// TerminalInfo.direct( +// '3333', '127.0.0.3', 3333, '3', 300, (DateTime.now().millisecondsSinceEpoch / 1000).round() - 500), +// ]; + + final _terminals = []; + + void _startInput() async { + _work = true; + _subscribe = _discoverer.clients.listen((event) { + _startMayBeDead(); + final timestamp = DateTime.now(); + event.getDevice().then((value) { + final info = TerminalInfo(event, value, timestamp: timestamp); + if (info != null) _newTerminalInfo(info); + }).catchError((e) => debugPrint('*** getDevice error: $e')); + }); + try { + await _discoverer.start(); + } catch (e) { + stop(error: e); + return; + } + _startMayBeDead(); + _search(); + _periodicSearchTimer = Timer.periodic(searchInterval, (_) => _search()); + _startPeriodicSend(); + } + + void _startPeriodicSend() => _periodicResendTimer = Timer.periodic(autoResendInterval, (_) => send()); + + void _startMayBeDead() { + _mayBeDead?.cancel(); + _mayBeDead = Timer(mayBeDeadInterval, stop); + } + + void _newTerminalInfo(TerminalInfo info) { + //debugPrint('New info: $info'); + bool resend = true; + final int index = _terminals.indexOf(info); + if (index > -1) { + resend = _terminals[index].upgrade(info); + } else { + _terminals.add(info); + } + if (resend) { + _periodicResendTimer?.cancel(); + send(); + _startPeriodicSend(); + } + } + + void _stopInput() { + _work = false; + _periodicResendTimer?.cancel(); + _periodicSearchTimer?.cancel(); + _mayBeDead?.cancel(); + _subscribe?.cancel(); + try { + _discoverer.stop(); + } catch (e) { + debugPrint('*** discoverer.stop: $e'); + } + } + + void _sendDataOutput() => + __outputStream.add(_sort ? (_terminals.sublist(0)..sort((a, b) => b.timestampSec - a.timestampSec)) : _terminals); + + _search() { + // FIXME Unhandled Exception: SocketException: Send failed (OS Error: Network is unreachable, errno = 101), address = 0.0.0.0, port = 0 + // не отловить :( + _discoverer.search(ST); + } + + void dispose() { + _stopInput(); + super.dispose(); + } +} diff --git a/lib/src/upnp/terminal_info.dart b/lib/src/upnp/terminal_info.dart new file mode 100644 index 0000000..fb6dcff --- /dev/null +++ b/lib/src/upnp/terminal_info.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +// ignore: implementation_imports +import 'package:upnp/src/utils.dart'; +import 'package:upnp/upnp.dart'; + +class _IpPort { + final String ip; + final int port; + _IpPort(this.ip, this.port); +} + +class TerminalInfo { + static const fullFresh = 2; + static const zeroFresh = 1200; + final String uuid; + String version; + int uptime; + String ip; + int port; + int timestampSec; + TerminalInfo.direct(this.uuid, this.ip, this.port, this.version, this.uptime, this.timestampSec); + + operator ==(other) => other is TerminalInfo && uuid == other.uuid; + bool isEqual(TerminalInfo o) => + version == o.version && uptime == o.uptime && ip == o.ip && port == o.port && timestampSec == o.timestampSec; + + bool upgrade(TerminalInfo o) { + if (isEqual(o)) return false; + version = o.version; + uptime = o.uptime; + ip = o.ip; + port = o.port; + timestampSec = o.timestampSec; + return true; + } + + int get fresh { + int _fresh = (DateTime.now().millisecondsSinceEpoch / 1000).truncate() - timestampSec; + if (_fresh < fullFresh) return 100; + if (_fresh > zeroFresh) return 0; + return 100 - (_fresh * 100 / zeroFresh).round(); + } + + int get hashCode => uuid.hashCode; + + String toString() => '[$ip:$port] version $version, uptime $uptime seconds'; + + static int _uptime(DiscoveredClient client) { + int result = -1; + try { + final list = client.server.split(' '); + result = int.parse(list.elementAt(list.length - 2)) ?? result; + } catch (e) { + debugPrint('*** parse uptime: $e'); + } + return result; + } + + static String _version(deviceNode) { + String result = 'unknown'; + try { + result = XmlUtils.getTextSafe(deviceNode, "modelNumber") ?? result; + } catch (e) { + debugPrint('*** parse version: $e'); + } + return result; + } + + static _IpPort _ipPort(deviceNode) { + _IpPort result; + try { + var service = XmlUtils.getElementByName(XmlUtils.getElementByName(deviceNode, "serviceList"), "service"); + List url = XmlUtils.getTextSafe(service, "URLBase").split(':'); + if (url[0].isNotEmpty) { + result = _IpPort(url[0], int.parse(url[1])); + } + } catch (e) { + debugPrint('*** parse ipPort: $e'); + } + return result; + } + + factory TerminalInfo(DiscoveredClient client, Device device, {DateTime timestamp}) { + timestamp ??= DateTime.now(); + if (device.modelName != 'mdmTerminal2' || device.uuid == null || device.uuid == '') return null; + var deviceNode; + try { + deviceNode = XmlUtils.getElementByName(device.deviceElement, "device"); + } catch (e) { + debugPrint('*** parse device: $e'); + } + if (deviceNode == null) return null; + + final ipPort = _ipPort(deviceNode); + if (ipPort == null) return null; + + return TerminalInfo.direct(device.uuid, ipPort.ip, ipPort.port, _version(deviceNode), _uptime(client), + (timestamp.millisecondsSinceEpoch / 1000).round()); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 0000000..d31cadd --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class ChangeValueNotifier extends ValueNotifier { + ChangeValueNotifier({bool value = false}) : super(value); + + @override + void notifyListeners() => super.notifyListeners(); + +} + +class ResettableBoolNotifier extends ValueNotifier { + final Duration delay; + final bool def; + Timer _timer; + + ResettableBoolNotifier(this.delay, {this.def = false}) : super(def); + + @override + set value(bool newValue) { + if (newValue != def) { + _timer?.cancel(); + _timer = Timer(delay, () { + _timer = null; + super.value = def; + }); + } else if (value == def && _timer != null) { + _timer.cancel(); + _timer = null; + } + super.value = newValue; + } + + void reset() => value = def; + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/lib/src/widgets.dart b/lib/src/widgets.dart new file mode 100644 index 0000000..7200b20 --- /dev/null +++ b/lib/src/widgets.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +Widget switchListTileTap( + {Color activeColor, + EdgeInsetsGeometry contentPadding, + Widget title, + Widget subtitle, + bool value = false, + Function(bool) onChanged}) { + final _value = ValueNotifier(value); + return ValueListenableBuilder( + valueListenable: _value, + builder: (_, value, __) => SwitchListTile( + activeColor: activeColor, + contentPadding: contentPadding, + title: title, + subtitle: subtitle, + value: value, + onChanged: (newVal) { + _value.value = newVal; + if (onChanged != null) onChanged(newVal); + })); +} + +seeOkToast(BuildContext context, String msg, {ScaffoldState scaffold}) { + scaffold ??= Scaffold.of(context); + scaffold.hideCurrentSnackBar(); + scaffold.showSnackBar(SnackBar( + behavior: SnackBarBehavior.floating, + content: Text(msg, textAlign: TextAlign.center), + action: SnackBarAction( + label: 'Ok', + onPressed: () => scaffold.hideCurrentSnackBar(), + ), + )); +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..523a262 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,82 @@ +name: mdmt2_config +description: App for managing mdmTerminal2 + +# 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 + +environment: + sdk: ">=2.3.2 <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: ^0.1.2 + shared_preferences: ^0.5.6 + package_info: ^0.4.0 + validators: ^2.0.0 + intl: ^0.16.1 + web_socket_channel: ^1.1.0 + crypto: ^2.1.3 + provider: ^4.0.4 + upnp: ^2.0.0+1 + url_launcher: ^5.4.2 + font_awesome_flutter: ^8.7.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: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # 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..6b61407 --- /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:mdmt2_config/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + // 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/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..32326aa --- /dev/null +++ b/web/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + mdmt2_config + + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..3fa897f --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "mdmt2_config", + "short_name": "mdmt2_config", + "start_url": ".", + "display": "minimal-ui", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "App for managing mdmTerminal2", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +}