From cba1267c71ce88f2a1ab2d70e6d8b152259a7818 Mon Sep 17 00:00:00 2001 From: aculeasis <42580940+Aculeasis@users.noreply.github.com> Date: Sun, 1 Mar 2020 00:47:38 +0300 Subject: [PATCH] Init --- .gitignore | 115 ++++ .metadata | 10 + LICENSE | 21 + README.md | 26 + android/.gitignore | 7 + android/app/build.gradle | 87 +++ android/app/src/debug/AndroidManifest.xml | 7 + android/app/src/main/AndroidManifest.xml | 48 ++ .../aculeasis/mdmt2_config/MainActivity.kt | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes android/app/src/main/res/values/styles.xml | 18 + android/app/src/profile/AndroidManifest.xml | 7 + android/build.gradle | 31 ++ android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 6 + android/settings.gradle | 15 + ios/.gitignore | 32 ++ ios/Flutter/AppFrameworkInfo.plist | 26 + ios/Flutter/Debug.xcconfig | 1 + ios/Flutter/Release.xcconfig | 1 + ios/Runner.xcodeproj/project.pbxproj | 511 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Runner.xcscheme | 91 ++++ .../contents.xcworkspacedata | 7 + ios/Runner/AppDelegate.h | 6 + ios/Runner/AppDelegate.m | 13 + .../AppIcon.appiconset/Contents.json | 122 +++++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 564 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 1588 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 1025 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 1716 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 1920 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 1283 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 1895 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 2665 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 3831 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 1888 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 3294 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 3612 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + ios/Runner/Base.lproj/LaunchScreen.storyboard | 37 ++ ios/Runner/Base.lproj/Main.storyboard | 26 + ios/Runner/Info.plist | 45 ++ ios/Runner/main.m | 9 + lib/generated_plugin_registrant.dart | 17 + lib/main.dart | 53 ++ lib/src/blocs/finder.dart | 81 +++ lib/src/blocs/instances_controller.dart | 59 ++ lib/src/blocs/servers_controller.dart | 69 +++ lib/src/dialogs.dart | 312 +++++++++++ lib/src/screens/finder_page.dart | 149 +++++ lib/src/screens/home.dart | 359 ++++++++++++ .../screens/running_server/backup_list.dart | 278 ++++++++++ .../screens/running_server/controller.dart | 499 +++++++++++++++++ .../running_server/runhing_server.dart | 418 ++++++++++++++ lib/src/screens/settings.dart | 64 +++ lib/src/servers/server_data.dart | 152 ++++++ lib/src/servers/servers_controller.dart | 191 +++++++ lib/src/settings/log_style.dart | 123 +++++ lib/src/settings/misc_settings.dart | 52 ++ lib/src/settings/theme_settings.dart | 72 +++ lib/src/terminal/instances_controller.dart | 357 ++++++++++++ lib/src/terminal/log.dart | 103 ++++ lib/src/terminal/terminal_client.dart | 497 +++++++++++++++++ lib/src/terminal/terminal_control.dart | 250 +++++++++ lib/src/terminal/terminal_logger.dart | 13 + lib/src/upnp/finder.dart | 103 ++++ lib/src/upnp/terminal_info.dart | 100 ++++ lib/src/utils.dart | 42 ++ lib/src/widgets.dart | 36 ++ pubspec.yaml | 82 +++ test/widget_test.dart | 30 + web/icons/Icon-192.png | Bin 0 -> 5292 bytes web/icons/Icon-512.png | Bin 0 -> 8252 bytes web/index.html | 30 + web/manifest.json | 23 + 88 files changed, 6002 insertions(+) create mode 100644 .gitignore create mode 100644 .metadata create mode 100644 LICENSE create mode 100644 README.md create mode 100644 android/.gitignore create mode 100644 android/app/build.gradle create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/kotlin/com/aculeasis/mdmt2_config/MainActivity.kt create mode 100644 android/app/src/main/res/drawable/launch_background.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values/styles.xml create mode 100644 android/app/src/profile/AndroidManifest.xml create mode 100644 android/build.gradle create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle create mode 100644 ios/.gitignore create mode 100644 ios/Flutter/AppFrameworkInfo.plist create mode 100644 ios/Flutter/Debug.xcconfig create mode 100644 ios/Flutter/Release.xcconfig create mode 100644 ios/Runner.xcodeproj/project.pbxproj create mode 100644 ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 ios/Runner/AppDelegate.h create mode 100644 ios/Runner/AppDelegate.m create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 ios/Runner/Base.lproj/Main.storyboard create mode 100644 ios/Runner/Info.plist create mode 100644 ios/Runner/main.m create mode 100644 lib/generated_plugin_registrant.dart create mode 100644 lib/main.dart create mode 100644 lib/src/blocs/finder.dart create mode 100644 lib/src/blocs/instances_controller.dart create mode 100644 lib/src/blocs/servers_controller.dart create mode 100644 lib/src/dialogs.dart create mode 100644 lib/src/screens/finder_page.dart create mode 100644 lib/src/screens/home.dart create mode 100644 lib/src/screens/running_server/backup_list.dart create mode 100644 lib/src/screens/running_server/controller.dart create mode 100644 lib/src/screens/running_server/runhing_server.dart create mode 100644 lib/src/screens/settings.dart create mode 100644 lib/src/servers/server_data.dart create mode 100644 lib/src/servers/servers_controller.dart create mode 100644 lib/src/settings/log_style.dart create mode 100644 lib/src/settings/misc_settings.dart create mode 100644 lib/src/settings/theme_settings.dart create mode 100644 lib/src/terminal/instances_controller.dart create mode 100644 lib/src/terminal/log.dart create mode 100644 lib/src/terminal/terminal_client.dart create mode 100644 lib/src/terminal/terminal_control.dart create mode 100644 lib/src/terminal/terminal_logger.dart create mode 100644 lib/src/upnp/finder.dart create mode 100644 lib/src/upnp/terminal_info.dart create mode 100644 lib/src/utils.dart create mode 100644 lib/src/widgets.dart create mode 100644 pubspec.yaml create mode 100644 test/widget_test.dart create mode 100644 web/icons/Icon-192.png create mode 100644 web/icons/Icon-512.png create mode 100644 web/index.html create mode 100644 web/manifest.json 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 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f091b6b0bca859a3f474b03065bef75ba58a9e4c GIT binary patch literal 1588 zcmV-42Fv-0P)C1SqPt}wig>|5Crh^=oyX$BK<}M8eLU3e2hGT;=G|!_SP)7zNI6fqUMB=)y zRAZ>eDe#*r`yDAVgB_R*LB*MAc)8(b{g{9McCXW!lq7r(btRoB9!8B-#AI6JMb~YFBEvdsV)`mEQO^&#eRKx@b&x- z5lZm*!WfD8oCLzfHGz#u7sT0^VLMI1MqGxF^v+`4YYnVYgk*=kU?HsSz{v({E3lb9 z>+xILjBN)t6`=g~IBOelGQ(O990@BfXf(DRI5I$qN$0Gkz-FSc$3a+2fX$AedL4u{ z4V+5Ong(9LiGcIKW?_352sR;LtDPmPJXI{YtT=O8=76o9;*n%_m|xo!i>7$IrZ-{l z-x3`7M}qzHsPV@$v#>H-TpjDh2UE$9g6sysUREDy_R(a)>=eHw-WAyfIN z*qb!_hW>G)Tu8nSw9yn#3wFMiLcfc4pY0ek1}8(NqkBR@t4{~oC>ryc-h_ByH(Cg5 z>ao-}771+xE3um9lWAY1FeQFxowa1(!J(;Jg*wrg!=6FdRX+t_<%z&d&?|Bn){>zm zZQj(aA_HeBY&OC^jj*)N`8fa^ePOU72VpInJoI1?`ty#lvlNzs(&MZX+R%2xS~5Kh zX*|AU4QE#~SgPzOXe9>tRj>hjU@c1k5Y_mW*Jp3fI;)1&g3j|zDgC+}2Q_v%YfDax z!?umcN^n}KYQ|a$Lr+51Nf9dkkYFSjZZjkma$0KOj+;aQ&721~t7QUKx61J3(P4P1 zstI~7-wOACnWP4=8oGOwz%vNDqD8w&Q`qcNGGrbbf&0s9L0De{4{mRS?o0MU+nR_! zrvshUau0G^DeMhM_v{5BuLjb#Hh@r23lDAk8oF(C+P0rsBpv85EP>4CVMx#04MOfG z;P%vktHcXwTj~+IE(~px)3*MY77e}p#|c>TD?sMatC0Tu4iKKJ0(X8jxQY*gYtxsC z(zYC$g|@+I+kY;dg_dE>scBf&bP1Nc@Hz<3R)V`=AGkc;8CXqdi=B4l2k|g;2%#m& z*jfX^%b!A8#bI!j9-0Fi0bOXl(-c^AB9|nQaE`*)Hw+o&jS9@7&Gov#HbD~#d{twV zXd^Tr^mWLfFh$@Dr$e;PBEz4(-2q1FF0}c;~B5sA}+Q>TOoP+t>wf)V9Iy=5ruQa;z)y zI9C9*oUga6=hxw6QasLPnee@3^Rr*M{CdaL5=R41nLs(AHk_=Y+A9$2&H(B7!_pURs&8aNw7?`&Z&xY_Ye z)~D5Bog^td-^QbUtkTirdyK^mTHAOuptDflut!#^lnKqU md>ggs(5nOWAqO?umG&QVYK#ibz}*4>0000U6E9hRK9^#O7(mu>ETqrXGsduA8$)?`v2seloOCza43C{NQ$$gAOH**MCn0Q?+L7dl7qnbRdqZ8LSVp1ItDxhxD?t@5_yHg6A8yI zC*%Wgg22K|8E#!~cTNYR~@Y9KepMPrrB8cABapAFa=`H+UGhkXUZV1GnwR1*lPyZ;*K(i~2gp|@bzp8}og7e*#% zEnr|^CWdVV!-4*Y_7rFvlww2Ze+>j*!Z!pQ?2l->4q#nqRu9`ELo6RMS5=br47g_X zRw}P9a7RRYQ%2Vsd0Me{_(EggTnuN6j=-?uFS6j^u69elMypu?t>op*wBx<=Wx8?( ztpe^(fwM6jJX7M-l*k3kEpWOl_Vk3@(_w4oc}4YF4|Rt=2V^XU?#Yz`8(e?aZ@#li0n*=g^qOcVpd-Wbok=@b#Yw zqn8u9a)z>l(1kEaPYZ6hwubN6i<8QHgsu0oE) ziJ(p;Wxm>sf!K+cw>R-(^Y2_bahB+&KI9y^);#0qt}t-$C|Bo71lHi{_+lg#f%RFy z0um=e3$K3i6K{U_4K!EX?F&rExl^W|G8Z8;`5z-k}OGNZ0#WVb$WCpQu-_YsiqKP?BB# vzVHS-CTUF4Ozn5G+mq_~Qqto~ahA+K`|lyv3(-e}00000NkvXXu0mjfd`9t{ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d0ef06e7edb86cdfe0d15b4b0d98334a86163658 GIT binary patch literal 1716 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c8f9ed8f5cee1c98386d13b17e89f719e83555b2 GIT binary patch literal 1895 zcmV-t2blPYP)FQtfgmafE#=YDCq`qUBt#QpG%*H6QHY765~R=q zZ6iudfM}q!Pz#~9JgOi8QJ|DSu?1-*(kSi1K4#~5?#|rh?sS)(-JQqX*}ciXJ56_H zdw=^s_srbAdqxlvGyrgGet#6T7_|j;95sL%MtM;q86vOxKM$f#puR)Bjv9Zvz9-di zXOTSsZkM83)E9PYBXC<$6(|>lNLVBb&&6y{NByFCp%6+^ALR@NCTse_wqvNmSWI-m z!$%KlHFH2omF!>#%1l3LTZg(s7eof$7*xB)ZQ0h?ejh?Ta9fDv59+u#MokW+1t8Zb zgHv%K(u9G^Lv`lh#f3<6!JVTL3(dCpxHbnbA;kKqQyd1~^Xe0VIaYBSWm6nsr;dFj z4;G-RyL?cYgsN1{L4ZFFNa;8)Rv0fM0C(~Tkit94 zz#~A)59?QjD&pAPSEQ)p8gP|DS{ng)j=2ux)_EzzJ773GmQ_Cic%3JJhC0t2cx>|v zJcVusIB!%F90{+}8hG3QU4KNeKmK%T>mN57NnCZ^56=0?&3@!j>a>B43pi{!u z7JyDj7`6d)qVp^R=%j>UIY6f+3`+qzIc!Y_=+uN^3BYV|o+$vGo-j-Wm<10%A=(Yk^beI{t%ld@yhKjq0iNjqN4XMGgQtbKubPM$JWBz}YA65k%dm*awtC^+f;a-x4+ddbH^7iDWGg&N0n#MW{kA|=8iMUiFYvMoDY@sPC#t$55gn6ykUTPAr`a@!(;np824>2xJthS z*ZdmT`g5-`BuJs`0LVhz+D9NNa3<=6m;cQLaF?tCv8)zcRSh66*Z|vXhG@$I%U~2l z?`Q zykI#*+rQ=z6Jm=Bui-SfpDYLA=|vzGE(dYm=OC8XM&MDo7ux4UF1~0J1+i%aCUpRe zt3L_uNyQ*cE(38Uy03H%I*)*Bh=Lb^Xj3?I^Hnbeq72(EOK^Y93CNp*uAA{5Lc=ky zx=~RKa4{iTm{_>_vSCm?$Ej=i6@=m%@VvAITnigVg{&@!7CDgs908761meDK5azA} z4?=NOH|PdvabgJ&fW2{Mo$Q0CcD8Qc84%{JPYt5EiG{MdLIAeX%T=D7NIP4%Hw}p9 zg)==!2Lbp#j{u_}hMiao9=!VSyx0gHbeCS`;q&vzeq|fs`y&^X-lso(Ls@-706qmA z7u*T5PMo_w3{se1t2`zWeO^hOvTsohG_;>J0wVqVe+n)AbQCx)yh9;w+J6?NF5Lmo zecS@ieAKL8%bVd@+-KT{yI|S}O>pYckUFs;ry9Ow$CD@ztz5K-*D$^{i(_1llhSh^ zEkL$}tsQt5>QA^;QgjgIfBDmcOgi5YDyu?t6vSnbp=1+@6D& z5MJ}B8q;bRlVoxasyhcUF1+)o`&3r0colr}QJ3hcSdLu;9;td>kf@Tcn<@9sIx&=m z;AD;SCh95=&p;$r{Xz3iWCO^MX83AGJ(yH&eTXgv|0=34#-&WAmw{)U7OU9!Wz^!7 zZ%jZFi@JR;>Mhi7S>V7wQ176|FdW2m?&`qa(ScO^CFPR80HucLHOTy%5s*HR0^8)i h0WYBP*#0Ks^FNSabJA*5${_#%002ovPDHLkV1oKhTl@e3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a6d6b8609df07bf62e5100a53a01510388bd2b22 GIT binary patch literal 2665 zcmV-v3YPVWP)oFh3q0MFesq&64WThn3$;G69TfjsAv=f2G9}p zgSx99+!YV6qME!>9MD13x)k(+XE7W?_O4LoLb5ND8 zaV{9+P@>42xDfRiYBMSgD$0!vssptcb;&?u9u(LLBKmkZ>RMD=kvD3h`sk6!QYtBa ztlZI#nu$8lJ^q2Z79UTgZe>BU73(Aospiq+?SdMt8lDZ;*?@tyWVZVS_Q7S&*tJaiRlJ z+aSMOmbg3@h5}v;A*c8SbqM3icg-`Cnwl;7Ts%A1RkNIp+Txl-Ckkvg4oxrqGA5ewEgYqwtECD<_3Egu)xGllKt&J8g&+=ac@Jq4-?w6M3b*>w5 z69N3O%=I^6&UL5gZ!}trC7bUj*12xLdkNs~Bz4QdJJ*UDZox2UGR}SNg@lmOvhCc~ z*f_UeXv(=#I#*7>VZx2ObEN~UoGUTl=-@)E;YtCRZ>SVp$p9yG5hEFZ!`wI!spd)n zSk+vK0Vin7FL{7f&6OB%f;SH22dtbcF<|9fi2Fp%q4kxL!b1#l^)8dUwJ zwEf{(wJj@8iYDVnKB`eSU+;ml-t2`@%_)0jDM`+a46xhDbBj2+&Ih>1A>6aky#(-SYyE{R3f#y57wfLs z6w1p~$bp;6!9DX$M+J~S@D6vJAaElETnsX4h9a5tvPhC3L@qB~bOzkL@^z0k_hS{T4PF*TDrgdXp+dzsE? z>V|VR035Pl9n5&-RePFdS{7KAr2vPOqR9=M$vXA1Yy5>w;EsF`;OK{2pkn-kpp9Pw z)r;5JfJKKaT$4qCb{TaXHjb$QA{y0EYy*+b1XI;6Ah- zw13P)xT`>~eFoJC!>{2XL(a_#upp3gaR1#5+L(Jmzp4TBnx{~WHedpJ1ch8JFk~Sw z>F+gN+i+VD?gMXwcIhn8rz`>e>J^TI3E-MW>f}6R-pL}>WMOa0k#jN+`RyUVUC;#D zg|~oS^$6%wpF{^Qr+}X>0PKcr3Fc&>Z>uv@C);pwDs@2bZWhYP!rvGx?_|q{d`t<*XEb#=aOb=N+L@CVBGqImZf&+a zCQEa3$~@#kC);pasdG=f6tuIi0PO-y&tvX%>Mv=oY3U$nD zJ#gMegnQ46pq+3r=;zmgcG+zRc9D~c>z+jo9&D+`E6$LmyFqlmCYw;-Zooma{sR@~ z)_^|YL1&&@|GXo*pivH7k!msl+$Sew3%XJnxajt0K%3M6Bd&YFNy9}tWG^aovK2eX z1aL1%7;KRDrA@eG-Wr6w+;*H_VD~qLiVI`{_;>o)k`{8xa3EJT1O_>#iy_?va0eR? zDV=N%;Zjb%Z2s$@O>w@iqt!I}tLjGk!=p`D23I}N4Be@$(|iSA zf3Ih7b<{zqpDB4WF_5X1(peKe+rASze%u8eKLn#KKXt;UZ+Adf$_TO+vTqshLLJ5c z52HucO=lrNVae5XWOLm!V@n-ObU11!b+DN<$RuU+YsrBq*lYT;?AwJpmNKniF0Q1< zJCo>Q$=v$@&y=sj6{r!Y&y&`0$-I}S!H_~pI&2H8Z1C|BX4VgZ^-! zje3-;x0PBD!M`v*J_)rL^+$<1VJhH*2Fi~aA7s&@_rUHYJ9zD=M%4AFQ`}k8OC$9s XsPq=LnkwKG00000NkvXXu0mjfhAk5^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..75b2d164a5a98e212cca15ea7bf2ab5de5108680 GIT binary patch literal 3831 zcmVjJBgitF5mAp-i>4+KS_oR{|13AP->1TD4=w)g|)JHOx|a2Wk1Va z!k)vP$UcQ#mdj%wNQoaJ!w>jv_6&JPyutpQps?s5dmDQ>`%?Bvj>o<%kYG!YW6H-z zu`g$@mp`;qDR!51QaS}|ZToSuAGcJ7$2HF0z`ln4t!#Yg46>;vGG9N9{V@9z#}6v* zfP?}r6b{*-C*)(S>NECI_E~{QYzN5SXRmVnP<=gzP+_Sp(Aza_hKlZ{C1D&l*(7IKXxQC1Z9#6wx}YrGcn~g%;icdw>T0Rf^w0{ z$_wn1J+C0@!jCV<%Go5LA45e{5gY9PvZp8uM$=1}XDI+9m7!A95L>q>>oe0$nC->i zeexUIvq%Uk<-$>DiDb?!In)lAmtuMWxvWlk`2>4lNuhSsjAf2*2tjT`y;@d}($o)S zn(+W&hJ1p0xy@oxP%AM15->wPLp{H!k)BdBD$toBpJh+crWdsNV)qsHaqLg2_s|Ih z`8E9z{E3sA!}5aKu?T!#enD(wLw?IT?k-yWVHZ8Akz4k5(TZJN^zZgm&zM28sfTD2BYJ|Fde3Xzh;;S` z=GXTnY4Xc)8nYoz6&vF;P7{xRF-{|2Xs5>a5)@BrnQ}I(_x7Cgpx#5&Td^4Q9_FnQ zX5so*;#8-J8#c$OlA&JyPp$LKUhC~-e~Ij!L%uSMu!-VZG7Hx-L{m2DVR2i=GR(_% zCVD!4N`I)&Q5S`?P&fQZ=4#Dgt_v2-DzkT}K(9gF0L(owe-Id$Rc2qZVLqI_M_DyO z9@LC#U28_LU{;wGZ&))}0R2P4MhajKCd^K#D+JJ&JIXZ_p#@+7J9A&P<0kdRujtQ_ zOy>3=C$kgi6$0pW06KaLz!21oOryKM3ZUOWqppndxfH}QpgjEJ`j7Tzn5bk6K&@RA?vl##y z$?V~1E(!wB5rH`>3nc&@)|#<1dN2cMzzm=PGhQ|Yppne(C-Vlt450IXc`J4R0W@I7 zd1e5uW6juvO%ni(WX7BsKx3MLngO7rHO;^R5I~0^nE^9^E_eYLgiR9&KnJ)pBbfno zSVnW$0R+&6jOOsZ82}nJ126+c|%svPo;TeUku<2G7%?$oft zyaO;tVo}(W)VsTUhq^XmFi#2z%-W9a{7mXn{uzivYQ_d6b7VJG{77naW(vHt-uhnY zVN#d!JTqVh(7r-lhtXVU6o})aZbDt_;&wJVGl2FKYFBFpU-#9U)z#(A%=IVnqytR$SY-sO( z($oNE09{D^@OuYPz&w~?9>Fl5`g9u&ecFGhqX=^#fmR=we0CJw+5xna*@oHnkahk+ z9aWeE3v|An+O5%?4fA&$Fgu~H_YmqR!yIU!bFCk4!#pAj%(lI(A5n)n@Id#M)O9Yx zJU9oKy{sRAIV3=5>(s8n{8ryJ!;ho}%pn6hZKTKbqk=&m=f*UnK$zW3YQP*)pw$O* zIfLA^!-bmBl6%d_n$#tP8Zd_(XdA*z*WH|E_yILwjtI~;jK#v-6jMl^?<%Y%`gvpwv&cFb$||^v4D&V=aNy?NGo620jL3VZnA%s zH~I|qPzB~e(;p;b^gJr7Ure#7?8%F0m4vzzPy^^(q4q1OdthF}Fi*RmVZN1OwTsAP zn9CZP`FazX3^kG(KodIZ=Kty8DLTy--UKfa1$6XugS zk%6v$Kmxt6U!YMx0JQ)0qX*{CXwZZk$vEROidEc7=J-1;peNat!vS<3P-FT5po>iE z!l3R+<`#x|+_hw!HjQGV=8!q|76y8L7N8gP3$%0kfush|u0uU^?dKBaeRSBUpOZ0c z62;D&Mdn2}N}xHRFTRI?zRv=>=AjHgH}`2k4WK=#AHB)UFrR-J87GgX*x5fL^W2#d z=(%K8-oZfMO=i{aWRDg=FX}UubM4eotRDcn;OR#{3q=*?3mE3_oJ-~prjhxh%PgQT zyn)Qozaq0@o&|LEgS{Ind4Swsr;b`u185hZPOBLL<`d2%^Yp1?oL)=jnLi;Zo0ZDliTtQ^b5SmfIMe{T==zZkbvn$KTQGlbG8w}s@M3TZnde;1Am46P3juKb zl9GU&3F=q`>j!`?SyH#r@O59%@aMX^rx}Nxe<>NqpUp5=lX1ojGDIR*-D^SDuvCKF z?3$xG(gVUsBERef_YjPFl^rU9EtD{pt z0CXwpN7BN3!8>hajGaTVk-wl=9rxmfWtIhC{mheHgStLi^+Nz12a?4r(fz)?3A%at zMlvQmL<2-R)-@G1wJ0^zQK%mR=r4d{Y3fHp){nWXUL#|CqXl(+v+qDh>FkF9`eWrW zfr^D%LNfOcTNvtx0JXR35J0~Jpi2#P3Q&80w+nqNfc}&G0A~*)lGHKv=^FE+b(37|)zL;KLF>oiGfb(?&1 zV3XRu!Sw>@quKiab%g6jun#oZ%!>V#A%+lNc?q>6+VvyAn=kf_6z^(TZUa4Eelh{{ zqFX-#dY(EV@7l$NE&kv9u9BR8&Ojd#ZGJ6l8_BW}^r?DIS_rU2(XaGOK z225E@kH5Opf+CgD^{y29jD4gHbGf{1MD6ggQ&%>UG4WyPh5q_tb`{@_34B?xfSO*| zZv8!)q;^o-bz`MuxXk*G^}(6)ACb@=Lfs`Hxoh>`Y0NE8QRQ!*p|SH@{r8=%RKd4p z+#Ty^-0kb=-H-O`nAA3_6>2z(D=~Tbs(n8LHxD0`R0_ATFqp-SdY3(bZ3;VUM?J=O zKCNsxsgt@|&nKMC=*+ZqmLHhX1KHbAJs{nGVMs6~TiF%Q)P@>!koa$%oS zjXa=!5>P`vC-a}ln!uH1ooeI&v?=?v7?1n~P(wZ~0>xWxd_Aw;+}9#eULM7M8&E?Y zC-ZLhi3RoM92SXUb-5i-Lmt5_rfjE{6y^+24`y$1lywLyHO!)Boa7438K4#iLe?rh z2O~YGSgFUBH?og*6=r9rme=peP~ah`(8Zt7V)j5!V0KPFf_mebo3z95U8(up$-+EA^9dTRLq>Yl)YMBuch9%=e5B`Vnb>o zt03=kq;k2TgGe4|lGne&zJa~h(UGutjP_zr?a7~#b)@15XNA>Dj(m=gg2Q5V4-$)D|Q9}R#002ovPDHLkV1o7DH3k3x literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c4df70d39da7941ef3f6dcb7f06a192d8dcb308d GIT binary patch literal 1888 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 iW8Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 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" + } + ] +}