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"
+ }
+ ]
+}