Compare commits

..

72 Commits

Author SHA1 Message Date
Junyuan Feng
0533766c1c opt & fix
- update make script (taget mac)
- only set system overlay once
2022-05-19 16:06:12 +08:00
Junyuan Feng
36d7dc7bb2 opt
- optimize TextField
- opt apt pwd req times
- use logger in update.dart
2022-05-15 13:48:57 +08:00
Junyuan Feng
282443a548 migrate flutter3.0 2022-05-12 15:06:48 +08:00
Junyuan Feng
bfe7140a44 fix & opt
- fix docker not found regexp
- use en_US in docker cmd
-
2022-05-12 09:50:28 +08:00
Junyuan Feng
7e8e0e2efc optimized apt experience 2022-05-10 21:49:17 +08:00
Junyuan Feng
49f70fe41a fix update in multi-platform 2022-05-10 18:15:04 +08:00
Junyuan Feng
50762d585c fix & opt
- fix sftp downloaded page bottom path bg color error
- apt/yum add chinese
- urltext add textalign
2022-05-08 09:55:07 +08:00
Junyuan Feng
6f30976ef7 fix & opt
- sftp download unknown spent time
- shareFiles return bool
2022-05-08 09:07:03 +08:00
Junyuan Feng
b0c3cd3834 fix & optimize
- fix downloading page unbond size
- auto dispose isolate
2022-05-07 23:05:31 +08:00
Junyuan Feng
d224ad8cf8 Optimize
- divide mkdir/newfile
2022-05-07 22:36:32 +08:00
Junyuan Feng
b824e06736 New feat
- SFTP download
- open downloaded files in other apps
2022-05-07 22:15:09 +08:00
Junyuan Feng
74a933eb6e set LANG in shell 2022-05-05 16:53:35 +08:00
Junyuan Feng
29e3ee0156 i18n support 2022-05-05 16:07:55 +08:00
Junyuan Feng
f9aa3b1728 Fix scorll error. 2022-05-05 13:31:45 +08:00
Junyuan Feng
521ff998af Empty. 2022-05-04 21:08:03 +08:00
Junyuan Feng
b0165c3313 New splash bg color. 2022-05-04 20:35:56 +08:00
Junyuan Feng
2a0cc5ca30 Update & Fix
- update deps.
- splash support dark bg
- fix parse cpu temp
2022-05-04 19:44:13 +08:00
Junyuan Feng
52b82af52c Fix & Optimize
- fix ping when results is empty
- optimize ping speed
2022-05-03 20:26:00 +08:00
Junyuan Feng
5c59833233 Fix exception caused by LANG > Chinese 2022-05-03 12:06:18 +08:00
Junyuan Feng
9e2d49773f New Ping, use servers to ping 2022-05-03 11:42:30 +08:00
Junyuan Feng
fd1b2fc7b0 auto scroll on apt/yum upgrading. 2022-05-03 09:17:42 +08:00
Junyuan Feng
a06ea82f2c build script will update project.pbxproj versions. 2022-05-03 09:16:06 +08:00
Junyuan Feng
9b7d33369a fix
- docker ps item parse
- parse issue in fish (shell)
- make.dart path
2022-04-29 11:59:27 +08:00
Junyuan Feng
9663e4174d New make.dart 2022-04-15 19:48:43 +08:00
Junyuan Feng
c524db9f59 Improve animation by using SKSL 2022-04-15 19:21:40 +08:00
Junyuan Feng
4274e8bed1 Snippet support import/export. 2022-04-07 20:00:06 +08:00
Junyuan Feng
fa73c4feee Support yum. 2022-04-07 18:56:33 +08:00
Junyuan Feng
00cfd0f88d Remove SFTP download 2022-04-06 13:23:54 +08:00
Junyuan Feng
f8201f9542 SFTP support download. listDIr() support rollback. 2022-04-04 15:42:56 +08:00
Junyuan Feng
f0081e0587 Support APT/Docker 2022-03-10 15:25:14 +08:00
Junyuan Feng
e6e08dc407 Center title in SFTP/Apt 2022-03-08 18:07:52 +08:00
Junyuan Feng
7a5516792c Change SFTP to single column 2022-03-08 18:00:46 +08:00
Junyuan Feng
241002c3ea Support Docker start/stop/remove 2022-03-08 17:40:32 +08:00
Junyuan Feng
34e6b99297 APT/Docker manage
- view apt update
- view docker container
2022-03-08 14:47:57 +08:00
Junyuan Feng
b800bd91fd Fix saving not encypted key 2022-03-07 19:24:13 +08:00
Junyuan Feng
07fbf78853 Fix get mem failed in chinese 2022-03-02 11:14:32 +08:00
Junyuan Feng
bb80f093e0 Merge pull request #1 from RainSunMe/fix-get-mem
fix: can not get chinese mem
2022-03-02 10:56:40 +08:00
RainSun
fa8ffdc656 fix: can not get chinese mem 2022-03-02 10:53:01 +08:00
Junyuan Feng
f07d33a1d6 SFTP init. 2022-02-18 13:32:50 +08:00
Junyuan Feng
282e61afac 50% sftp 2022-02-10 20:27:30 +08:00
Junyuan Feng
feb3b10f1f Improve. 2022-02-09 11:29:56 +08:00
Junyuan Feng
1184841c24 Check drawer state when back. 2022-02-08 21:33:14 +08:00
Junyuan Feng
02a8a38a0a Update screenshots. 2022-02-08 21:21:35 +08:00
Junyuan Feng
d70cbb66d2 New UI. 2022-02-08 21:12:38 +08:00
Junyuan Feng
4636219b6a Update README 2022-02-08 19:18:21 +08:00
Junyuan Feng
b7b8d33bd9 Fix update server info failed. Display temp&uptime 2022-02-08 19:04:55 +08:00
Junyuan Feng
10c874e7d4 Migrate Flutter 2.10.0 2022-02-08 16:53:28 +08:00
Junyuan Feng
5bf5a4b67e Improve 2022-02-08 15:40:26 +08:00
Junyuan Feng
1bd43829bb Improve UI. Rounded Card. 2022-02-04 18:52:35 +08:00
Junyuan Feng
026d203608 Improve efficiency of data fetching 2022-02-02 13:23:54 +08:00
Junyuan Feng
5d9b19407f Auto unfocus to hide IME 2022-01-31 16:26:01 +08:00
Junyuan Feng
86be556a22 Improve 2022-01-19 16:54:08 +08:00
Junyuan Feng
e65d30590a Add Ping. Support launch page setting. 2022-01-18 13:35:00 +08:00
Junyuan Feng
86a700d0bb Optimize layout 2022-01-16 15:17:11 +08:00
Junyuan Feng
db3ec38a0f Change app name 2022-01-11 13:34:15 +08:00
Junyuan Feng
a58119ef54 Optimize load speed 2022-01-11 13:21:52 +08:00
Junyuan Feng
340a7641e6 Solve ssh connect slow 2022-01-11 12:19:17 +08:00
Junyuan Feng
4d227c3f00 Fill macOS data 2022-01-10 17:04:18 +08:00
Junyuan Feng
195e188d0c Fix issue caused by flutter_staggered_animations 2022-01-10 16:00:11 +08:00
Junyuan Feng
692f3bcdbc Isolate SSHKeyPair process to avoid run stuck 2022-01-10 12:10:56 +08:00
Junyuan Feng
516cec5565 change default macos window size 2022-01-09 23:11:51 +08:00
Junyuan Feng
5a706313f5 use 9/16 to fit layout 2022-01-09 22:22:31 +08:00
Junyuan Feng
937f2fb38f fix server tab circle chart size 2022-01-09 21:14:47 +08:00
Junyuan Feng
7e36c06542 Solve launches too slow 2022-01-09 19:21:42 +08:00
Junyuan Feng
317936c521 Change url 2022-01-09 18:18:06 +08:00
Junyuan Feng
a2ed889183 migrate to darssh2 2022-01-09 18:06:39 +08:00
Junyuan Feng
3fc43d06e9 Add padding for bottom little white tile 2022-01-03 15:03:38 +08:00
Junyuan Feng
b922428c40 Fix mem progress display percent. 2022-01-02 19:26:00 +08:00
Junyuan Feng
e08f37fedc Improve.
- make the refresh interval setting makes effect immediately.
- auto stop/restart status update when app goto background/resume
2022-01-02 19:06:26 +08:00
Junyuan Feng
7fb8c88ab8 detail page display memory exact value 2021-12-31 18:51:33 +08:00
Junyuan Feng
f480c49f1f scroll server connection failed info 2021-12-31 17:55:07 +08:00
Junyuan Feng
f7558d6beb can manually refresh when updateInterval==0 2021-12-31 17:44:17 +08:00
170 changed files with 7760 additions and 927 deletions

1
.gitignore vendored
View File

@@ -47,3 +47,4 @@ app.*.map.json
/android/app/fjy.androidstudio.key
/release
test.dart

6
.vscode/launch.json vendored
View File

@@ -13,7 +13,11 @@
"name": "toolbox (profile mode)",
"request": "launch",
"type": "dart",
"flutterMode": "profile"
"flutterMode": "profile",
"args": [
"--cache-sksl",
// "--purge-persistent-cache"
]
}
]
}

View File

@@ -1,56 +1,65 @@
# Server Monitor & Toolbox
A new Flutter project which provide a chart view to display server status data and a manager toolbox.
A new Flutter project which provide a chart view to display server status data.
## Milestone
- [x] Status chart view
- [x] Base64/Url En/Decode
- [x] Ping
- [x] Desktop support
- [x] Apt/Yum manager
- [x] SFTP
- [ ] Snippet market
- [x] Docker manager
- [x] i18n (English, Chinese)
## ScreenShots
<table>
<tr>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/IMG_3327.PNG">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/server.jpg">
</td>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/IMG_3347.PNG">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/server_detail.jpg">
</td>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/detail.jpg">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/server_edit.jpg">
</td>
</tr>
</table>
<table>
<tr>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/IMG_3330.PNG">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/convert.jpg">
</td>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/IMG_3331.PNG">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/ping.jpg">
</td>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/IMG_3346.PNG">
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/setting.jpg">
</td>
</tr>
</table>
<table>
<tr>
<td>
<img width="200px" src="https://raw.githubusercontent.com/LollipopKit/flutter_server_monitor_toolbox/main/screenshots/drawer.jpg">
</td>
</tr>
</table>
## Milestone
- [x] SSH connect
- [x] Server info store
- [x] Status chart view
- [x] Base64/Url En/Decode
- [x] Private key store
- [x] Server status detail page
- [x] Theme switch
- [ ] Execute snippet
- [ ] Migrate from `ssh2` to `dartssh2`
- [ ] Desktop support.
# Support
Status|Platform
--|--|
Full Support|Android/iOS/macOS
Support, but not tested|Windows/Linux
## Build
Please use `make.dart` to build.
```shell
# build android apk
./make.dart build android
# due to pub package 'ssh2' incompatibility
# can't build ios ipa through './make.dart build ios'
# more info: [https://github.com/jda258/flutter_ssh2/issues/8]
# please run below cmd to run on ios device
# build android apk and ios archive
./make.dart build
# Run in release mode
./make.dart run release
```

View File

@@ -22,6 +22,8 @@ linter:
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
library_private_types_in_public_api: false
use_build_context_synchronously: false
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule

View File

@@ -32,7 +32,7 @@ apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion 30
compileSdkVersion 31
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowSplashScreenBackground">#121212</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,7 +5,8 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowFullscreen">true</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowSplashScreenBackground">#ffffff</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -5,7 +5,8 @@
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowFullscreen">true</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your

View File

@@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.3.50'
ext.kotlin_version = '1.6.10'
repositories {
google()
mavenCentral()

1
apk.sksl.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,47 +1,52 @@
PODS:
- countly_flutter (20.11.4):
- countly_flutter (21.11.0):
- Flutter
- Flutter (1.0.0)
- GZ-NMSSH (4.1.5)
- path_provider (0.0.1):
- flutter_native_splash (0.0.1):
- Flutter
- ssh2 (2.2.3):
- path_provider_ios (0.0.1):
- Flutter
- GZ-NMSSH (~> 4.1.5)
- url_launcher (0.0.1):
- r_upgrade (0.0.1):
- Flutter
- share_plus (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- countly_flutter (from `.symlinks/plugins/countly_flutter/ios`)
- Flutter (from `Flutter`)
- path_provider (from `.symlinks/plugins/path_provider/ios`)
- ssh2 (from `.symlinks/plugins/ssh2/ios`)
- url_launcher (from `.symlinks/plugins/url_launcher/ios`)
SPEC REPOS:
trunk:
- GZ-NMSSH
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- r_upgrade (from `.symlinks/plugins/r_upgrade/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
countly_flutter:
:path: ".symlinks/plugins/countly_flutter/ios"
Flutter:
:path: Flutter
path_provider:
:path: ".symlinks/plugins/path_provider/ios"
ssh2:
:path: ".symlinks/plugins/ssh2/ios"
url_launcher:
:path: ".symlinks/plugins/url_launcher/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
r_upgrade:
:path: ".symlinks/plugins/r_upgrade/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
countly_flutter: 38419412e193a1faa5babeb5d28a63fda260687d
countly_flutter: e245f94349d8adf306c22e60c10648c69aae7380
Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a
GZ-NMSSH: d749f8ae2fd0094b953cd1d5abd8e0cab3c93f8d
path_provider: d1e9807085df1f9cc9318206cd649dc0b76be3de
ssh2: 74165efc99417a075ecafd52caf93edadfb5eb60
url_launcher: b6e016d912f04be9f5bf6e8e82dc599b7ba59649
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
r_upgrade: 44d715c61914cce3d01ea225abffe894fd51c114
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

View File

@@ -155,7 +155,7 @@
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1020;
LastUpgradeCheck = 1300;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@@ -354,7 +354,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = 136;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -362,7 +362,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.toolbox;
MARKETING_VERSION = 1.0.136;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -483,7 +484,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = 136;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -491,7 +492,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.toolbox;
MARKETING_VERSION = 1.0.136;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -506,7 +508,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
CURRENT_PROJECT_VERSION = 136;
DEVELOPMENT_TEAM = BA88US33G6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -514,7 +516,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = tech.lolli.toolbox;
MARKETING_VERSION = 1.0.136;
PRODUCT_BUNDLE_IDENTIFIER = com.lollipopkit.toolbox;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1020"
LastUpgradeVersion = "1300"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -5,11 +5,42 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "darkbackground.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "3x"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -11,21 +11,23 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>toolbox</string>
<string>ServerBox</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
@@ -39,7 +41,12 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
</dict>
</plist>

1
ipa.sksl.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/res/build_data.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/home.dart';
@@ -21,16 +25,24 @@ class MyApp extends StatelessWidget {
});
}
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
setTransparentNavigationBar(context);
return ValueListenableBuilder<int>(
valueListenable: locator<SettingStore>().primaryColor.listenable(),
builder: (_, value, __) {
final primaryColor = Color(value);
final textStyle = TextStyle(color: primaryColor);
return MaterialApp(
title: 'ToolBox',
localizationsDelegates: const [
S.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: S.delegate.supportedLocales,
title: BuildData.name,
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: primaryColor,
appBarTheme: AppBarTheme(backgroundColor: primaryColor),

View File

@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:countly_flutter/countly_config.dart';
import 'package:countly_flutter/countly_flutter.dart';
import 'package:logging/logging.dart';
class Analysis {
static const _url = 'https://countly.xuty.cc';
@@ -9,12 +12,18 @@ class Analysis {
static bool _enabled = false;
static Future<void> init(bool debug) async {
_enabled = true;
await Countly.setLoggingEnabled(debug);
await Countly.init(_url, _key);
await Countly.start();
await Countly.enableCrashReporting();
await Countly.giveAllConsent();
if (Platform.isAndroid || Platform.isIOS) {
_enabled = true;
final config = CountlyConfig(_url, _key)
.setLoggingEnabled(debug)
.enableCrashReporting();
await Countly.initWithConfig(config);
await Countly.start();
await Countly.giveAllConsent();
} else {
Logger('COUNTLY')
.info('Unsupported platform ${Platform.operatingSystem}');
}
}
static void recordView(String view) {

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
extension ColorX on Color {
bool get isBrightColor {
return getBrightnessFromColor == Brightness.light;
}
Brightness get getBrightnessFromColor {
return ThemeData.estimateBrightnessForColor(this);
}
}

View File

@@ -0,0 +1,15 @@
extension NumX on num {
String get convertBytes {
const suffix = ['B', 'KB', 'MB', 'GB', 'TB'];
double value = toDouble();
int squareTimes = 0;
for (; value / 1024 > 1 && squareTimes < suffix.length - 1; squareTimes++) {
value /= 1024;
}
var finalValue = value.toStringAsFixed(1);
if (finalValue.endsWith('.0')) {
finalValue = finalValue.replaceFirst('.0', '');
}
return '$finalValue ${suffix[squareTimes]}';
}
}

View File

@@ -1,3 +1,63 @@
import 'package:flutter/material.dart';
import 'package:toolbox/data/model/distribution.dart';
extension StringX on String {
int get i => int.parse(this);
Distribution get dist {
final lower = toLowerCase();
for (var dist in debianDistList) {
if (lower.contains(dist)) {
return Distribution.debian;
}
}
for (var dist in rehlDistList) {
if (lower.contains(dist)) {
return Distribution.rehl;
}
}
return Distribution.unknown;
}
Uri get uri {
return Uri.parse(this);
}
Widget omitStartStr(
{TextStyle? style, TextOverflow? overflow, int? maxLines}) {
return LayoutBuilder(builder: (context, size) {
bool exceeded = false;
int len = 0;
for (; !exceeded && len < length; len++) {
// Build the textspan
var span = TextSpan(
text: 'A' * 7 + substring(length - len),
style: style ?? Theme.of(context).textTheme.bodyText2,
);
// Use a textpainter to determine if it will exceed max lines
var tp = TextPainter(
maxLines: maxLines ?? 1,
textDirection: TextDirection.ltr,
text: span,
);
// trigger it to layout
tp.layout(maxWidth: size.maxWidth);
// whether the text overflowed or not
exceeded = tp.didExceedMaxLines;
}
return Text(
(exceeded ? '...' : '') + substring(length - len),
overflow: overflow ?? TextOverflow.fade,
softWrap: false,
maxLines: maxLines ?? 1,
style: style,
);
});
}
String get withLangExport => 'export LANG=en_US.UTF-8 && $this';
}

View File

@@ -0,0 +1,10 @@
import 'dart:convert';
import 'dart:typed_data';
extension FutureUint8ListX on Future<Uint8List> {
Future<String> get string async => utf8.decode(await this);
}
extension Uint8ListX on Uint8List {
String get string => utf8.decode(this);
}

View File

@@ -1,22 +1,24 @@
// ignore_for_file: avoid_print
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:r_upgrade/r_upgrade.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/provider/app.dart';
import 'package:toolbox/data/res/build_data.dart';
import 'package:toolbox/data/service/app.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
final logger = Logger('UPDATE');
Future<bool> isFileAvailable(String url) async {
try {
final resp = await Dio().head(url);
return resp.statusCode == 200;
} catch (e) {
print('update file not available: $e');
logger.warning('update file not available: $e');
return false;
}
}
@@ -26,28 +28,47 @@ Future<void> doUpdate(BuildContext context, {bool force = false}) async {
locator<AppProvider>().setNewestBuild(update.newest);
if (!force && update.newest <= BuildData.build) {
print('Update ignored due to current: ${BuildData.build}, '
'update: ${update.newest}');
final newest = () {
if (Platform.isAndroid) {
return update.androidbuild;
} else if (Platform.isIOS) {
return update.iosbuild;
} else if (Platform.isMacOS) {
return update.macbuild;
}
return update.newest;
}();
if (!force && newest <= BuildData.build) {
logger.info('Update ignored due to current: ${BuildData.build}, '
'update: $newest');
return;
}
print('Update available: ${update.newest}');
logger.info('Update available: $newest');
if (Platform.isAndroid && !await isFileAvailable(update.android)) {
return;
}
final s = S.of(context);
showSnackBarWithAction(
context,
update.min > BuildData.build
? 'Your version is too old. \nPlease update to v1.0.${update.newest}.'
: 'Update: v1.0.${update.newest} available. \n${update.changelog}',
? 'Your version is too old. \nPlease update to v1.0.$newest.'
: 'Update: v1.0.$newest available. \n${update.changelog}',
'Update', () async {
if (Platform.isAndroid) {
await RUpgrade.upgrade(update.android,
fileName: update.android.split('/').last, isAutoRequestInstall: true);
} else if (Platform.isIOS) {
showSnackBar(context, const Text('Not support iOS now.'));
await RUpgrade.upgradeFromAppStore('1586449703');
} else if (Platform.isMacOS) {
await RUpgrade.upgradeFromUrl(update.mac);
}
showRoundDialog(context, s.attention, Text(s.platformNotSupportUpdate), [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: Text(s.ok))
]);
});
}

View File

@@ -3,9 +3,12 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import 'package:toolbox/core/persistant_store.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/widget/card_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:toolbox/core/extension/stringx.dart';
void unawaited(Future<void> future) {}
@@ -27,10 +30,11 @@ void showSnackBarWithAction(BuildContext context, String content, String action,
}
Future<bool> openUrl(String url) async {
if (!await canLaunch(url)) {
final uri = url.uri;
if (!await canLaunchUrl(uri)) {
return false;
}
final ok = await launch(url, forceSafariVC: false);
final ok = await launchUrl(uri);
if (ok == true) {
return true;
}
@@ -71,12 +75,38 @@ Widget buildSwitch(BuildContext context, StoreProperty<bool> prop,
void setTransparentNavigationBar(BuildContext context) {
if (Platform.isAndroid) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: true,
systemNavigationBarIconBrightness:
isDarkMode(context) ? Brightness.light : Brightness.dark,
));
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
systemNavigationBarColor: Colors.transparent,
systemNavigationBarContrastEnforced: true));
}
}
String tabTitleName(BuildContext context, int i) {
final s = S.of(context);
switch (i) {
case 0:
return s.server;
case 1:
return s.convert;
case 2:
return s.ping;
default:
return '';
}
}
Future<bool> shareFiles(BuildContext context, List<String> filePaths) async {
for (final filePath in filePaths) {
if (!await File(filePath).exists()) {
return false;
}
}
var text = '';
if (filePaths.length == 1) {
text = filePaths.first.split('/').last;
} else {
text = '${filePaths.length} ${S.of(context).files}';
}
await Share.shareFiles(filePaths, text: 'ServerBox -> $text');
return filePaths.isNotEmpty;
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:toolbox/data/res/color.dart';
class DropdownBtnItem {
final String text;
final IconData icon;
const DropdownBtnItem({
required this.text,
required this.icon,
});
Widget get build => Row(
children: [
Icon(icon, color: primaryColor),
const SizedBox(
width: 10,
),
Text(
text,
),
],
);
}
class ServerTabMenuItems {
static const List<DropdownBtnItem> firstItems = [sftp, snippet, apt, docker];
static const List<DropdownBtnItem> secondItems = [edit];
static const sftp =
DropdownBtnItem(text: 'SFTP', icon: Icons.insert_drive_file);
static const snippet = DropdownBtnItem(text: 'Snippet', icon: Icons.label);
static const apt =
DropdownBtnItem(text: 'Apt/Yum', icon: Icons.system_security_update);
static const docker =
DropdownBtnItem(text: 'Docker', icon: Icons.view_agenda);
static const edit = DropdownBtnItem(text: 'Edit', icon: Icons.edit);
}
class DockerMenuItems {
static const rm = DropdownBtnItem(text: 'Remove', icon: Icons.delete);
static const start = DropdownBtnItem(text: 'Start', icon: Icons.play_arrow);
static const stop = DropdownBtnItem(text: 'Stop', icon: Icons.stop);
}

View File

@@ -0,0 +1,8 @@
import 'package:flutter/material.dart';
class NavigationItem {
final IconData icon;
final String title;
NavigationItem(this.icon, this.title);
}

View File

@@ -0,0 +1,38 @@
class PathWithPrefix {
late String _prefixPath;
String _path = '/';
String? _prePath;
String get path => _prefixPath + _path;
PathWithPrefix(String prefixPath) {
if (prefixPath.endsWith('/')) {
_prefixPath = prefixPath.substring(0, prefixPath.length - 1);
} else {
_prefixPath = prefixPath;
}
}
void update(String newPath) {
_prePath = _path;
if (newPath == '..') {
_path = _path.substring(0, _path.lastIndexOf('/'));
if (_path == '') {
_path = '/';
}
return;
}
if (newPath == '/') {
_path = '/';
return;
}
_path = _path + (_path.endsWith('/') ? '' : '/') + newPath;
}
bool undo() {
if (_prePath == null || _path == _prePath) {
return false;
}
_path = _prePath!;
return true;
}
}

View File

@@ -13,8 +13,12 @@ class AppUpdate {
*/
late int newest;
late int iosbuild;
late int androidbuild;
late int macbuild;
late String android;
late String ios;
late String mac;
late int min;
late String changelog;
@@ -27,6 +31,9 @@ class AppUpdate {
});
AppUpdate.fromJson(Map<String, dynamic> json) {
newest = json["newest"]?.toInt();
macbuild = json["macbuild"]?.toInt();
iosbuild = json["iosbuild"]?.toInt();
androidbuild = json["androidbuild"]?.toInt();
android = json["android"].toString();
ios = json["ios"].toString();
min = json["min"].toInt();
@@ -35,6 +42,9 @@ class AppUpdate {
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["newest"] = newest;
data["macbuild"] = macbuild;
data["iosbuild"] = iosbuild;
data["androidbuild"] = androidbuild;
data["android"] = android;
data["ios"] = ios;
data["min"] = min;

View File

@@ -0,0 +1,41 @@
import 'package:toolbox/data/model/distribution.dart';
class UpgradePkgInfo {
final String _raw;
final Distribution _dist;
late String package;
late String nowVersion;
late String newVersion;
late String arch;
UpgradePkgInfo(this._raw, this._dist) {
switch (_dist) {
case Distribution.debian:
case Distribution.unknown:
_parseApt();
break;
case Distribution.rehl:
_parseYum();
}
}
void _parseApt() {
final split1 = _raw.split("/");
package = split1[0];
final split2 = split1[1].split(" ");
newVersion = split2[1];
arch = split2[2];
nowVersion = split2[5].replaceFirst(']', '');
}
void _parseYum() {
final result = RegExp(r'\S+').allMatches(_raw);
final pkgAndArch = result.elementAt(0).group(0) ?? '.';
final split1 = pkgAndArch.split('.');
package = split1[0];
arch = split1[1];
newVersion = result.elementAt(1).group(0) ?? 'Unknown';
nowVersion = '';
}
}

View File

@@ -0,0 +1,21 @@
enum Distribution {
unknown,
debian,
rehl,
}
const debianDistList = [
'debian',
'ubuntu',
'linuxmint',
'elementary',
'raspbian'
];
const rehlDistList = [
'redhat',
'fedora',
'centos',
'scientificlinux',
'rhel',
'oraclelinux'
];

View File

@@ -0,0 +1,37 @@
class DockerPsItem {
late String containerId;
late String image;
late String command;
late String created;
late String status;
late String ports;
late String name;
DockerPsItem(this.containerId, this.image, this.command, this.created,
this.status, this.ports, this.name);
DockerPsItem.fromRawString(String rawString) {
List<String> parts = rawString.split(RegExp(' +'));
parts = parts.map((e) => e.trim()).toList();
containerId = parts[0];
image = parts[1];
command = parts[2].trim();
created = parts[3];
status = parts[4];
if (running && parts.length > 6) {
ports = parts[5];
name = parts[6];
} else {
ports = '';
name = parts[5];
}
}
bool get running => status.contains('Up ');
@override
String toString() {
return 'DockerPsItem<$containerId@$name>';
}
}

View File

@@ -14,8 +14,10 @@ class Cpu2Status {
return used.isNaN ? 0 : 100 - used * 100;
}
Cpu2Status update(List<CpuStatus> newStatus, String newTemp) {
return Cpu2Status(now, newStatus, newTemp);
void update(List<CpuStatus> newStatus, String newTemp) {
pre = now;
now = newStatus;
temp = newTemp;
}
int get coresCount => now.length;

View File

@@ -1,4 +1,4 @@
import 'dart:math';
import 'package:toolbox/core/extension/numx.dart';
class NetSpeedPart {
String device;
@@ -21,51 +21,40 @@ class NetSpeed {
return devices;
}
NetSpeed update(List<NetSpeedPart> newOne) => NetSpeed(now, newOne);
void update(List<NetSpeedPart> newOne) {
old = now;
now = newOne;
}
int get timeDiff => now[0].time - old[0].time;
String speedIn({String? device}) {
if (old[0].device == '' || now[0].device == '') return '0kb/s';
int idx = 0;
if (device != null) {
for (var item in now) {
if (item.device == device) {
idx = now.indexOf(item);
break;
}
}
}
final idx = deviceIdx(device);
final speedInBytesPerSecond =
(now[idx].bytesIn - old[idx].bytesIn) / timeDiff;
int squareTimes = 0;
for (; speedInBytesPerSecond / pow(1024, squareTimes) > 1024;) {
if (squareTimes >= suffixs.length - 1) break;
squareTimes++;
}
return '${(speedInBytesPerSecond / pow(1024, squareTimes)).toStringAsFixed(1)} ${suffixs[squareTimes]}';
return buildStandardOutput(speedInBytesPerSecond);
}
String speedOut({String? device}) {
if (old[0].device == '' || now[0].device == '') return '0kb/s';
int idx = 0;
final idx = deviceIdx(device);
final speedInBytesPerSecond =
(now[idx].bytesOut - old[idx].bytesOut) / timeDiff;
return buildStandardOutput(speedInBytesPerSecond);
}
int deviceIdx(String? device) {
if (device != null) {
for (var item in now) {
if (item.device == device) {
idx = now.indexOf(item);
break;
return now.indexOf(item);
}
}
}
final speedInBytesPerSecond =
(now[idx].bytesOut - old[idx].bytesOut) / timeDiff;
int squareTimes = 0;
for (; speedInBytesPerSecond / pow(1024, squareTimes) > 1024;) {
if (squareTimes >= suffixs.length - 1) break;
squareTimes++;
}
return '${(speedInBytesPerSecond / pow(1024, squareTimes)).toStringAsFixed(1)} ${suffixs[squareTimes]}';
return 0;
}
}
const suffixs = ['b/s', 'kb/s', 'mb/s', 'gb/s'];
String buildStandardOutput(double speed) =>
'${speed.convertBytes.toLowerCase()}/s';
}

View File

@@ -0,0 +1,79 @@
final parseFailed = Exception('Parse failed');
final seqReg = RegExp(r'icmp_seq=(.+) ttl=(.+) time=(.+) ms');
final packetReg =
RegExp(r'(.+) packets transmitted, (.+) received, (.+)% packet loss');
final timeReg = RegExp(r'min/avg/max/mdev = (.+)/(.+)/(.+)/(.+) ms');
final ipReg = RegExp(r' \((\S+)\) ');
class PingResult {
String serverName;
String? ip;
List<PingSeqResult>? results;
PingStatistics? statistic;
PingResult.parse(this.serverName, String raw) {
final lines = raw.split('\n');
lines.removeWhere((element) => element.isEmpty);
final statisticIndex =
lines.indexWhere((element) => element.startsWith('---'));
if (statisticIndex == -1) {
throw parseFailed;
}
final statisticRaw = lines.sublist(statisticIndex + 1);
statistic = PingStatistics.parse(statisticRaw);
results = lines
.sublist(1, statisticIndex)
.map((e) => PingSeqResult.parse(e))
.toList();
ip = ipReg.firstMatch(lines[0])?.group(1);
}
}
class PingSeqResult {
int? seq;
int? ttl;
double? time;
PingSeqResult.parse(String raw) {
final seqMatched = seqReg.firstMatch(raw);
if (seqMatched == null) {
throw parseFailed;
}
seq = int.tryParse(seqMatched.group(1)!);
ttl = int.tryParse(seqMatched.group(2)!);
time = double.tryParse(seqMatched.group(3)!);
}
@override
String toString() {
return 'seq: $seq, ttl: $ttl, time: $time';
}
}
class PingStatistics {
int? total;
int? received;
double? loss;
double? min;
double? max;
double? avg;
double? stddev;
PingStatistics.parse(List<String> lines) {
if (lines.isEmpty || lines.length != 2) {
return;
}
final packetMatched = packetReg.firstMatch(lines[0]);
final timeMatched = timeReg.firstMatch(lines[1]);
if (packetMatched == null || timeMatched == null) {
return;
}
total = int.tryParse(packetMatched.group(1)!);
received = int.tryParse(packetMatched.group(2)!);
loss = double.tryParse(packetMatched.group(3)!);
min = double.tryParse(timeMatched.group(1)!);
avg = double.tryParse(timeMatched.group(2)!);
max = double.tryParse(timeMatched.group(3)!);
stddev = double.tryParse(timeMatched.group(4)!);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:ssh2/ssh2.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:toolbox/data/model/server/server_connection_state.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/server_status.dart';
@@ -6,7 +6,7 @@ import 'package:toolbox/data/model/server/server_status.dart';
class ServerInfo {
ServerPrivateInfo info;
ServerStatus status;
SSHClient client;
SSHClient? client;
ServerConnectionState connectionState;
ServerInfo(this.info, this.status, this.client, this.connectionState);

View File

@@ -40,5 +40,6 @@ class ServerStatus {
String? failedInfo;
ServerStatus(this.cpu2Status, this.memory, this.sysVer, this.uptime,
this.disk, this.tcp, this.netSpeed, {this.failedInfo});
this.disk, this.tcp, this.netSpeed,
{this.failedInfo});
}

View File

@@ -0,0 +1,29 @@
class AbsolutePath {
String path;
String? _prePath;
AbsolutePath(this.path);
void update(String newPath) {
_prePath = path;
if (newPath == '..') {
path = path.substring(0, path.lastIndexOf('/'));
if (path == '') {
path = '/';
}
return;
}
if (newPath == '/') {
path = '/';
return;
}
path = path + (path.endsWith('/') ? '' : '/') + newPath;
}
bool undo() {
if (_prePath == null || _prePath == path) {
return false;
}
path = _prePath!;
return true;
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/sftp/absolute_path.dart';
class SftpBrowserStatus {
bool selected = false;
ServerPrivateInfo? spi;
List<SftpName>? files;
AbsolutePath? path;
SftpClient? client;
bool isBusy = false;
SftpBrowserStatus();
}

View File

@@ -0,0 +1,58 @@
import 'package:toolbox/data/model/sftp/download_worker.dart';
class SftpDownloadStatus {
final int id;
final DownloadItem item;
final void Function() notifyListeners;
late SftpDownloadWorker worker;
String get fileName => item.localPath.split('/').last;
// status of the download
double? progress;
SftpWorkerStatus? status;
int? size;
Exception? error;
Duration? spentTime;
SftpDownloadStatus(this.item, this.notifyListeners, {String? key})
: id = DateTime.now().microsecondsSinceEpoch {
worker =
SftpDownloadWorker(onNotify: onNotify, item: item, privateKey: key);
worker.init();
}
@override
bool operator ==(Object other) =>
other is SftpDownloadStatus && id == other.id;
@override
int get hashCode => id ^ super.hashCode;
void onNotify(dynamic event) {
switch (event.runtimeType) {
case SftpWorkerStatus:
status = event;
if (status == SftpWorkerStatus.finished) {
worker.dispose();
}
break;
case double:
progress = event;
break;
case int:
size = event;
break;
case Exception:
error = event;
break;
case Duration:
spentTime = event;
break;
default:
}
notifyListeners();
}
}
enum SftpWorkerStatus { preparing, sshConnectted, downloading, finished }

View File

@@ -0,0 +1,111 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'package:dartssh2/dartssh2.dart';
import 'package:easy_isolate/easy_isolate.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/sftp/download_status.dart';
class DownloadItem {
DownloadItem(this.spi, this.remotePath, this.localPath);
final ServerPrivateInfo spi;
final String remotePath;
final String localPath;
}
class SftpDownloadWorker {
SftpDownloadWorker(
{required this.onNotify, required this.item, this.privateKey});
final Function(Object event) onNotify;
final DownloadItem item;
final worker = Worker();
final String? privateKey;
void dispose() {
worker.dispose();
}
/// Initiate the worker (new thread) and start listen from messages between
/// the threads
Future<void> init() async {
if (worker.isInitialized) worker.dispose();
await worker.init(
mainMessageHandler,
isolateMessageHandler,
errorHandler: print,
);
worker.sendMessage(DownloadItemEvent(item, privateKey));
}
/// Handle the messages coming from the isolate
void mainMessageHandler(dynamic data, SendPort isolateSendPort) {
onNotify(data);
}
/// Handle the messages coming from the main
static isolateMessageHandler(
dynamic data, SendPort mainSendPort, SendErrorFunction sendError) async {
if (data is DownloadItemEvent) {
try {
mainSendPort.send(SftpWorkerStatus.preparing);
final watch = Stopwatch()..start();
final item = data.item;
final spi = item.spi;
final socket = await SSHSocket.connect(spi.ip, spi.port);
SSHClient client;
if (spi.pubKeyId == null) {
client = SSHClient(socket,
username: spi.user,
onPasswordRequest: () => spi.authorization as String);
} else {
client = SSHClient(socket,
username: spi.user,
identities: SSHKeyPair.fromPem(data.privateKey!));
}
mainSendPort.send(SftpWorkerStatus.sshConnectted);
final remotePath = item.remotePath;
final localPath = item.localPath;
await Directory(localPath.substring(0, item.localPath.lastIndexOf('/')))
.create(recursive: true);
final local = File(localPath);
if (await local.exists()) {
await local.delete();
}
final localFile = local.openWrite(mode: FileMode.append);
final file = await (await client.sftp()).open(remotePath);
final size = (await file.stat()).size;
if (size == null) {
mainSendPort.send(Exception('can not get file size'));
return;
}
const defaultChunkSize = 1024 * 1024;
final chunkSize = size > defaultChunkSize ? defaultChunkSize : size;
mainSendPort.send(size);
mainSendPort.send(SftpWorkerStatus.downloading);
for (var i = 0; i < size; i += chunkSize) {
final fileData = file.read(length: chunkSize);
await for (var form in fileData) {
localFile.add(form);
mainSendPort.send((i + form.length) / size * 100);
}
}
localFile.close();
mainSendPort.send(watch.elapsed);
mainSendPort.send(SftpWorkerStatus.finished);
} catch (e) {
mainSendPort.send(e);
}
}
}
}
class DownloadItemEvent {
DownloadItemEvent(this.item, this.privateKey);
final DownloadItem item;
final String? privateKey;
}

164
lib/data/provider/apt.dart Normal file
View File

@@ -0,0 +1,164 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:logging/logging.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/extension/uint8list.dart';
import 'package:toolbox/core/provider_base.dart';
import 'package:toolbox/data/model/apt/upgrade_pkg_info.dart';
import 'package:toolbox/data/model/distribution.dart';
typedef PwdRequestFunc = Future<String> Function(
bool lastTimes, String? userName);
final pwdRequestWithUserReg = RegExp(r'\[sudo\] password for (.+):');
class AptProvider extends BusyProvider {
final logger = Logger('AptProvider');
SSHClient? client;
Distribution? dist;
Function()? onUpgrade;
Function()? onUpdate;
PwdRequestFunc? onPasswordRequest;
String? whoami;
List<UpgradePkgInfo>? upgradeable;
String? error;
String? upgradeLog;
String? updateLog;
String lastLog = '';
int triedTimes = 0;
AptProvider();
Future<void> init(SSHClient client, Distribution dist, Function() onUpgrade,
Function() onUpdate, PwdRequestFunc onPasswordRequest) async {
this.client = client;
this.dist = dist;
this.onUpgrade = onUpgrade;
this.onPasswordRequest = onPasswordRequest;
whoami = (await client.run('whoami').string).trim();
}
bool get isSU => whoami == 'root';
void clear() {
client = null;
dist = null;
upgradeable = null;
error = null;
upgradeLog = null;
updateLog = whoami = null;
onUpgrade = null;
onUpdate = null;
onPasswordRequest = null;
triedTimes = 0;
}
Future<void> refreshInstalled() async {
if (client == null) {
error = 'No client';
return;
}
final result = await _update();
try {
getUpgradeableList(result);
} catch (e) {
error = '[Server Raw]:\n$result\n[App Error]:\n$e';
} finally {
notifyListeners();
}
}
void getUpgradeableList(String? raw) {
if (raw == null) return;
var list = raw.split('\n');
switch (dist) {
case Distribution.rehl:
list = list.sublist(2);
list.removeWhere((element) => element.isEmpty);
final endLine = list.lastIndexWhere(
(element) => element.contains('Obsoleting Packages'));
list = list.sublist(0, endLine);
break;
default:
list = list.sublist(4);
list.removeWhere((element) => element.isEmpty);
}
upgradeable = list.map((e) => UpgradePkgInfo(e, dist!)).toList();
}
Future<String> _update() async {
switch (dist) {
case Distribution.rehl:
return await client?.run(_wrap('yum check-update')).string ?? '';
default:
final session = await client!.execute(_wrap('apt update'));
session.stderr.listen((event) => _onPwd(event, session.stdin));
session.stdout.listen((event) {
updateLog = (updateLog ?? '') + event.string;
notifyListeners();
onUpdate ?? () {}();
});
await session.done;
return await client
?.run('apt list --upgradeable'.withLangExport)
.string ??
'';
}
}
Future<void> upgrade() async {
if (client == null) {
error = 'No client';
return;
}
final upgradeCmd = () {
switch (dist) {
case Distribution.rehl:
return 'yum upgrade -y';
default:
return 'apt upgrade -y';
}
}();
final session = await client!.execute(_wrap(upgradeCmd));
session.stderr.listen((e) => _onPwd(e, session.stdin));
session.stdout.listen((data) async {
final log = data.string;
if (lastLog == log.trim()) return;
upgradeLog = (upgradeLog ?? '') + log;
lastLog = log.trim();
notifyListeners();
onUpgrade!();
});
upgradeLog = null;
await session.done;
refreshInstalled();
}
Future<void> _onPwd(Uint8List e, StreamSink<Uint8List> stdin) async {
final event = e.string;
if (event.contains('[sudo] password for ')) {
final user = pwdRequestWithUserReg.firstMatch(event)?.group(1);
logger.info('sudo password request for $user');
triedTimes++;
final pwd =
await (onPasswordRequest ?? (_) async => '')(triedTimes == 3, user);
if (pwd.isEmpty) {
logger.info('sudo password request cancelled');
return;
}
stdin.add(Uint8List.fromList(utf8.encode('$pwd\n')));
}
}
String _wrap(String cmd) =>
'export LANG=en_US.utf-8 && ${isSU ? "" : "sudo -S "}$cmd';
}

View File

@@ -34,8 +34,8 @@ class DebugProvider extends ChangeNotifier {
),
);
_addWidget(SingleChildScrollView(
child: widget,
scrollDirection: Axis.horizontal,
child: widget,
));
}

View File

@@ -0,0 +1,107 @@
import 'package:dartssh2/dartssh2.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/extension/uint8list.dart';
import 'package:toolbox/core/provider_base.dart';
import 'package:toolbox/data/model/docker/ps.dart';
final dockerNotFound = RegExp(r'command not found|Unknown command');
class DockerProvider extends BusyProvider {
SSHClient? client;
List<DockerPsItem>? items;
String? version;
String? edition;
String? error;
void init(SSHClient client) => this.client = client;
void clear() {
client = null;
error = null;
items = null;
version = null;
edition = null;
}
Future<void> refresh() async {
if (client == null) {
error = 'no client';
notifyListeners();
return;
}
final verRaw = await client!.run('docker version'.withLangExport).string;
if (verRaw.contains(dockerNotFound)) {
error = 'docker not found';
notifyListeners();
return;
}
final verSplit = verRaw.split('\n');
if (verSplit.length < 3) {
error = 'invalid version';
notifyListeners();
return;
} else {
try {
version = verSplit[1].split(' ').last;
edition = verSplit[0].split(': ')[1];
} catch (e) {
error = e.toString();
return;
}
}
final raw = await client!.run('docker ps -a'.withLangExport).string;
final lines = raw.split('\n');
lines.removeAt(0);
lines.removeWhere((element) => element.isEmpty);
try {
items = lines.map((e) => DockerPsItem.fromRawString(e)).toList();
} catch (e) {
error = e.toString();
rethrow;
} finally {
notifyListeners();
}
}
Future<bool> stop(String id) async {
setBusyState();
if (client == null) {
error = 'no client';
setBusyState(false);
return false;
}
final result = await client!.run('docker stop $id').string;
await refresh();
setBusyState(false);
return result.contains(id);
}
Future<bool> start(String id) async {
setBusyState();
if (client == null) {
error = 'no client';
setBusyState(false);
return false;
}
final result = await client!.run('docker start $id').string;
await refresh();
setBusyState(false);
return result.contains(id);
}
Future<bool> delete(String id) async {
setBusyState();
if (client == null) {
error = 'no client';
setBusyState(false);
return false;
}
final result = await client!.run('docker rm $id').string;
await refresh();
setBusyState(false);
return result.contains(id);
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:async';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:ssh2/ssh2.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/extension/uint8list.dart';
import 'package:toolbox/core/provider_base.dart';
import 'package:toolbox/data/model/server/cpu_2_status.dart';
import 'package:toolbox/data/model/server/cpu_status.dart';
@@ -15,14 +17,39 @@ import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/server_status.dart';
import 'package:toolbox/data/model/server/snippet.dart';
import 'package:toolbox/data/model/server/tcp_status.dart';
import 'package:toolbox/data/store/private_key.dart';
import 'package:toolbox/data/store/server.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/locator.dart';
/// Must put this func out of any Class.
/// Because of this function is called by [compute] in [ServerProvider.genClient].
/// https://stackoverflow.com/questions/51998995/invalid-arguments-illegal-argument-in-isolate-message-object-is-a-closure
List<SSHKeyPair> loadIndentity(String key) {
return SSHKeyPair.fromPem(key);
}
const seperator = 'A====A';
const shellCmd = "export LANG=en_US.utf-8 \necho '$seperator' \n"
"cat /proc/net/dev && date +%s \necho $seperator \n "
"cat /etc/os-release | grep PRETTY_NAME \necho $seperator \n"
"cat /proc/stat | grep cpu \necho $seperator \n"
"uptime \necho $seperator \n"
"cat /proc/net/snmp \necho $seperator \n"
"df -h \necho $seperator \n"
"free -m \necho $seperator \n"
"cat /sys/class/thermal/thermal_zone*/type \necho $seperator \n"
"cat /sys/class/thermal/thermal_zone*/temp";
const shellPath = '.serverbox.sh';
const memPrefix = 'Mem:';
final cpuTempReg = RegExp('(x86_pkg_temp|cpu_thermal)');
class ServerProvider extends BusyProvider {
List<ServerInfo> _servers = [];
List<ServerInfo> get servers => _servers;
Timer? _timer;
final logger = Logger('ServerProvider');
Memory get emptyMemory =>
@@ -57,122 +84,133 @@ class ServerProvider extends BusyProvider {
ServerInfo genInfo(ServerPrivateInfo spi) {
return ServerInfo(
spi, emptyStatus, genClient(spi), ServerConnectionState.disconnected);
spi, emptyStatus, null, ServerConnectionState.disconnected);
}
SSHClient genClient(ServerPrivateInfo spi) {
return SSHClient(
host: spi.ip,
port: spi.port,
Future<SSHClient> genClient(ServerPrivateInfo spi) async {
final socket = await SSHSocket.connect(spi.ip, spi.port);
if (spi.pubKeyId == null) {
return SSHClient(socket,
username: spi.user,
onPasswordRequest: () => spi.authorization as String);
}
final key = locator<PrivateKeyStore>().get(spi.pubKeyId!);
return SSHClient(socket,
username: spi.user,
passwordOrKey: spi.authorization);
identities: await compute(loadIndentity, key.privateKey));
}
Future<void> refreshData({int? idx}) async {
if (idx != null) {
final singleData = await _getData(_servers[idx].info, idx);
if (singleData != null) {
_servers[idx].status = singleData;
notifyListeners();
}
Future<void> refreshData({ServerPrivateInfo? spi}) async {
if (spi != null) {
_getData(spi);
return;
}
try {
await Future.wait(_servers.map((s) async {
final idx = _servers.indexOf(s);
final status = await _getData(s.info, idx);
if (status != null) {
_servers[idx].status = status;
notifyListeners();
}
}));
} catch (e) {
rethrow;
}
await Future.wait(_servers.map((s) async {
await _getData(s.info);
}));
}
Future<void> startAutoRefresh() async {
final duration =
locator<SettingStore>().serverStatusUpdateInterval.fetch()!;
if (duration == 0) return;
Timer.periodic(Duration(seconds: duration), (_) async {
stopAutoRefresh();
_timer = Timer.periodic(Duration(seconds: duration), (_) async {
await refreshData();
});
}
void addServer(ServerPrivateInfo info) {
_servers.add(genInfo(info));
locator<ServerStore>().put(info);
void stopAutoRefresh() {
if (_timer != null) {
_timer!.cancel();
_timer = null;
}
}
void setDisconnected() {
for (var i = 0; i < _servers.length; i++) {
_servers[i].connectionState = ServerConnectionState.disconnected;
}
}
void addServer(ServerPrivateInfo spi) {
_servers.add(genInfo(spi));
locator<ServerStore>().put(spi);
notifyListeners();
refreshData(idx: _servers.length - 1);
refreshData(spi: spi);
}
void delServer(ServerPrivateInfo info) {
_servers.removeWhere((e) => e.info == info);
final idx = _servers.indexWhere((s) => s.info == info);
if (idx == -1) return;
_servers[idx].client?.close();
_servers.removeAt(idx);
notifyListeners();
locator<ServerStore>().delete(info);
notifyListeners();
}
void updateServer(ServerPrivateInfo old, ServerPrivateInfo newInfo) {
Future<void> updateServer(
ServerPrivateInfo old, ServerPrivateInfo newSpi) async {
final idx = _servers.indexWhere((e) => e.info == old);
_servers[idx].info = newInfo;
_servers[idx].client = genClient(newInfo);
locator<ServerStore>().update(old, newInfo);
if (idx < 0) {
throw RangeError.index(idx, _servers);
}
_servers[idx].info = newSpi;
_servers[idx].client = await genClient(newSpi);
locator<ServerStore>().update(old, newSpi);
notifyListeners();
refreshData(idx: idx);
refreshData(spi: newSpi);
}
Future<ServerStatus?> _getData(ServerPrivateInfo info, int idx) async {
final client = _servers[idx].client;
final connected = await client.isConnected();
Future<void> _getData(ServerPrivateInfo spi) async {
final idx = _servers.indexWhere((element) => element.info == spi);
final state = _servers[idx].connectionState;
if (!connected ||
if (_servers[idx].client == null ||
state == ServerConnectionState.failed ||
state == ServerConnectionState.disconnected) {
_servers[idx].connectionState = ServerConnectionState.connecting;
notifyListeners();
final time1 = DateTime.now();
try {
await client.connect();
_servers[idx].client = await genClient(spi);
final time2 = DateTime.now();
logger.info(
'Connected to [${info.name}] in [${time2.difference(time1).toString()}].');
'Connected to [${spi.name}] in [${time2.difference(time1).toString()}].');
_servers[idx].connectionState = ServerConnectionState.connected;
notifyListeners();
_servers[idx]
.client!
.run("echo '$shellCmd' > $shellPath && chmod +x $shellPath");
} catch (e) {
_servers[idx].connectionState = ServerConnectionState.failed;
_servers[idx].status.failedInfo = e.toString().split(', ')[1];
notifyListeners();
_servers[idx].status.failedInfo = '$e ## ';
logger.warning(e);
} finally {
notifyListeners();
}
}
try {
final cpu = await client.execute("cat /proc/stat | grep cpu") ?? '';
final cpuTemp = await client.execute(
r"paste <(cat /sys/class/thermal/thermal_zone*/type) <(cat /sys/class/thermal/thermal_zone*/temp) | column -s $'\t' -t | sed 's/\(.\)..$/.\1°C/'") ??
'';
final mem = await client.execute('free -m') ?? '';
final sysVer =
await client.execute('cat /etc/os-release | grep PRETTY_NAME') ?? '';
final upTime = await client.execute('uptime') ?? '';
final disk = await client.execute('df -h') ?? '';
final tcp = await client.execute('cat /proc/net/snmp') ?? '';
final netSpeed =
await client.execute('cat /proc/net/dev && date +%s') ?? '';
return ServerStatus(
_getCPU(cpu, _servers[idx].status.cpu2Status, cpuTemp),
_getMem(mem),
_getSysVer(sysVer),
_getUpTime(upTime),
_getDisk(disk),
_getTcp(tcp),
_getNetSpeed(netSpeed, _servers[idx].status.netSpeed));
// if client is null, return
final si = _servers[idx];
if (si.client == null) return;
final raw = await si.client!.run("sh $shellPath").string;
final lines = raw.split(seperator).map((e) => e.trim()).toList();
lines.removeAt(0);
try {
_getCPU(spi, lines[2], lines[7], lines[8]);
_getMem(spi, lines[6]);
_getSysVer(spi, lines[1]);
_getUpTime(spi, lines[3]);
_getDisk(spi, lines[5]);
_getTcp(spi, lines[4]);
_getNetSpeed(spi, lines[0]);
} catch (e) {
_servers[idx].connectionState = ServerConnectionState.failed;
notifyListeners();
servers[idx].status.failedInfo = e.toString();
logger.warning(e);
return null;
rethrow;
} finally {
notifyListeners();
}
}
@@ -182,11 +220,12 @@ class ServerProvider extends BusyProvider {
/// lo: 45929941 269112 0 0 0 0 0 0 45929941 269112 0 0 0 0 0 0
/// eth0: 48481023 505772 0 0 0 0 0 0 36002262 202307 0 0 0 0 0 0
/// 1635752901
NetSpeed _getNetSpeed(String raw, NetSpeed old) {
void _getNetSpeed(ServerPrivateInfo spi, String raw) {
final info = _servers.firstWhere((e) => e.info == spi);
final split = raw.split('\n');
final deviceCount = split.length - 3;
if (deviceCount < 1) return emptyNetSpeed;
final time = int.parse(split[split.length - 2]);
if (deviceCount < 1) return;
final time = int.parse(split[split.length - 1]);
final results = <NetSpeedPart>[];
for (int idx = 2; idx < deviceCount; idx++) {
final data = split[idx].trim().split(':');
@@ -197,28 +236,40 @@ class ServerProvider extends BusyProvider {
final bytesOut = int.parse(bytes[8]);
results.add(NetSpeedPart(device, bytesIn, bytesOut, time));
}
return old.update(results);
info.status.netSpeed.update(results);
}
String _getSysVer(String raw) {
void _getSysVer(ServerPrivateInfo spi, String raw) {
final info = _servers.firstWhere((e) => e.info == spi);
final s = raw.split('=');
if (s.length == 2) {
return s[1].replaceAll('"', '').replaceFirst('\n', '');
info.status.sysVer = s[1].replaceAll('"', '').replaceFirst('\n', '');
}
return '';
}
String _getCPUTemp(String raw) {
final split = raw.split('\n');
String _getCPUTemp(String type, String value) {
const noMatch = "/sys/class/thermal/thermal_zone*/type";
// Not support to get CPU temperature
if (value.contains(noMatch) ||
type.contains(noMatch) ||
value.isEmpty ||
type.isEmpty) {
return '';
}
final split = type.split('\n');
int idx = 0;
for (var item in split) {
if (item.contains('x86_pkg_temp') || item.contains('cpu_thermal')) {
return item.split(' ').last;
if (item.contains(cpuTempReg)) {
break;
}
idx++;
}
return '';
return '${(int.parse(value.split('\n')[idx].trim()) / 1000).toStringAsFixed(1)}°C';
}
Cpu2Status _getCPU(String raw, Cpu2Status old, String temp) {
void _getCPU(
ServerPrivateInfo spi, String raw, String tempType, String tempValue) {
final info = _servers.firstWhere((e) => e.info == spi);
final List<CpuStatus> cpus = [];
for (var item in raw.split('\n')) {
@@ -235,35 +286,31 @@ class ServerProvider extends BusyProvider {
int.parse(matches[5]),
int.parse(matches[6])));
}
if (cpus.isEmpty) {
return emptyCpu2Status;
if (cpus.isNotEmpty) {
info.status.cpu2Status.update(cpus, _getCPUTemp(tempType, tempValue));
}
return old.update(cpus, _getCPUTemp(temp));
}
String _getUpTime(String raw) {
return raw.split('up ')[1].split(', ')[0];
void _getUpTime(ServerPrivateInfo spi, String raw) {
_servers.firstWhere((e) => e.info == spi).status.uptime =
raw.split('up ')[1].split(', ')[0];
}
TcpStatus _getTcp(String raw) {
void _getTcp(ServerPrivateInfo spi, String raw) {
final info = _servers.firstWhere((e) => e.info == spi);
final lines = raw.split('\n');
int idx = 0;
for (var item in lines) {
if (item.contains('Tcp:')) {
idx++;
}
if (idx == 2) {
final vals = item.split(RegExp(r'\s{1,}'));
return TcpStatus(vals[5].i, vals[6].i, vals[7].i, vals[8].i);
}
final idx = lines.lastWhere((element) => element.startsWith('Tcp:'),
orElse: () => '');
if (idx != '') {
final vals = idx.split(RegExp(r'\s{1,}'));
info.status.tcp = TcpStatus(vals[5].i, vals[6].i, vals[7].i, vals[8].i);
}
return TcpStatus(0, 0, 0, 0);
}
List<DiskInfo> _getDisk(String disk) {
void _getDisk(ServerPrivateInfo spi, String raw) {
final info = _servers.firstWhere((e) => e.info == spi);
final list = <DiskInfo>[];
final items = disk.split('\n');
final items = raw.split('\n');
for (var item in items) {
if (items.indexOf(item) == 0 || item.isEmpty) {
continue;
@@ -272,28 +319,33 @@ class ServerProvider extends BusyProvider {
list.add(DiskInfo(vals[0], vals[5],
int.parse(vals[4].replaceFirst('%', '')), vals[2], vals[1], vals[3]));
}
return list;
info.status.disk = list;
}
Memory _getMem(String mem) {
for (var item in mem.split('\n')) {
if (item.contains('Mem:')) {
final split = item.replaceFirst('Mem:', '').split(' ');
void _getMem(ServerPrivateInfo spi, String raw) {
final info = _servers.firstWhere((e) => e.info == spi);
for (var item in raw.split('\n')) {
if (item.contains(memPrefix)) {
final split = item.replaceFirst(memPrefix, '').split(' ');
split.removeWhere((e) => e == '');
final memList = split.map((e) => int.parse(e)).toList();
return Memory(
info.status.memory = Memory(
total: memList[0],
used: memList[1],
free: memList[2],
shared: memList[3],
cache: memList[4],
avail: memList[5]);
break;
}
}
return emptyMemory;
}
Future<String?> runSnippet(int idx, Snippet snippet) {
return _servers[idx].client.execute(snippet.script);
Future<String?> runSnippet(ServerPrivateInfo spi, Snippet snippet) async {
return await _servers
.firstWhere((element) => element.info == spi)
.client!
.run(snippet.script)
.string;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:toolbox/core/provider_base.dart';
import 'package:toolbox/data/model/sftp/download_status.dart';
import 'package:toolbox/data/model/sftp/download_worker.dart';
class SftpDownloadProvider extends ProviderBase {
final List<SftpDownloadStatus> _status = [];
List<SftpDownloadStatus> get status => _status;
List<SftpDownloadStatus> gets({int? id, String? fileName}) {
var found = <SftpDownloadStatus>[];
if (id != null) {
found = _status.where((e) => e.id == id).toList();
}
if (fileName != null) {
found = found
.where((e) => e.item.localPath.split('/').last == fileName)
.toList();
}
return found;
}
SftpDownloadStatus? get({int? id, String? name}) {
final found = gets(id: id, fileName: name);
if (found.isEmpty) return null;
return found.first;
}
void add(DownloadItem item, {String? key}) {
_status.add(SftpDownloadStatus(item, notifyListeners, key: key));
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:toolbox/core/provider_base.dart';
import 'package:toolbox/data/model/server/snippet.dart';
import 'package:toolbox/data/store/snippet.dart';
@@ -11,22 +13,34 @@ class SnippetProvider extends BusyProvider {
_snippets = locator<SnippetStore>().fetch();
}
void addInfo(Snippet snippet) {
void add(Snippet snippet) {
if (have(snippet)) return;
_snippets.add(snippet);
locator<SnippetStore>().put(snippet);
notifyListeners();
}
void delInfo(Snippet snippet) {
_snippets.removeWhere((e) => e.name == snippet.name);
void del(Snippet snippet) {
if (!have(snippet)) return;
_snippets.removeAt(index(snippet));
locator<SnippetStore>().delete(snippet);
notifyListeners();
}
void updateInfo(Snippet old, Snippet newOne) {
final idx = _snippets.indexWhere((e) => e.name == old.name);
_snippets[idx] = newOne;
int index(Snippet snippet) {
return _snippets.indexWhere((e) => e.name == snippet.name);
}
bool have(Snippet snippet) {
return index(snippet) != -1;
}
void update(Snippet old, Snippet newOne) {
if (!have(old)) return;
_snippets[index(old)] = newOne;
locator<SnippetStore>().update(old, newOne);
notifyListeners();
}
String get export => json.encode(snippets);
}

View File

@@ -1,9 +1,10 @@
// This file is generated by ./make.dart
class BuildData {
static const String name = "ToolBox";
static const int build = 64;
static const String engine = "Flutter 2.5.3 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 18116933e7 (5 weeks ago) • 2021-10-15 10:46:35 -0700\nEngine • revision d3ea636dc5\nTools • Dart 2.14.4\n";
static const String buildAt = "2021-11-21 19:42:23.223010";
static const int modifications = 2;
static const String name = "ServerBox";
static const int build = 136;
static const String engine =
"Flutter 3.0.0 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision ee4e09cce0 (9 days ago) • 2022-05-09 16:45:18 -0700\nEngine • revision d1b9a6938a\nTools • Dart 2.17.0 • DevTools 2.12.2\n";
static const String buildAt = "2022-05-19 15:59:10.728748";
static const int modifications = 15;
}

View File

@@ -0,0 +1,4 @@
import 'package:flutter/material.dart';
const TextStyle size18 = TextStyle(fontSize: 18);
const TextStyle grey = TextStyle(color: Colors.grey);

View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
const roundRectCardPadding = EdgeInsets.symmetric(horizontal: 17);

10
lib/data/res/path.dart Normal file
View File

@@ -0,0 +1,10 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
Future<Directory> get docDir async => await getApplicationDocumentsDirectory();
Future<Directory> get sftpDownloadDir async {
final dir = Directory('${(await docDir).path}/sftp');
return dir.create(recursive: true);
}

9
lib/data/res/tab.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:toolbox/data/model/app/navigation_item.dart';
final List<String> tabs = ['Servers', 'En/Decode', 'Ping'];
final List<NavigationItem> tabItems = [
NavigationItem(Icons.cloud, 'Server'),
NavigationItem(Icons.code, 'Convert'),
NavigationItem(Icons.leak_add, 'Ping'),
];

View File

@@ -1,5 +1,6 @@
const backendUrl = 'https://v2.custed.lolli.tech';
const baseUrl = backendUrl + '/res/toolbox';
const backendUrl = 'https://res.lolli.tech';
const baseUrl = '$backendUrl/toolbox';
const joinQQGroupUrl = 'https://jq.qq.com/?_wv=1027&k=G0hUmPAq';
const myGithub = 'https://github.com/LollipopKit';
const rainSunMeGithub = 'https://github.com/RainSunMe';
const issueUrl = 'https://github.com/LollipopKit/flutter_server_box/issues';

View File

@@ -15,6 +15,11 @@ class PrivateKeyStore extends PersistentStore {
json.decode(box.get('key', defaultValue: '[]')!));
}
PrivateKeyInfo get(String id) {
final ss = fetch();
return ss.firstWhere((e) => e.id == id);
}
void delete(PrivateKeyInfo s) {
final ss = fetch();
ss.removeAt(index(s));

View File

@@ -23,7 +23,11 @@ class ServerStore extends PersistentStore {
void update(ServerPrivateInfo old, ServerPrivateInfo newInfo) {
final ss = fetch();
ss[index(old)] = newInfo;
final idx = index(old);
if (idx < 0) {
throw RangeError.index(idx, ss);
}
ss[idx] = newInfo;
box.put('servers', json.encode(ss));
}

View File

@@ -5,5 +5,6 @@ class SettingStore extends PersistentStore {
StoreProperty<int> get primaryColor =>
property('primaryColor', defaultValue: Colors.deepPurpleAccent.value);
StoreProperty<int> get serverStatusUpdateInterval =>
property('serverStatusUpdateInterval', defaultValue: 3);
property('serverStatusUpdateInterval', defaultValue: 2);
StoreProperty<int> get launchPage => property('launchPage', defaultValue: 0);
}

View File

@@ -0,0 +1,66 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that looks up messages for specific locales by
// delegating to the appropriate library.
// Ignore issues from commonly used lints in this file.
// ignore_for_file:implementation_imports, file_names, unnecessary_new
// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering
// ignore_for_file:argument_type_not_assignable, invalid_assignment
// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases
// ignore_for_file:comment_references
import 'dart:async';
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
import 'package:intl/src/intl_helpers.dart';
import 'messages_en.dart' as messages_en;
import 'messages_zh.dart' as messages_zh;
typedef Future<dynamic> LibraryLoader();
Map<String, LibraryLoader> _deferredLibraries = {
'en': () => new Future.value(null),
'zh': () => new Future.value(null),
};
MessageLookupByLibrary? _findExact(String localeName) {
switch (localeName) {
case 'en':
return messages_en.messages;
case 'zh':
return messages_zh.messages;
default:
return null;
}
}
/// User programs should call this before using [localeName] for messages.
Future<bool> initializeMessages(String localeName) async {
var availableLocale = Intl.verifiedLocale(
localeName, (locale) => _deferredLibraries[locale] != null,
onFailure: (_) => null);
if (availableLocale == null) {
return new Future.value(false);
}
var lib = _deferredLibraries[availableLocale];
await (lib == null ? new Future.value(false) : lib());
initializeInternalMessageLookup(() => new CompositeMessageLookup());
messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor);
return new Future.value(true);
}
bool _messagesExistFor(String locale) {
try {
return _findExact(locale) != null;
} catch (e) {
return false;
}
}
MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) {
var actualLocale =
Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null);
if (actualLocale == null) return null;
return _findExact(actualLocale);
}

View File

@@ -0,0 +1,213 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a en locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes
// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = new MessageLookup();
typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'en';
static String m0(rainSunMeGithub) =>
"\nThanks ${rainSunMeGithub} for participating in the test.\n\nAll rights reserved.";
static String m1(fileName) => "Download [${fileName}] to local?";
static String m2(runningCount, stoppedCount) =>
"${runningCount} running, ${stoppedCount} container stopped.";
static String m3(count) => "${count} container running.";
static String m4(percent, size) => "${percent}% of ${size}";
static String m5(count) => "Found ${count} update";
static String m6(code) => "request failed, status code: ${code}";
static String m7(url) =>
"Please make sure that docker is installed correctly, or that you are using a non-self-compiled version. If you don\'t have the above issues, please submit an issue on ${url}.";
static String m8(myGithub) => "\nMade with ❤️ by ${myGithub}";
static String m9(url) => "Please report bugs on ${url}";
static String m10(time) => "Spent time: ${time}";
static String m11(name) => "Are you sure to delete [${name}]?";
static String m12(server) => "Are you sure to delete server [${server}]?";
static String m13(build) => "Found: v1.0.${build}, click to update";
static String m14(build) => "Current: v1.0.${build}";
static String m15(build) => "Current: v1.0.${build}, is up to date";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"aboutThanks": m0,
"addAServer": MessageLookupByLibrary.simpleMessage("add a server"),
"addPrivateKey":
MessageLookupByLibrary.simpleMessage("Add private key"),
"alreadyLastDir":
MessageLookupByLibrary.simpleMessage("Already in last directory."),
"appPrimaryColor":
MessageLookupByLibrary.simpleMessage("App primary color"),
"attention": MessageLookupByLibrary.simpleMessage("Attention"),
"backDir": MessageLookupByLibrary.simpleMessage("Back"),
"cancel": MessageLookupByLibrary.simpleMessage("Cancel"),
"choose": MessageLookupByLibrary.simpleMessage("Choose"),
"chooseDestination":
MessageLookupByLibrary.simpleMessage("Choose destination"),
"choosePrivateKey":
MessageLookupByLibrary.simpleMessage("Choose private key"),
"clear": MessageLookupByLibrary.simpleMessage("Clear"),
"close": MessageLookupByLibrary.simpleMessage("Close"),
"containerStatus":
MessageLookupByLibrary.simpleMessage("Container status"),
"convert": MessageLookupByLibrary.simpleMessage("Convert"),
"copy": MessageLookupByLibrary.simpleMessage("Copy"),
"copyPath": MessageLookupByLibrary.simpleMessage("Copy path"),
"createFile": MessageLookupByLibrary.simpleMessage("Create file"),
"createFolder": MessageLookupByLibrary.simpleMessage("Create folder"),
"currentMode": MessageLookupByLibrary.simpleMessage("Current Mode"),
"debug": MessageLookupByLibrary.simpleMessage("Debug"),
"decode": MessageLookupByLibrary.simpleMessage("Decode"),
"delete": MessageLookupByLibrary.simpleMessage("Delete"),
"disconnected": MessageLookupByLibrary.simpleMessage("Disconnected"),
"dl2Local": m1,
"dockerStatusRunningAndStoppedFmt": m2,
"dockerStatusRunningFmt": m3,
"download": MessageLookupByLibrary.simpleMessage("Download"),
"downloadFinished":
MessageLookupByLibrary.simpleMessage("Download finished"),
"downloadStatus": m4,
"edit": MessageLookupByLibrary.simpleMessage("Edit"),
"encode": MessageLookupByLibrary.simpleMessage("Encode"),
"error": MessageLookupByLibrary.simpleMessage("Error"),
"exampleName": MessageLookupByLibrary.simpleMessage("Example name"),
"experimentalFeature":
MessageLookupByLibrary.simpleMessage("Experimental feature"),
"export": MessageLookupByLibrary.simpleMessage("Export"),
"fieldMustNotEmpty": MessageLookupByLibrary.simpleMessage(
"These fields must not be empty."),
"files": MessageLookupByLibrary.simpleMessage("Files"),
"foundNUpdate": m5,
"go": MessageLookupByLibrary.simpleMessage("Go"),
"goSftpDlPage":
MessageLookupByLibrary.simpleMessage("Go to SFTP download page?"),
"host": MessageLookupByLibrary.simpleMessage("Host"),
"httpFailedWithCode": m6,
"import": MessageLookupByLibrary.simpleMessage("Import"),
"importAndExport":
MessageLookupByLibrary.simpleMessage("Import and Export"),
"install": MessageLookupByLibrary.simpleMessage("install"),
"installDockerWithUrl": MessageLookupByLibrary.simpleMessage(
"Please https://docs.docker.com/engine/install docker first."),
"invalidVersionHelp": m7,
"keepForeground":
MessageLookupByLibrary.simpleMessage("Keep app foreground!"),
"keyAuth": MessageLookupByLibrary.simpleMessage("Key Auth"),
"lastTry": MessageLookupByLibrary.simpleMessage("Last try!"),
"launchPage": MessageLookupByLibrary.simpleMessage("Launch page"),
"license": MessageLookupByLibrary.simpleMessage("License"),
"loadingFiles":
MessageLookupByLibrary.simpleMessage("Loading files..."),
"loss": MessageLookupByLibrary.simpleMessage("Loss"),
"madeWithLove": m8,
"max": MessageLookupByLibrary.simpleMessage("max"),
"min": MessageLookupByLibrary.simpleMessage("min"),
"ms": MessageLookupByLibrary.simpleMessage("ms"),
"name": MessageLookupByLibrary.simpleMessage("Name"),
"noClient": MessageLookupByLibrary.simpleMessage("No client"),
"noInterface": MessageLookupByLibrary.simpleMessage("No interface"),
"noResult": MessageLookupByLibrary.simpleMessage("No result"),
"noSavedPrivateKey":
MessageLookupByLibrary.simpleMessage("No saved private keys."),
"noSavedSnippet":
MessageLookupByLibrary.simpleMessage("No saved snippets."),
"noServerAvailable":
MessageLookupByLibrary.simpleMessage("No server available."),
"noUpdateAvailable":
MessageLookupByLibrary.simpleMessage("No update available"),
"ok": MessageLookupByLibrary.simpleMessage("OK"),
"open": MessageLookupByLibrary.simpleMessage("Open"),
"ping": MessageLookupByLibrary.simpleMessage("Ping"),
"pingAvg": MessageLookupByLibrary.simpleMessage("Avg:"),
"pingInputIP": MessageLookupByLibrary.simpleMessage(
"Please input a target IP/domain."),
"platformNotSupportUpdate": MessageLookupByLibrary.simpleMessage(
"Current platform does not support in app update.\nPlease build from source and install it."),
"plzEnterHost":
MessageLookupByLibrary.simpleMessage("Please enter host."),
"plzEnterPwd":
MessageLookupByLibrary.simpleMessage("Please enter password."),
"plzSelectKey":
MessageLookupByLibrary.simpleMessage("Please select a key."),
"port": MessageLookupByLibrary.simpleMessage("Port"),
"privateKey": MessageLookupByLibrary.simpleMessage("Private Key"),
"pwd": MessageLookupByLibrary.simpleMessage("Password"),
"rename": MessageLookupByLibrary.simpleMessage("Rename"),
"reportBugsOnGithubIssue": m9,
"result": MessageLookupByLibrary.simpleMessage("Result"),
"run": MessageLookupByLibrary.simpleMessage("Run"),
"save": MessageLookupByLibrary.simpleMessage("Save"),
"second": MessageLookupByLibrary.simpleMessage("s"),
"server": MessageLookupByLibrary.simpleMessage("Server"),
"serverTabConnecting":
MessageLookupByLibrary.simpleMessage("Connecting..."),
"serverTabEmpty": MessageLookupByLibrary.simpleMessage(
"There is no server.\nClick the fab to add one."),
"serverTabFailed": MessageLookupByLibrary.simpleMessage("Failed"),
"serverTabLoading": MessageLookupByLibrary.simpleMessage("Loading..."),
"serverTabPlzSave": MessageLookupByLibrary.simpleMessage(
"Please \'save\' this private key again."),
"serverTabUnkown":
MessageLookupByLibrary.simpleMessage("Unknown state"),
"setting": MessageLookupByLibrary.simpleMessage("Setting"),
"sftpDlPrepare":
MessageLookupByLibrary.simpleMessage("Preparing to connect..."),
"sftpNoDownloadTask":
MessageLookupByLibrary.simpleMessage("No download task."),
"sftpSSHConnected":
MessageLookupByLibrary.simpleMessage("SFTP Connected"),
"snippet": MessageLookupByLibrary.simpleMessage("Snippet"),
"spentTime": m10,
"start": MessageLookupByLibrary.simpleMessage("Start"),
"stop": MessageLookupByLibrary.simpleMessage("Stop"),
"sureDelete": m11,
"sureToDeleteServer": m12,
"ttl": MessageLookupByLibrary.simpleMessage("TTL"),
"unknown": MessageLookupByLibrary.simpleMessage("unknown"),
"unknownError": MessageLookupByLibrary.simpleMessage("Unknown error"),
"unkownConvertMode":
MessageLookupByLibrary.simpleMessage("Unknown convert mode"),
"updateAll": MessageLookupByLibrary.simpleMessage("Update all"),
"updateIntervalEqual0": MessageLookupByLibrary.simpleMessage(
"You set to 0, will not update automatically.\nYou can pull to refresh manually."),
"updateServerStatusInterval": MessageLookupByLibrary.simpleMessage(
"Server status update interval"),
"upsideDown": MessageLookupByLibrary.simpleMessage("Upside Down"),
"urlOrJson": MessageLookupByLibrary.simpleMessage("URL or JSON"),
"user": MessageLookupByLibrary.simpleMessage("User"),
"versionHaveUpdate": m13,
"versionUnknownUpdate": m14,
"versionUpdated": m15,
"waitConnection": MessageLookupByLibrary.simpleMessage(
"Please wait for the connection to be established."),
"willTakEeffectImmediately":
MessageLookupByLibrary.simpleMessage("Will take effect immediately")
};
}

View File

@@ -0,0 +1,185 @@
// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart
// This is a library that provides messages for a zh locale. All the
// messages from the main program should be duplicated here with the same
// function name.
// Ignore issues from commonly used lints in this file.
// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new
// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering
// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases
// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes
// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes
import 'package:intl/intl.dart';
import 'package:intl/message_lookup_by_library.dart';
final messages = new MessageLookup();
typedef String MessageIfAbsent(String messageStr, List<dynamic> args);
class MessageLookup extends MessageLookupByLibrary {
String get localeName => 'zh';
static String m0(rainSunMeGithub) =>
"\n感谢 ${rainSunMeGithub} 参与软件测试。\n\n保留所有权利。";
static String m1(fileName) => "下载 [${fileName}] 到本地?";
static String m2(runningCount, stoppedCount) =>
"${runningCount}个正在运行, ${stoppedCount}个已停止";
static String m3(count) => "${count}个容器正在运行";
static String m4(percent, size) => "${size}${percent}%";
static String m5(count) => "找到 ${count} 个更新";
static String m6(code) => "请求失败, 状态码: ${code}";
static String m7(url) =>
"请确保正确安装了docker或者使用的非自编译版本。如果没有以上问题请在 ${url} 提交问题。";
static String m8(myGithub) => "\n用❤️制作 by ${myGithub}";
static String m9(url) => "请到 ${url} 提交问题";
static String m10(time) => "耗时: ${time}";
static String m11(name) => "确定删除[${name}]";
static String m12(server) => "你确定要删除服务器 [${server}] 吗?";
static String m13(build) => "找到新版本v1.0.${build}, 点击更新";
static String m14(build) => "当前v1.0.${build}";
static String m15(build) => "当前v1.0.${build}, 已是最新版本";
final messages = _notInlinedMessages(_notInlinedMessages);
static Map<String, Function> _notInlinedMessages(_) => <String, Function>{
"aboutThanks": m0,
"addAServer": MessageLookupByLibrary.simpleMessage("添加服务器"),
"addPrivateKey": MessageLookupByLibrary.simpleMessage("添加一个私钥"),
"alreadyLastDir": MessageLookupByLibrary.simpleMessage("已经是最上层目录了"),
"appPrimaryColor": MessageLookupByLibrary.simpleMessage("App主要色"),
"attention": MessageLookupByLibrary.simpleMessage("注意"),
"backDir": MessageLookupByLibrary.simpleMessage("返回上一级"),
"cancel": MessageLookupByLibrary.simpleMessage("取消"),
"choose": MessageLookupByLibrary.simpleMessage("选择"),
"chooseDestination": MessageLookupByLibrary.simpleMessage("选择目标"),
"choosePrivateKey": MessageLookupByLibrary.simpleMessage("选择私钥"),
"clear": MessageLookupByLibrary.simpleMessage("清除"),
"close": MessageLookupByLibrary.simpleMessage("关闭"),
"containerStatus": MessageLookupByLibrary.simpleMessage("容器状态"),
"convert": MessageLookupByLibrary.simpleMessage("转换"),
"copy": MessageLookupByLibrary.simpleMessage("复制到剪切板"),
"copyPath": MessageLookupByLibrary.simpleMessage("复制路径"),
"createFile": MessageLookupByLibrary.simpleMessage("创建文件"),
"createFolder": MessageLookupByLibrary.simpleMessage("创建文件夹"),
"currentMode": MessageLookupByLibrary.simpleMessage("当前模式"),
"debug": MessageLookupByLibrary.simpleMessage("调试"),
"decode": MessageLookupByLibrary.simpleMessage("解码"),
"delete": MessageLookupByLibrary.simpleMessage("删除"),
"disconnected": MessageLookupByLibrary.simpleMessage("连接断开"),
"dl2Local": m1,
"dockerStatusRunningAndStoppedFmt": m2,
"dockerStatusRunningFmt": m3,
"download": MessageLookupByLibrary.simpleMessage("下载"),
"downloadFinished": MessageLookupByLibrary.simpleMessage("下载完成!"),
"downloadStatus": m4,
"edit": MessageLookupByLibrary.simpleMessage("编辑"),
"encode": MessageLookupByLibrary.simpleMessage("编码"),
"error": MessageLookupByLibrary.simpleMessage("出错了"),
"exampleName": MessageLookupByLibrary.simpleMessage("名称示例"),
"experimentalFeature": MessageLookupByLibrary.simpleMessage("实验性功能"),
"export": MessageLookupByLibrary.simpleMessage("导出"),
"fieldMustNotEmpty": MessageLookupByLibrary.simpleMessage("这些输入框不能为空。"),
"files": MessageLookupByLibrary.simpleMessage("文件"),
"foundNUpdate": m5,
"go": MessageLookupByLibrary.simpleMessage("开始"),
"goSftpDlPage": MessageLookupByLibrary.simpleMessage("前往下载页?"),
"host": MessageLookupByLibrary.simpleMessage("主机"),
"httpFailedWithCode": m6,
"import": MessageLookupByLibrary.simpleMessage("导入"),
"importAndExport": MessageLookupByLibrary.simpleMessage("导入或导出"),
"install": MessageLookupByLibrary.simpleMessage("安装"),
"installDockerWithUrl": MessageLookupByLibrary.simpleMessage(
"请先 https://docs.docker.com/engine/install docker"),
"invalidVersionHelp": m7,
"keepForeground": MessageLookupByLibrary.simpleMessage("请保持应用处于前台!"),
"keyAuth": MessageLookupByLibrary.simpleMessage("公钥认证"),
"lastTry": MessageLookupByLibrary.simpleMessage("最后尝试"),
"launchPage": MessageLookupByLibrary.simpleMessage("启动页"),
"license": MessageLookupByLibrary.simpleMessage("开源证书"),
"loadingFiles": MessageLookupByLibrary.simpleMessage("正在加载目录。。。"),
"loss": MessageLookupByLibrary.simpleMessage("丢包率"),
"madeWithLove": m8,
"max": MessageLookupByLibrary.simpleMessage("最大"),
"min": MessageLookupByLibrary.simpleMessage("最小"),
"ms": MessageLookupByLibrary.simpleMessage("毫秒"),
"name": MessageLookupByLibrary.simpleMessage("名称"),
"noClient": MessageLookupByLibrary.simpleMessage("没有SSH连接"),
"noInterface": MessageLookupByLibrary.simpleMessage("没有可用的接口"),
"noResult": MessageLookupByLibrary.simpleMessage("无结果"),
"noSavedPrivateKey": MessageLookupByLibrary.simpleMessage("没有已保存的私钥。"),
"noSavedSnippet": MessageLookupByLibrary.simpleMessage("没有已保存的代码片段。"),
"noServerAvailable": MessageLookupByLibrary.simpleMessage("没有可用的服务器。"),
"noUpdateAvailable": MessageLookupByLibrary.simpleMessage("没有可用更新"),
"ok": MessageLookupByLibrary.simpleMessage(""),
"open": MessageLookupByLibrary.simpleMessage("打开"),
"ping": MessageLookupByLibrary.simpleMessage("Ping"),
"pingAvg": MessageLookupByLibrary.simpleMessage("平均:"),
"pingInputIP": MessageLookupByLibrary.simpleMessage("请输入目标IP或域名"),
"platformNotSupportUpdate":
MessageLookupByLibrary.simpleMessage("当前平台不支持更新,请编译最新源码后手动安装"),
"plzEnterHost": MessageLookupByLibrary.simpleMessage("请输入主机"),
"plzEnterPwd": MessageLookupByLibrary.simpleMessage("请输入密码"),
"plzSelectKey": MessageLookupByLibrary.simpleMessage("请选择私钥"),
"port": MessageLookupByLibrary.simpleMessage("端口"),
"privateKey": MessageLookupByLibrary.simpleMessage("私钥"),
"pwd": MessageLookupByLibrary.simpleMessage("密码"),
"rename": MessageLookupByLibrary.simpleMessage("重命名"),
"reportBugsOnGithubIssue": m9,
"result": MessageLookupByLibrary.simpleMessage("结果"),
"run": MessageLookupByLibrary.simpleMessage("运行"),
"save": MessageLookupByLibrary.simpleMessage("保存"),
"second": MessageLookupByLibrary.simpleMessage(""),
"server": MessageLookupByLibrary.simpleMessage("服务器"),
"serverTabConnecting": MessageLookupByLibrary.simpleMessage("连接中..."),
"serverTabEmpty":
MessageLookupByLibrary.simpleMessage("现在没有服务器。\n点击右下方按钮来添加。"),
"serverTabFailed": MessageLookupByLibrary.simpleMessage("失败"),
"serverTabLoading": MessageLookupByLibrary.simpleMessage("加载中..."),
"serverTabPlzSave": MessageLookupByLibrary.simpleMessage("请再次保存该私钥"),
"serverTabUnkown": MessageLookupByLibrary.simpleMessage("未知状态"),
"setting": MessageLookupByLibrary.simpleMessage("设置"),
"sftpDlPrepare": MessageLookupByLibrary.simpleMessage("准备连接至服务器..."),
"sftpNoDownloadTask": MessageLookupByLibrary.simpleMessage("没有下载任务"),
"sftpSSHConnected":
MessageLookupByLibrary.simpleMessage("SFTP 已连接,即将开始下载..."),
"snippet": MessageLookupByLibrary.simpleMessage("代码片段"),
"spentTime": m10,
"start": MessageLookupByLibrary.simpleMessage("开始"),
"stop": MessageLookupByLibrary.simpleMessage("停止"),
"sureDelete": m11,
"sureToDeleteServer": m12,
"ttl": MessageLookupByLibrary.simpleMessage("缓存时间"),
"unknown": MessageLookupByLibrary.simpleMessage("未知"),
"unknownError": MessageLookupByLibrary.simpleMessage("未知错误"),
"unkownConvertMode": MessageLookupByLibrary.simpleMessage("未知转换模式"),
"updateAll": MessageLookupByLibrary.simpleMessage("更新全部"),
"updateIntervalEqual0": MessageLookupByLibrary.simpleMessage(
"你设置为0服务器状态不会自动刷新。\n你可以手动下拉刷新。"),
"updateServerStatusInterval":
MessageLookupByLibrary.simpleMessage("服务器状态刷新间隔"),
"upsideDown": MessageLookupByLibrary.simpleMessage("上下交换"),
"urlOrJson": MessageLookupByLibrary.simpleMessage("链接或JSON"),
"user": MessageLookupByLibrary.simpleMessage("用户"),
"versionHaveUpdate": m13,
"versionUnknownUpdate": m14,
"versionUpdated": m15,
"waitConnection": MessageLookupByLibrary.simpleMessage("请等待连接建立"),
"willTakEeffectImmediately":
MessageLookupByLibrary.simpleMessage("更改将会立即生效")
};
}

1250
lib/generated/l10n.dart Normal file

File diff suppressed because it is too large Load Diff

119
lib/l10n/intl_en.arb Normal file
View File

@@ -0,0 +1,119 @@
{
"server": "Server",
"convert": "Convert",
"ping": "Ping",
"debug": "Debug",
"addAServer": "add a server",
"setting": "Setting",
"license": "License",
"snippet": "Snippet",
"privateKey": "Private Key",
"madeWithLove": "\nMade with ❤️ by {myGithub}",
"aboutThanks": "\nThanks {rainSunMeGithub} for participating in the test.\n\nAll rights reserved.",
"serverTabEmpty": "There is no server.\nClick the fab to add one.",
"serverTabLoading": "Loading...",
"serverTabPlzSave": "Please 'save' this private key again.",
"serverTabFailed": "Failed",
"serverTabUnkown": "Unknown state",
"serverTabConnecting": "Connecting...",
"decode": "Decode",
"encode": "Encode",
"currentMode": "Current Mode",
"unkownConvertMode": "Unknown convert mode",
"copy": "Copy",
"upsideDown": "Upside Down",
"pingAvg": "Avg:",
"unknown": "unknown",
"min": "min",
"max": "max",
"ms": "ms",
"ttl": "TTL",
"loss": "Loss",
"noResult": "No result",
"pingInputIP": "Please input a target IP/domain.",
"clear": "Clear",
"start": "Start",
"appPrimaryColor": "App primary color",
"updateServerStatusInterval": "Server status update interval",
"willTakEeffectImmediately": "Will take effect immediately",
"launchPage": "Launch page",
"versionUpdated": "Current: v1.0.{build}, is up to date",
"versionUnknownUpdate": "Current: v1.0.{build}",
"versionHaveUpdate": "Found: v1.0.{build}, click to update",
"second": "s",
"updateIntervalEqual0": "You set to 0, will not update automatically.\nYou can pull to refresh manually.",
"edit": "Edit",
"noSavedPrivateKey": "No saved private keys.",
"name": "Name",
"pwd": "Password",
"save": "Save",
"delete": "Delete",
"fieldMustNotEmpty": "These fields must not be empty.",
"importAndExport": "Import and Export",
"choose": "Choose",
"import": "Import",
"export": "Export",
"ok": "OK",
"cancel": "Cancel",
"urlOrJson": "URL or JSON",
"go": "Go",
"httpFailedWithCode": "request failed, status code: {code}",
"run": "Run",
"noSavedSnippet": "No saved snippets.",
"chooseDestination": "Choose destination",
"noServerAvailable": "No server available.",
"result": "Result",
"close": "Close",
"attention": "Attention",
"sureToDeleteServer": "Are you sure to delete server [{server}]?",
"host": "Host",
"port": "Port",
"user": "User",
"keyAuth": "Key Auth",
"addPrivateKey": "Add private key",
"choosePrivateKey": "Choose private key",
"plzEnterHost": "Please enter host.",
"plzEnterPwd": "Please enter password.",
"plzSelectKey": "Please select a key.",
"exampleName": "Example name",
"stop": "Stop",
"download": "Download",
"copyPath": "Copy path",
"keepForeground": "Keep app foreground!",
"downloadFinished": "Download finished",
"downloadStatus": "{percent}% of {size}",
"sftpDlPrepare": "Preparing to connect...",
"sftpSSHConnected": "SFTP Connected",
"spentTime": "Spent time: {time}",
"backDir": "Back",
"alreadyLastDir": "Already in last directory.",
"open": "Open",
"sureDelete": "Are you sure to delete [{name}]?",
"containerStatus": "Container status",
"noClient": "No client",
"installDockerWithUrl": "Please https://docs.docker.com/engine/install docker first.",
"waitConnection": "Please wait for the connection to be established.",
"unknownError": "Unknown error",
"dockerStatusRunningFmt": "{count} container running.",
"dockerStatusRunningAndStoppedFmt": "{runningCount} running, {stoppedCount} container stopped.",
"install": "install",
"loadingFiles": "Loading files...",
"sftpNoDownloadTask": "No download task.",
"goSftpDlPage": "Go to SFTP download page?",
"createFolder": "Create folder",
"createFile": "Create file",
"rename": "Rename",
"dl2Local": "Download [{fileName}] to local?",
"error": "Error",
"disconnected": "Disconnected",
"files": "Files",
"experimentalFeature": "Experimental feature",
"reportBugsOnGithubIssue": "Please report bugs on {url}",
"noUpdateAvailable": "No update available",
"foundNUpdate": "Found {count} update",
"updateAll": "Update all",
"platformNotSupportUpdate": "Current platform does not support in app update.\nPlease build from source and install it.",
"invalidVersionHelp": "Please make sure that docker is installed correctly, or that you are using a non-self-compiled version. If you don't have the above issues, please submit an issue on {url}.",
"noInterface": "No interface",
"lastTry": "Last try!"
}

119
lib/l10n/intl_zh.arb Normal file
View File

@@ -0,0 +1,119 @@
{
"server": "服务器",
"convert": "转换",
"ping": "Ping",
"debug": "调试",
"addAServer": "添加服务器",
"setting": "设置",
"license": "开源证书",
"snippet": "代码片段",
"privateKey": "私钥",
"madeWithLove": "\n用❤制作 by {myGithub}",
"aboutThanks": "\n感谢 {rainSunMeGithub} 参与软件测试。\n\n保留所有权利。",
"serverTabEmpty": "现在没有服务器。\n点击右下方按钮来添加。",
"serverTabLoading": "加载中...",
"serverTabPlzSave": "请再次保存该私钥",
"serverTabFailed": "失败",
"serverTabUnkown": "未知状态",
"serverTabConnecting": "连接中...",
"decode": "解码",
"encode": "编码",
"currentMode": "当前模式",
"unkownConvertMode": "未知转换模式",
"copy": "复制到剪切板",
"upsideDown": "上下交换",
"pingAvg": "平均:",
"unknown": "未知",
"min": "最小",
"max": "最大",
"ms": "毫秒",
"ttl": "缓存时间",
"loss": "丢包率",
"noResult": "无结果",
"pingInputIP": "请输入目标IP或域名",
"clear": "清除",
"start": "开始",
"appPrimaryColor": "App主要色",
"updateServerStatusInterval": "服务器状态刷新间隔",
"willTakEeffectImmediately": "更改将会立即生效",
"launchPage": "启动页",
"versionUpdated": "当前v1.0.{build}, 已是最新版本",
"versionUnknownUpdate": "当前v1.0.{build}",
"versionHaveUpdate": "找到新版本v1.0.{build}, 点击更新",
"second": "秒",
"updateIntervalEqual0": "你设置为0服务器状态不会自动刷新。\n你可以手动下拉刷新。",
"edit": "编辑",
"noSavedPrivateKey": "没有已保存的私钥。",
"name": "名称",
"pwd": "密码",
"save": "保存",
"delete": "删除",
"fieldMustNotEmpty": "这些输入框不能为空。",
"importAndExport": "导入或导出",
"choose": "选择",
"import": "导入",
"export": "导出",
"ok": "好",
"cancel": "取消",
"urlOrJson": "链接或JSON",
"go": "开始",
"httpFailedWithCode": "请求失败, 状态码: {code}",
"run": "运行",
"noSavedSnippet": "没有已保存的代码片段。",
"chooseDestination": "选择目标",
"noServerAvailable": "没有可用的服务器。",
"result": "结果",
"close": "关闭",
"attention": "注意",
"sureToDeleteServer": "你确定要删除服务器 [{server}] 吗?",
"host": "主机",
"port": "端口",
"user": "用户",
"keyAuth": "公钥认证",
"addPrivateKey": "添加一个私钥",
"choosePrivateKey": "选择私钥",
"plzEnterHost": "请输入主机",
"plzEnterPwd": "请输入密码",
"plzSelectKey": "请选择私钥",
"exampleName": "名称示例",
"stop": "停止",
"download": "下载",
"copyPath": "复制路径",
"keepForeground": "请保持应用处于前台!",
"downloadFinished": "下载完成!",
"downloadStatus": "{size} 的 {percent}%",
"sftpDlPrepare": "准备连接至服务器...",
"sftpSSHConnected": "SFTP 已连接,即将开始下载...",
"spentTime": "耗时: {time}",
"backDir": "返回上一级",
"alreadyLastDir": "已经是最上层目录了",
"open": "打开",
"sureDelete": "确定删除[{name}]",
"containerStatus": "容器状态",
"noClient": "没有SSH连接",
"installDockerWithUrl": "请先 https://docs.docker.com/engine/install docker",
"waitConnection": "请等待连接建立",
"unknownError": "未知错误",
"dockerStatusRunningFmt": "{count}个容器正在运行",
"dockerStatusRunningAndStoppedFmt": "{runningCount}个正在运行, {stoppedCount}个已停止",
"install": "安装",
"loadingFiles": "正在加载目录。。。",
"sftpNoDownloadTask": "没有下载任务",
"goSftpDlPage": "前往下载页?",
"createFolder": "创建文件夹",
"createFile": "创建文件",
"rename": "重命名",
"dl2Local": "下载 [{fileName}] 到本地?",
"error": "出错了",
"disconnected": "连接断开",
"files": "文件",
"experimentalFeature": "实验性功能",
"reportBugsOnGithubIssue": "请到 {url} 提交问题",
"noUpdateAvailable": "没有可用更新",
"foundNUpdate": "找到 {count} 个更新",
"updateAll": "更新全部",
"platformNotSupportUpdate": "当前平台不支持更新,请编译最新源码后手动安装",
"invalidVersionHelp": "请确保正确安装了docker或者使用的非自编译版本。如果没有以上问题请在 {url} 提交问题。",
"noInterface": "没有可用的接口",
"lastTry": "最后尝试"
}

View File

@@ -1,8 +1,11 @@
import 'package:get_it/get_it.dart';
import 'package:toolbox/data/provider/app.dart';
import 'package:toolbox/data/provider/apt.dart';
import 'package:toolbox/data/provider/debug.dart';
import 'package:toolbox/data/provider/docker.dart';
import 'package:toolbox/data/provider/private_key.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/provider/snippet.dart';
import 'package:toolbox/data/service/app.dart';
import 'package:toolbox/data/store/private_key.dart';
@@ -18,10 +21,13 @@ void setupLocatorForServices() {
void setupLocatorForProviders() {
locator.registerSingleton(AppProvider());
locator.registerSingleton(AptProvider());
locator.registerSingleton(DebugProvider());
locator.registerSingleton(DockerProvider());
locator.registerSingleton(ServerProvider());
locator.registerSingleton(SnippetProvider());
locator.registerSingleton(PrivateKeyProvider());
locator.registerSingleton(SftpDownloadProvider());
}
Future<void> setupLocatorForStores() async {

View File

@@ -7,9 +7,12 @@ import 'package:provider/provider.dart';
import 'package:toolbox/app.dart';
import 'package:toolbox/core/analysis.dart';
import 'package:toolbox/data/provider/app.dart';
import 'package:toolbox/data/provider/apt.dart';
import 'package:toolbox/data/provider/debug.dart';
import 'package:toolbox/data/provider/docker.dart';
import 'package:toolbox/data/provider/private_key.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/provider/snippet.dart';
import 'package:toolbox/locator.dart';
@@ -62,10 +65,14 @@ Future<void> main() async {
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => locator<AppProvider>()),
ChangeNotifierProvider(create: (_) => locator<AptProvider>()),
ChangeNotifierProvider(create: (_) => locator<DebugProvider>()),
ChangeNotifierProvider(create: (_) => locator<DockerProvider>()),
ChangeNotifierProvider(create: (_) => locator<ServerProvider>()),
ChangeNotifierProvider(create: (_) => locator<SnippetProvider>()),
ChangeNotifierProvider(create: (_) => locator<PrivateKeyProvider>()),
ChangeNotifierProvider(
create: (_) => locator<SftpDownloadProvider>()),
],
child: const MyApp(),
),

236
lib/view/page/apt.dart Normal file
View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/apt/upgrade_pkg_info.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/provider/apt.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/url.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/center_loading.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
import 'package:toolbox/view/widget/two_line_text.dart';
import 'package:toolbox/view/widget/url_text.dart';
class AptManagePage extends StatefulWidget {
const AptManagePage(this.spi, {Key? key}) : super(key: key);
final ServerPrivateInfo spi;
@override
_AptManagePageState createState() => _AptManagePageState();
}
class _AptManagePageState extends State<AptManagePage>
with SingleTickerProviderStateMixin {
late MediaQueryData _media;
final greyStyle = const TextStyle(color: Colors.grey);
final scrollController = ScrollController();
final scrollControllerUpdate = ScrollController();
final _aptProvider = locator<AptProvider>();
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
s = S.of(context);
_aptProvider.refreshInstalled();
}
@override
void dispose() {
super.dispose();
locator<AptProvider>().clear();
}
@override
void initState() {
super.initState();
final si = locator<ServerProvider>()
.servers
.firstWhere((e) => e.info == widget.spi);
if (si.client == null) {
showSnackBar(context, Text(s.waitConnection));
Navigator.of(context).pop();
return;
}
// ignore: prefer_function_declarations_over_variables
PwdRequestFunc onPwdRequest = (lastTime, user) async {
if (!mounted) return '';
final textController = TextEditingController();
await showRoundDialog(
context,
lastTime ? s.lastTry : (user ?? s.unknown),
TextField(
controller: textController,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
onSubmitted: (_) => textController.text.trim(),
decoration: InputDecoration(
labelText: s.pwd,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
if (textController.text == '') {
showRoundDialog(
context, s.attention, Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
Navigator.of(context).pop();
},
child: Text(
s.ok,
style: const TextStyle(color: Colors.red),
)),
]);
return textController.text.trim();
};
_aptProvider.init(
si.client!,
si.status.sysVer.dist,
() =>
scrollController.jumpTo(scrollController.position.maxScrollExtent),
() => scrollControllerUpdate
.jumpTo(scrollControllerUpdate.position.maxScrollExtent),
onPwdRequest);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: TwoLineText(up: 'Apt', down: widget.spi.name),
),
body: Consumer<AptProvider>(builder: (_, apt, __) {
if (apt.error != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error,
color: Colors.redAccent,
size: 37,
),
const SizedBox(
height: 37,
),
Text(
apt.error!,
textAlign: TextAlign.center,
),
],
);
}
if (apt.updateLog == null && apt.upgradeable == null) {
return centerLoading;
}
if (apt.updateLog != null && apt.upgradeable == null) {
return SizedBox(
height: _media.size.height * 0.7,
child: ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(18),
controller: scrollControllerUpdate,
child: Text(apt.updateLog!),
),
));
}
return ListView(
padding: const EdgeInsets.all(13),
children: [
Padding(
padding: const EdgeInsets.all(17),
child: UrlText(
text:
'${s.experimentalFeature}\n${s.reportBugsOnGithubIssue(issueUrl)}',
replace: 'Github Issue',
textAlign: TextAlign.center,
),
),
_buildUpdatePanel(apt)
].map((e) => RoundRectCard(e)).toList(),
);
}),
);
}
Widget _buildUpdatePanel(AptProvider apt) {
if (apt.upgradeable!.isEmpty) {
return ListTile(
title: Text(
s.noUpdateAvailable,
textAlign: TextAlign.center,
),
subtitle: const Text('>_<', textAlign: TextAlign.center),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExpansionTile(
title: Text(s.foundNUpdate(apt.upgradeable!.length)),
subtitle: Text(
apt.upgradeable!.map((e) => e.package).join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: greyStyle,
),
children: apt.upgradeLog == null
? [
TextButton(
child: Text(s.updateAll),
onPressed: () {
apt.upgrade();
}),
SizedBox(
height: _media.size.height * 0.73,
child: ListView(
controller: scrollController,
children: apt.upgradeable!
.map((e) => _buildUpdateItem(e, apt))
.toList()),
)
]
: [
SizedBox(
height: _media.size.height * 0.7,
child: ConstrainedBox(
constraints: const BoxConstraints.expand(),
child: SingleChildScrollView(
padding: const EdgeInsets.all(18),
controller: scrollController,
child: Text(apt.upgradeLog!),
),
))
],
)
],
);
}
Widget _buildUpdateItem(UpgradePkgInfo info, AptProvider apt) {
return ListTile(
title: Text(info.package),
subtitle: Text(
'${info.nowVersion} -> ${info.newVersion}',
style: greyStyle,
),
);
}
}

View File

@@ -2,8 +2,11 @@ import 'dart:convert';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/widget/input_field.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class ConvertPage extends StatefulWidget {
const ConvertPage({Key? key}) : super(key: key);
@@ -18,13 +21,8 @@ class _ConvertPageState extends State<ConvertPage>
late TextEditingController _textEditingControllerResult;
late MediaQueryData _media;
late ThemeData _theme;
late S s;
static const List<String> _typeOption = [
'base64 decode',
'base64 encode',
'URL encode',
'URL decode'
];
int _typeOptionIndex = 0;
@override
@@ -39,6 +37,7 @@ class _ConvertPageState extends State<ConvertPage>
super.didChangeDependencies();
_media = MediaQuery.of(context);
_theme = Theme.of(context);
s = S.of(context);
}
@override
@@ -47,39 +46,32 @@ class _ConvertPageState extends State<ConvertPage>
return Scaffold(
body: GestureDetector(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: AnimationLimiter(
child: Column(
children: AnimationConfiguration.toStaggeredList(
duration: const Duration(milliseconds: 377),
childAnimationBuilder: (widget) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: widget,
),
),
children: [
padding: const EdgeInsets.symmetric(horizontal: 7),
controller: ScrollController(),
child: Column(children: [
const SizedBox(height: 13),
_buildInputTop(),
_buildTypeOption(),
_buildResult(),
],
))),
),
])),
onTap: () => FocusScope.of(context).requestFocus(FocusNode()),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
_textEditingControllerResult.text = doConvert();
try {
_textEditingControllerResult.text = doConvert();
} catch (e) {
showSnackBar(context, Text('Error: \n$e'));
}
},
tooltip: 'convert',
tooltip: s.convert,
child: const Icon(Icons.send),
),
);
}
String doConvert() {
final text = _textEditingController.text;
final text = _textEditingController.text.trim();
switch (_typeOptionIndex) {
case 0:
return utf8.decode(base64.decode(text));
@@ -90,20 +82,29 @@ class _ConvertPageState extends State<ConvertPage>
case 3:
return Uri.decodeFull(text);
default:
return 'Unknown';
return s.unkownConvertMode;
}
}
Widget _buildInputTop() {
return SizedBox(
height: _media.size.height * 0.33,
child: _buildInput(_textEditingController),
child: buildInput(context, _textEditingController,
onSubmitted: (_) => _textEditingControllerResult.text = doConvert()),
);
}
Widget _buildTypeOption() {
return Card(
child: ExpansionTile(
final decode = s.decode;
final encode = s.encode;
final List<String> typeOption = [
'Base64 $decode',
'Base64 $encode',
'URL $encode',
'URL $decode'
];
return RoundRectCard(
ExpansionTile(
tilePadding: const EdgeInsets.only(left: 7, right: 27),
childrenPadding: EdgeInsets.zero,
title: Row(
@@ -111,7 +112,7 @@ class _ConvertPageState extends State<ConvertPage>
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(primaryColor)),
child: const Icon(Icons.change_circle),
child: Icon(Icons.change_circle, semanticLabel: s.upsideDown),
onPressed: () {
final temp = _textEditingController.text;
_textEditingController.text = _textEditingControllerResult.text;
@@ -121,7 +122,7 @@ class _ConvertPageState extends State<ConvertPage>
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(primaryColor)),
child: const Icon(Icons.copy),
child: Icon(Icons.copy, semanticLabel: s.copy),
onPressed: () => FlutterClipboard.copy(
_textEditingControllerResult.text == ''
? ' '
@@ -135,23 +136,23 @@ class _ConvertPageState extends State<ConvertPage>
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_typeOption[_typeOptionIndex],
Text(typeOption[_typeOptionIndex],
textScaleFactor: 1.0,
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w500,
color: primaryColor)),
const Text(
'Current Mode',
Text(
s.currentMode,
textScaleFactor: 1.0,
textAlign: TextAlign.right,
style: TextStyle(fontSize: 9.0, color: Colors.grey),
style: const TextStyle(fontSize: 9.0, color: Colors.grey),
)
],
),
),
children: _typeOption
children: typeOption
.map((e) => ListTile(
title: Text(
e,
@@ -159,7 +160,7 @@ class _ConvertPageState extends State<ConvertPage>
color:
_theme.textTheme.bodyText2!.color!.withAlpha(177)),
),
trailing: _buildRadio(_typeOption.indexOf(e)),
trailing: _buildRadio(typeOption.indexOf(e)),
))
.toList(),
),
@@ -169,20 +170,7 @@ class _ConvertPageState extends State<ConvertPage>
Widget _buildResult() {
return SizedBox(
height: _media.size.height * 0.33,
child: _buildInput(_textEditingControllerResult),
);
}
Widget _buildInput(TextEditingController controller) {
return Card(
child: TextField(
maxLines: 20,
decoration: InputDecoration(
fillColor: Theme.of(context).cardColor,
filled: true,
border: InputBorder.none),
controller: controller,
),
child: buildInput(context, _textEditingControllerResult),
);
}

View File

@@ -14,7 +14,7 @@ class _DebugPageState extends State<DebugPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar:
AppBar(title: const Text('Terminal'), backgroundColor: Colors.black),
AppBar(title: const Text('App log'), backgroundColor: Colors.black),
body: _buildTerminal(context),
backgroundColor: Colors.black,
);

209
lib/view/page/docker.dart Normal file
View File

@@ -0,0 +1,209 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/app/menu_item.dart';
import 'package:toolbox/data/model/docker/ps.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/provider/docker.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/url.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/center_loading.dart';
import 'package:toolbox/view/widget/two_line_text.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
import 'package:toolbox/view/widget/url_text.dart';
class DockerManagePage extends StatefulWidget {
final ServerPrivateInfo spi;
const DockerManagePage(this.spi, {Key? key}) : super(key: key);
@override
State<DockerManagePage> createState() => _DockerManagePageState();
}
class _DockerManagePageState extends State<DockerManagePage> {
final _docker = locator<DockerProvider>();
final greyTextStyle = const TextStyle(color: Colors.grey);
late S s;
@override
void dispose() {
super.dispose();
_docker.clear();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
void initState() {
super.initState();
final client = locator<ServerProvider>()
.servers
.firstWhere((element) => element.info == widget.spi)
.client;
if (client == null) {
showSnackBar(context, Text(s.noClient));
Navigator.of(context).pop();
return;
}
_docker.init(client);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: TwoLineText(up: 'Docker', down: widget.spi.name),
),
body: _buildMain(),
);
}
Widget _buildMain() {
return Consumer<DockerProvider>(builder: (_, docker, __) {
final running = docker.items;
if (docker.error != null && running == null) {
return SizedBox.expand(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.error,
size: 37,
),
const SizedBox(height: 27),
Text(docker.error!),
const SizedBox(height: 27),
Padding(
padding: const EdgeInsets.all(17),
child: _buildSolution(docker.error!),
)
],
),
);
}
if (running == null) {
_docker.refresh();
return centerLoading;
}
return ListView(
padding: const EdgeInsets.all(7),
children: [
_buildVersion(
docker.edition ?? s.unknown, docker.version ?? s.unknown),
_buildPsItems(running, docker)
].map((e) => RoundRectCard(e)).toList(),
);
});
}
Widget _buildSolution(String err) {
switch (err) {
case 'docker not found':
return UrlText(
text: s.installDockerWithUrl,
replace: s.install,
);
case 'no client':
return Text(s.waitConnection);
case 'invalid version':
return UrlText(
text: s.invalidVersionHelp(issueUrl),
replace: 'Github',
);
default:
return Text(s.unknownError);
}
}
Widget _buildVersion(String edition, String version) {
return Padding(
padding: const EdgeInsets.all(17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(edition), Text(version)],
),
);
}
Widget _buildPsItems(List<DockerPsItem> running, DockerProvider docker) {
return ExpansionTile(
title: Text(s.containerStatus),
subtitle: Text(_buildSubtitle(running), style: greyTextStyle),
children: running.map((item) {
return ListTile(
title: Text(item.image),
subtitle: Text(item.status),
trailing: docker.isBusy
? const CircularProgressIndicator()
: _buildMoreBtn(item.running, item.containerId),
);
}).toList(),
);
}
Widget _buildMoreBtn(bool running, String containerId) {
final item = running ? DockerMenuItems.stop : DockerMenuItems.start;
return DropdownButtonHideUnderline(
child: DropdownButton2(
customButton: const Padding(
padding: EdgeInsets.only(left: 7),
child: Icon(
Icons.more_vert,
size: 17,
),
),
customItemsHeight: 8,
items: [
DropdownMenuItem<DropdownBtnItem>(
value: item,
child: item.build,
),
DropdownMenuItem<DropdownBtnItem>(
value: DockerMenuItems.rm,
child: DockerMenuItems.rm.build,
),
],
onChanged: (value) {
final item = value as DropdownBtnItem;
switch (item) {
case DockerMenuItems.rm:
_docker.delete(containerId);
break;
case DockerMenuItems.start:
_docker.start(containerId);
break;
case DockerMenuItems.stop:
_docker.stop(containerId);
break;
}
},
itemHeight: 37,
itemPadding: const EdgeInsets.only(left: 17, right: 17),
dropdownWidth: 133,
dropdownDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
),
dropdownElevation: 8,
offset: const Offset(0, 8),
),
);
}
String _buildSubtitle(List<DockerPsItem> running) {
final runningCount = running.where((element) => element.running).length;
final stoped = running.length - runningCount;
if (stoped == 0) {
return s.dockerStatusRunningFmt(runningCount);
}
return s.dockerStatusRunningAndStoppedFmt(runningCount, stoped);
}
}

View File

@@ -1,21 +1,30 @@
import 'package:after_layout/after_layout.dart';
import 'package:flutter/material.dart';
import 'package:flutter_advanced_drawer/flutter_advanced_drawer.dart';
import 'package:get_it/get_it.dart';
import 'package:toolbox/core/analysis.dart';
import 'package:toolbox/core/build_mode.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/update.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/app/navigation_item.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/build_data.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/icon/common.dart';
import 'package:toolbox/data/res/tab.dart';
import 'package:toolbox/data/res/url.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/convert.dart';
import 'package:toolbox/view/page/debug.dart';
import 'package:toolbox/view/page/ping.dart';
import 'package:toolbox/view/page/private_key/list.dart';
import 'package:toolbox/view/page/server/tab.dart';
import 'package:toolbox/view/page/setting.dart';
import 'package:toolbox/view/page/sftp/downloaded.dart';
import 'package:toolbox/view/page/snippet/list.dart';
import 'package:toolbox/view/widget/url_text.dart';
@@ -31,84 +40,229 @@ class _MyHomePageState extends State<MyHomePage>
with
AutomaticKeepAliveClientMixin,
SingleTickerProviderStateMixin,
AfterLayoutMixin {
final List<String> _tabs = ['Servers', 'En/Decode'];
late final TabController _tabController;
AfterLayoutMixin,
WidgetsBindingObserver {
late final ServerProvider _serverProvider;
late final PageController _pageController;
late final AdvancedDrawerController _advancedDrawerController;
late int _selectIndex;
late double _width;
late S s;
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabs.length, vsync: this);
_serverProvider = locator<ServerProvider>();
WidgetsBinding.instance.addObserver(this);
_selectIndex = locator<SettingStore>().launchPage.fetch()!;
_pageController = PageController(initialPage: _selectIndex);
_advancedDrawerController = AdvancedDrawerController();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
_width = MediaQuery.of(context).size.width;
}
@override
void dispose() {
super.dispose();
WidgetsBinding.instance.removeObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.paused) {
_serverProvider.setDisconnected();
_serverProvider.stopAutoRefresh();
}
if (state == AppLifecycleState.resumed) {
_serverProvider.startAutoRefresh();
}
}
@override
Widget build(BuildContext context) {
setTransparentNavigationBar(context);
super.build(context);
return Scaffold(
appBar: AppBar(
title: GestureDetector(
onLongPress: () =>
AppRoute(const DebugPage(), 'Debug Page').go(context),
child: const Text('ToolBox'),
),
bottom: TabBar(
indicatorColor: widget.primaryColor,
tabs: _tabs.map((e) => Tab(text: e)).toList(),
controller: _tabController,
return WillPopScope(
child: _buildMain(context),
onWillPop: () {
if (_advancedDrawerController.value.visible) {
_advancedDrawerController.hideDrawer();
return Future.value(false);
}
return Future.value(true);
});
}
Widget _buildMain(BuildContext context) {
return AdvancedDrawer(
controller: _advancedDrawerController,
animationCurve: Curves.easeInOut,
animationDuration: const Duration(milliseconds: 300),
animateChildDecoration: true,
rtlOpening: false,
childDecoration: const BoxDecoration(
// NOTICE: Uncomment if you want to add shadow behind the page.
// Keep in mind that it may cause animation jerks.
// boxShadow: <BoxShadow>[
// BoxShadow(
// color: Colors.black12,
// blurRadius: 0.0,
// ),
// ],
borderRadius: BorderRadius.all(Radius.circular(16)),
),
drawer: _buildDrawer(),
child: Scaffold(
appBar: AppBar(
title: Text(tabTitleName(context, _selectIndex), style: size18),
actions: [
IconButton(
icon: const Icon(Icons.developer_mode, size: 23),
tooltip: s.debug,
onPressed: () =>
AppRoute(const DebugPage(), 'Debug Page').go(context),
),
],
leading: IconButton(
onPressed: () => _advancedDrawerController.showDrawer(),
icon: ValueListenableBuilder<AdvancedDrawerValue>(
valueListenable: _advancedDrawerController,
builder: (_, value, __) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: Icon(
value.visible ? Icons.clear : Icons.menu,
key: ValueKey<bool>(value.visible),
),
);
},
),
),
),
body: PageView(
physics: const ClampingScrollPhysics(),
controller: _pageController,
onPageChanged: (i) {
FocusScope.of(context).unfocus();
_selectIndex = i;
setState(() {});
},
children: const [ServerPage(), ConvertPage(), PingPage()],
),
bottomNavigationBar: _buildBottom(context),
));
}
Widget _buildItem(int idx, NavigationItem item, bool isSelected) {
bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
final width = _width / tabItems.length;
return AnimatedContainer(
duration: const Duration(milliseconds: 377),
curve: Curves.fastOutSlowIn,
height: 50,
width: isSelected ? width : width - 17,
decoration: BoxDecoration(
color: isSelected
? isDarkMode
? Colors.white12
: Colors.black.withOpacity(0.07)
: Colors.transparent,
borderRadius: const BorderRadius.all(Radius.circular(50))),
child: IconButton(
icon: Icon(item.icon),
tooltip: tabTitleName(context, idx),
splashRadius: width / 3.3,
padding: const EdgeInsets.only(left: 17, right: 17),
onPressed: () {
setState(() {
_pageController.animateToPage(idx,
duration: const Duration(milliseconds: 677),
curve: Curves.fastLinearToSlowEaseIn);
});
},
),
drawer: _buildDrawer(),
body: TabBarView(controller: _tabController, children: const [
ServerPage(),
ConvertPage(),
]),
);
}
Widget _buildBottom(BuildContext context) {
return SafeArea(
child: Container(
height: 56,
padding: const EdgeInsets.only(left: 8, top: 4, bottom: 4, right: 8),
width: _width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: tabItems.map((item) {
int itemIndex = tabItems.indexOf(item);
return _buildItem(itemIndex, item, _selectIndex == itemIndex);
}).toList(),
),
));
}
Widget _buildDrawer() {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
return SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
UserAccountsDrawerHeader(
accountName: const Text(BuildData.name),
accountEmail: Text(_buildVersionStr()),
currentAccountPicture: _buildIcon(),
_buildIcon(),
const Text(BuildData.name),
Text(_buildVersionStr()),
SizedBox(
height: MediaQuery.of(context).size.height * 0.07,
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Setting'),
onTap: () => AppRoute(const SettingPage(), 'Setting').go(context),
),
ListTile(
leading: const Icon(Icons.vpn_key),
title: const Text('Private Key'),
onTap: () =>
AppRoute(const StoredPrivateKeysPage(), 'private key list')
.go(context),
),
ListTile(
leading: const Icon(Icons.snippet_folder),
title: const Text('Snippet'),
onTap: () =>
AppRoute(const SnippetListPage(), 'snippet list').go(context),
),
AboutListTile(
icon: const Icon(Icons.text_snippet),
child: const Text('Licences'),
applicationName: BuildData.name,
applicationVersion: _buildVersionStr(),
applicationIcon: _buildIcon(),
aboutBoxChildren: const [
UrlText(
text: '\nMade with ❤️ by $myGithub', replace: 'LollipopKit'),
UrlText(
text:
'\nThanks $rainSunMeGithub for participating in the test.\n\nAll rights reserved.',
replace: 'RainSunMe',
),
],
Padding(
padding: const EdgeInsets.only(left: 29),
child: Column(
children: [
ListTile(
leading: const Icon(Icons.settings),
title: Text(s.setting),
onTap: () =>
AppRoute(const SettingPage(), 'Setting').go(context),
),
ListTile(
leading: const Icon(Icons.vpn_key),
title: Text(s.privateKey),
onTap: () => AppRoute(
const StoredPrivateKeysPage(), 'private key list')
.go(context),
),
ListTile(
leading: const Icon(Icons.download),
title: Text(s.download),
onTap: () =>
AppRoute(const SFTPDownloadedPage(), 'snippet list')
.go(context),
),
ListTile(
leading: const Icon(Icons.snippet_folder),
title: Text(s.snippet),
onTap: () => AppRoute(const SnippetListPage(), 'snippet list')
.go(context),
),
AboutListTile(
icon: const Icon(Icons.text_snippet),
applicationName: BuildData.name,
applicationVersion: _buildVersionStr(),
applicationIcon: _buildIcon(),
aboutBoxChildren: [
UrlText(
text: s.madeWithLove(myGithub), replace: 'LollipopKit'),
UrlText(
text: s.aboutThanks(rainSunMeGithub),
replace: 'RainSunMe',
),
],
child: Text(s.license),
)
],
),
),
],
),
@@ -116,14 +270,28 @@ class _MyHomePageState extends State<MyHomePage>
}
Widget _buildIcon() {
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 60, maxWidth: 60),
child: appIcon,
return Stack(
alignment: Alignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 53, maxWidth: 53),
child: Container(
color: primaryColor,
)),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 83, maxWidth: 83),
child: appIcon,
)
],
);
}
String _buildVersionStr() {
return 'Ver: 1.0.${BuildData.build}';
var mod = '';
if (BuildData.modifications != 0) {
mod = '(+${BuildData.modifications})';
}
return 'Ver: 1.0.${BuildData.build}$mod';
}
@override

174
lib/view/page/ping.dart Normal file
View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:toolbox/core/extension/uint8list.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/ping_result.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/input_field.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class PingPage extends StatefulWidget {
const PingPage({Key? key}) : super(key: key);
@override
_PingPageState createState() => _PingPageState();
}
class _PingPageState extends State<PingPage>
with AutomaticKeepAliveClientMixin {
late TextEditingController _textEditingController;
late MediaQueryData _media;
final List<PingResult> _results = [];
late S s;
static const summaryTextStyle = TextStyle(
fontSize: 12,
);
@override
void initState() {
super.initState();
_textEditingController = TextEditingController(text: '');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
s = S.of(context);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: GestureDetector(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: Column(children: [
const SizedBox(height: 13),
buildInput(context, _textEditingController,
maxLines: 1, onSubmitted: (_) => doPing()),
_buildControl(),
SizedBox(
width: double.infinity,
height: _media.size.height * 0.6,
child: ListView.builder(
controller: ScrollController(),
itemCount: _results.length,
itemBuilder: (context, index) {
final result = _results[index];
return _buildResultItem(result);
}),
),
])),
onTap: () => FocusScope.of(context).requestFocus(FocusNode()),
),
);
}
Widget _buildResultItem(PingResult result) {
final unknown = s.unknown;
final ms = s.ms;
return RoundRectCard(ListTile(
contentPadding: const EdgeInsets.symmetric(vertical: 7, horizontal: 17),
title: Text(result.serverName,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold, color: primaryColor)),
subtitle: Text(
_buildPingSummary(result, unknown, ms),
style: summaryTextStyle,
),
trailing: Text(
'${s.pingAvg}${result.statistic?.avg?.toStringAsFixed(2) ?? s.unknown} $ms',
style: TextStyle(fontSize: 14, color: primaryColor)),
));
}
String _buildPingSummary(PingResult result, String unknown, String ms) {
final ip = result.ip ?? unknown;
if (result.results == null || result.results!.isEmpty) {
return '$ip - ${s.noResult}';
}
final ttl = result.results?.first.ttl ?? unknown;
final loss = result.statistic?.loss ?? unknown;
final min = result.statistic?.min ?? unknown;
final max = result.statistic?.max ?? unknown;
return '$ip\n${s.ttl}: $ttl, ${s.loss}: $loss%\n${s.min}: $min $ms, ${s.max}: $max $ms';
}
Future<void> doPing() async {
_results.clear();
final target = _textEditingController.text.trim();
if (target.isEmpty) {
showSnackBar(context, Text(s.pingInputIP));
return;
}
await Future.wait(locator<ServerProvider>().servers.map((e) async {
if (e.client == null) {
return;
}
final result = await e.client!.run('ping -c 3 $target').string;
_results.add(PingResult.parse(e.info.name, result));
setState(() {});
}));
}
Widget _buildControl() {
return SizedBox(
height: 57,
child: RoundRectCard(
InkWell(
onTap: () => FocusScope.of(context).unfocus(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(primaryColor)),
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(
width: 7,
),
Text(s.clear)
],
),
onPressed: () {
_results.clear();
setState(() {});
},
),
TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all(primaryColor)),
child: Row(
children: [
const Icon(Icons.play_arrow),
const SizedBox(
width: 7,
),
Text(s.start)
],
),
onPressed: () {
try {
doPing();
} catch (e) {
showSnackBar(context, Text('Error: \n$e'));
}
},
)
],
),
),
),
);
}
@override
bool get wantKeepAlive => true;
}

View File

@@ -1,8 +1,12 @@
import 'package:after_layout/after_layout.dart';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/private_key_info.dart';
import 'package:toolbox/data/provider/private_key.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/input_decoration.dart';
@@ -22,19 +26,29 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
final pwdController = TextEditingController();
late PrivateKeyProvider _provider;
late Widget loading;
late S s;
@override
void initState() {
super.initState();
_provider = locator<PrivateKeyProvider>();
loading = const SizedBox();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit'), actions: [
appBar: AppBar(title: Text(s.edit, style: size18), actions: [
widget.info != null
? IconButton(
tooltip: s.delete,
onPressed: () {
_provider.delInfo(widget.info!);
Navigator.of(context).pop();
@@ -48,7 +62,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
TextField(
controller: nameController,
keyboardType: TextInputType.text,
decoration: buildDecoration('Name', icon: Icons.info),
decoration: buildDecoration(s.name, icon: Icons.info),
),
TextField(
controller: keyController,
@@ -57,29 +71,51 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
maxLines: 10,
keyboardType: TextInputType.text,
enableSuggestions: false,
decoration: buildDecoration('Private Key', icon: Icons.vpn_key),
decoration: buildDecoration(s.privateKey, icon: Icons.vpn_key),
),
TextField(
controller: pwdController,
autocorrect: false,
keyboardType: TextInputType.text,
obscureText: true,
decoration: buildDecoration('Password', icon: Icons.password),
decoration: buildDecoration(s.pwd, icon: Icons.password),
),
SizedBox(height: MediaQuery.of(context).size.height * 0.1),
loading
],
),
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.send),
onPressed: () {
tooltip: s.save,
onPressed: () async {
final name = nameController.text;
final key = keyController.text;
final pwd = pwdController.text;
if (name.isEmpty || key.isEmpty || pwd.isEmpty) {
showSnackBar(
context, const Text('Three fields must not be empty.'));
if (name.isEmpty || key.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty));
return;
}
FocusScope.of(context).unfocus();
setState(() {
loading = const SizedBox(
height: 50,
child: Center(
child: CircularProgressIndicator(),
),
);
});
final info = PrivateKeyInfo(name, key, pwd);
bool haveErr = false;
try {
info.privateKey = await compute(decyptPem, [key, pwd]);
} catch (e) {
showSnackBar(context, Text(e.toString()));
haveErr = true;
} finally {
setState(() {
loading = const SizedBox();
});
}
if (haveErr) return;
if (widget.info != null) {
_provider.updateInfo(widget.info!, info);
} else {
@@ -87,6 +123,7 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
}
Navigator.of(context).pop();
},
child: const Icon(Icons.save),
),
);
}
@@ -100,3 +137,11 @@ class _PrivateKeyEditPageState extends State<PrivateKeyEditPage>
}
}
}
/// [args] : [key, pwd]
String decyptPem(List<String> args) {
/// skip when the key is not encrypted, or will throw exception
if (!SSHKeyPair.isEncryptedPem(args[0])) return args[0];
final sshKey = SSHKeyPair.fromPem(args[0], args[1]);
return sshKey.first.toPem();
}

View File

@@ -3,6 +3,9 @@ import 'package:provider/provider.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/data/provider/private_key.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/padding.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/page/private_key/edit.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
@@ -15,11 +18,19 @@ class StoredPrivateKeysPage extends StatefulWidget {
class _PrivateKeyListState extends State<StoredPrivateKeysPage> {
final _textStyle = TextStyle(color: primaryColor);
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Private Keys'),
title: Text(s.privateKey, style: size18),
),
body: Consumer<PrivateKeyProvider>(
builder: (_, key, __) {
@@ -29,27 +40,30 @@ class _PrivateKeyListState extends State<StoredPrivateKeysPage> {
itemCount: key.infos.length,
itemExtent: 57,
itemBuilder: (context, idx) {
return RoundRectCard(Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.infos[idx].id,
textAlign: TextAlign.center,
),
TextButton(
onPressed: () => AppRoute(
PrivateKeyEditPage(info: key.infos[idx]),
'private key edit page')
.go(context),
child: Text(
'Edit',
style: _textStyle,
))
],
return RoundRectCard(Padding(
padding: roundRectCardPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.infos[idx].id,
textAlign: TextAlign.center,
),
TextButton(
onPressed: () => AppRoute(
PrivateKeyEditPage(info: key.infos[idx]),
'private key edit page')
.go(context),
child: Text(
s.edit,
style: _textStyle,
))
],
),
));
})
: const Center(child: Text('No saved private keys.'));
: Center(child: Text(s.noSavedPrivateKey));
},
),
floatingActionButton: FloatingActionButton(

View File

@@ -1,13 +1,15 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/data/model/server/net_speed.dart';
import 'package:toolbox/data/model/server/server.dart';
import 'package:toolbox/data/model/server/server_status.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/icon/linux_icons.dart';
import 'package:toolbox/view/page/server/edit.dart';
import 'package:toolbox/data/res/padding.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
const style11 = TextStyle(fontSize: 11);
@@ -25,38 +27,30 @@ class ServerDetailPage extends StatefulWidget {
class _ServerDetailPageState extends State<ServerDetailPage>
with SingleTickerProviderStateMixin {
late MediaQueryData _media;
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Consumer<ServerProvider>(builder: (_, provider, __) {
return _buildMainPage(
provider.servers.firstWhere((e) => e.client.id == widget.id));
return _buildMainPage(provider.servers
.firstWhere((e) => '${e.info.ip}:${e.info.port}' == widget.id));
});
}
Widget _buildMainPage(ServerInfo si) {
return Scaffold(
appBar: AppBar(
title: Text(si.info.name),
actions: [
IconButton(
onPressed: () => AppRoute(
ServerEditPage(
spi: si.info,
),
'Edit server info page')
.go(context),
icon: const Icon(Icons.edit))
],
title: Text(si.info.name, style: size18),
),
body: ListView(
padding: const EdgeInsets.all(17),
padding: const EdgeInsets.all(13),
children: [
_buildLinuxIcon(si.status.sysVer),
SizedBox(height: _media.size.height * 0.03),
@@ -64,7 +58,8 @@ class _ServerDetailPageState extends State<ServerDetailPage>
_buildCPUView(si.status),
_buildDiskView(si.status),
_buildMemView(si.status),
_buildNetView(si.status.netSpeed)
_buildNetView(si.status.netSpeed),
SizedBox(height: _media.padding.bottom),
],
),
);
@@ -79,41 +74,44 @@ class _ServerDetailPageState extends State<ServerDetailPage>
Widget _buildCPUView(ServerStatus ss) {
return RoundRectCard(
SizedBox(
height: 12 * ss.cpu2Status.coresCount + 67,
child: Column(children: [
SizedBox(
height: _media.size.height * 0.02,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ss.cpu2Status.usedPercent(coreIdx: 0).toInt()}%',
style: const TextStyle(fontSize: 27),
textScaleFactor: 1.0,
),
Row(
children: [
_buildCPUTimePercent(ss.cpu2Status.user, 'user'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.sys, 'sys'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.nice, 'nice'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.idle, 'idle')
],
)
],
),
_buildCPUProgress(ss)
]),
Padding(
padding: roundRectCardPadding,
child: SizedBox(
height: 12 * ss.cpu2Status.coresCount + 67,
child: Column(children: [
SizedBox(
height: _media.size.height * 0.02,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${ss.cpu2Status.usedPercent(coreIdx: 0).toInt()}%',
style: const TextStyle(fontSize: 27),
textScaleFactor: 1.0,
),
Row(
children: [
_buildCPUTimePercent(ss.cpu2Status.user, 'user'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.sys, 'sys'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.nice, 'nice'),
SizedBox(
width: _media.size.width * 0.03,
),
_buildCPUTimePercent(ss.cpu2Status.idle, 'idle')
],
)
],
),
_buildCPUProgress(ss)
]),
),
),
);
}
@@ -125,7 +123,7 @@ class _ServerDetailPageState extends State<ServerDetailPage>
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
percent.toStringAsFixed(1) + '%',
'${percent.toStringAsFixed(1)}%',
style: const TextStyle(fontSize: 13),
textScaleFactor: 1.0,
),
@@ -169,7 +167,7 @@ class _ServerDetailPageState extends State<ServerDetailPage>
Widget _buildUpTimeAndSys(ServerStatus ss) {
return RoundRectCard(Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 17),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -188,50 +186,57 @@ class _ServerDetailPageState extends State<ServerDetailPage>
final pColor = primaryColor;
final used = ss.memory.used / ss.memory.total;
final width = _media.size.width - 17 * 2 - 17 * 2;
return RoundRectCard(SizedBox(
height: 47,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildMemExplain('Used', pColor),
_buildMemExplain('Cache', pColor.withAlpha(77)),
_buildMemExplain('Avail', progressColor.resolve(context))
],
),
const SizedBox(
height: 7,
),
Row(
children: [
SizedBox(
width: width * used,
const mb = 1024 * 1024;
return RoundRectCard(Padding(
padding: roundRectCardPadding,
child: SizedBox(
height: 47,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildMemExplain((ss.memory.used * mb).convertBytes, pColor),
_buildMemExplain(
(ss.memory.cache * mb).convertBytes, pColor.withAlpha(77)),
_buildMemExplain(
((ss.memory.total - ss.memory.used) * mb).convertBytes,
progressColor.resolve(context))
],
),
const SizedBox(
height: 7,
),
Row(
children: [
SizedBox(
width: width * used,
child: LinearProgressIndicator(
value: 1,
color: pColor,
)),
SizedBox(
width: width * (1 - used),
child: LinearProgressIndicator(
value: 1,
color: pColor,
)),
SizedBox(
width: width * (1 - used),
child: LinearProgressIndicator(
// memory.total == 1: failed to get mem, now mem = [emptyMemory] which is initial value.
value: ss.memory.total == 1
? 0
: ss.memory.cache / ss.memory.total,
backgroundColor: progressColor.resolve(context),
color: pColor.withAlpha(77),
),
)
],
)
],
// memory.total == 1: failed to get mem, now mem = [emptyMemory] which is initial value.
value: ss.memory.total == 1
? 0
: ss.memory.cache / (ss.memory.total - ss.memory.used),
backgroundColor: progressColor.resolve(context),
color: pColor.withAlpha(77),
),
)
],
)
],
),
),
));
}
Widget _buildMemExplain(String type, Color color) {
Widget _buildMemExplain(String value, Color color) {
return Row(
children: [
Container(
@@ -240,7 +245,12 @@ class _ServerDetailPageState extends State<ServerDetailPage>
width: 11,
),
const SizedBox(width: 4),
Text(type, style: style11, textScaleFactor: 1.0)
Text(
value,
style: style11,
textScaleFactor: 1.0,
textAlign: TextAlign.center,
)
],
);
}
@@ -255,7 +265,7 @@ class _ServerDetailPageState extends State<ServerDetailPage>
return RoundRectCard(SizedBox(
height: 27 * clone.length + 25,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 13),
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 17),
physics: const NeverScrollableScrollPhysics(),
itemCount: clone.length,
itemBuilder: (_, idx) {
@@ -290,9 +300,19 @@ class _ServerDetailPageState extends State<ServerDetailPage>
height: 7,
)
];
children.addAll(ns.devices.map((e) => _buildNetSpeedItem(ns, e)));
if (ns.devices.isEmpty) {
children.add(Center(
child: Text(
s.noInterface,
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
));
} else {
children.addAll(ns.devices.map((e) => _buildNetSpeedItem(ns, e)));
}
return RoundRectCard(Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 17),
child: Column(
children: children,
),

View File

@@ -8,6 +8,9 @@ import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/provider/private_key.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/store/private_key.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/private_key/edit.dart';
import 'package:toolbox/view/widget/input_decoration.dart';
@@ -31,10 +34,12 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
late ServerProvider _serverProvider;
late S s;
bool usePublicKey = false;
int _pubKeyIndex = -1;
late PrivateKeyInfo _keyInfo;
PrivateKeyInfo? _keyInfo;
@override
void initState() {
@@ -42,33 +47,35 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
_serverProvider = locator<ServerProvider>();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit'), actions: [
appBar: AppBar(title: Text(s.edit, style: size18), actions: [
widget.spi != null
? IconButton(
onPressed: () {
showRoundDialog(
context,
'Attention',
Text(
'Are you sure to delete server [${widget.spi!.name}]'),
[
TextButton(
onPressed: () {
_serverProvider.delServer(widget.spi!);
Navigator.of(context).pop();
Navigator.of(context).pop();
},
child: const Text(
'Yes',
style: TextStyle(color: Colors.red),
)),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('No'))
]);
showRoundDialog(context, s.attention,
Text(s.sureToDeleteServer(widget.spi!.name)), [
TextButton(
onPressed: () {
_serverProvider.delServer(widget.spi!);
Navigator.of(context).pop();
Navigator.of(context).pop();
},
child: Text(
s.ok,
style: const TextStyle(color: Colors.red),
)),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel))
]);
},
icon: const Icon(Icons.delete))
: const SizedBox()
@@ -82,21 +89,21 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
TextField(
controller: nameController,
keyboardType: TextInputType.text,
decoration:
buildDecoration('Name', icon: Icons.info, hint: 'Example'),
decoration: buildDecoration(s.name,
icon: Icons.info, hint: s.exampleName),
),
TextField(
controller: ipController,
keyboardType: TextInputType.text,
autocorrect: false,
enableSuggestions: false,
decoration: buildDecoration('Host',
decoration: buildDecoration(s.host,
icon: Icons.storage, hint: 'example.com'),
),
TextField(
controller: portController,
keyboardType: TextInputType.number,
decoration: buildDecoration('Port',
decoration: buildDecoration(s.port,
icon: Icons.format_list_numbered, hint: '22'),
),
TextField(
@@ -104,13 +111,13 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
keyboardType: TextInputType.text,
autocorrect: false,
enableSuggestions: false,
decoration: buildDecoration('User',
decoration: buildDecoration(s.user,
icon: Icons.account_box, hint: 'root'),
),
const SizedBox(height: 7),
Row(
children: [
const Text('Public Key Auth'),
Text(s.keyAuth),
Switch(
value: usePublicKey,
onChanged: (val) => setState(() => usePublicKey = val)),
@@ -121,8 +128,8 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
controller: passwordController,
obscureText: true,
keyboardType: TextInputType.text,
decoration: buildDecoration('Pwd',
icon: Icons.password, hint: 'Password'),
decoration: buildDecoration(s.pwd,
icon: Icons.password, hint: s.pwd),
onSubmitted: (_) => {},
)
: const SizedBox(),
@@ -142,7 +149,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
)
.toList();
tiles.add(ListTile(
title: const Text('Add a Private Key'),
title: Text(s.addPrivateKey),
contentPadding: EdgeInsets.zero,
trailing: IconButton(
icon: const Icon(Icons.add),
@@ -156,9 +163,9 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
iconColor: primaryColor,
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: const Text(
'Choose Key',
style: TextStyle(fontSize: 14),
title: Text(
s.choosePrivateKey,
style: const TextStyle(fontSize: 14),
),
children: tiles,
);
@@ -171,15 +178,15 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
child: const Icon(Icons.send),
onPressed: () {
if (ipController.text == '') {
showSnackBar(context, const Text('Please enter host.'));
showSnackBar(context, Text(s.plzEnterHost));
return;
}
if (!usePublicKey && passwordController.text == '') {
showSnackBar(context, const Text('Please enter password.'));
showSnackBar(context, Text(s.plzEnterPwd));
return;
}
if (usePublicKey && _pubKeyIndex == -1) {
showSnackBar(context, const Text('Please select a private key.'));
showSnackBar(context, Text(s.plzSelectKey));
return;
}
if (usernameController.text == '') {
@@ -188,10 +195,15 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
if (portController.text == '') {
portController.text = '22';
}
if (widget.spi != null && widget.spi!.pubKeyId != null) {
_keyInfo ??= locator<PrivateKeyStore>().get(widget.spi!.pubKeyId!);
}
final authorization = usePublicKey
? {
"privateKey": _keyInfo.privateKey,
"passphrase": _keyInfo.password
"privateKey": _keyInfo!.privateKey,
"passphrase": _keyInfo!.password
}
: passwordController.text;
final spi = ServerPrivateInfo(
@@ -200,7 +212,7 @@ class _ServerEditPageState extends State<ServerEditPage> with AfterLayoutMixin {
port: int.parse(portController.text),
user: usernameController.text,
authorization: authorization,
pubKeyId: usePublicKey ? _keyInfo.id : null);
pubKeyId: usePublicKey ? _keyInfo!.id : null);
if (widget.spi == null) {
_serverProvider.addServer(spi);

View File

@@ -1,19 +1,29 @@
import 'package:after_layout/after_layout.dart';
import 'package:circle_chart/circle_chart.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:get_it/get_it.dart';
import 'package:marquee/marquee.dart';
import 'package:provider/provider.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/data/model/app/menu_item.dart';
import 'package:toolbox/data/model/server/server.dart';
import 'package:toolbox/data/model/server/server_connection_state.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/server_status.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/apt.dart';
import 'package:toolbox/view/page/docker.dart';
import 'package:toolbox/view/page/server/detail.dart';
import 'package:toolbox/view/page/server/edit.dart';
import 'package:toolbox/view/page/sftp/view.dart';
import 'package:toolbox/view/page/snippet/list.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class ServerPage extends StatefulWidget {
const ServerPage({Key? key}) : super(key: key);
@@ -27,13 +37,16 @@ class _ServerPageState extends State<ServerPage>
late MediaQueryData _media;
late ThemeData _theme;
late Color _primaryColor;
late RefreshController _refreshController;
late ServerProvider _serverProvider;
late S s;
@override
void initState() {
super.initState();
_serverProvider = locator<ServerProvider>();
_refreshController = RefreshController();
}
@override
@@ -41,46 +54,55 @@ class _ServerPageState extends State<ServerPage>
super.didChangeDependencies();
_media = MediaQuery.of(context);
_theme = Theme.of(context);
_primaryColor = Color(locator<SettingStore>().primaryColor.fetch()!);
_primaryColor = primaryColor;
s = S.of(context);
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: Consumer<ServerProvider>(builder: (_, pro, __) {
if (pro.servers.isEmpty) {
return const Center(
child: Text(
'There is no server.\nClick the fab to add one.',
textAlign: TextAlign.center,
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: AnimationLimiter(
child: Column(
children: AnimationConfiguration.toStaggeredList(
duration: const Duration(milliseconds: 377),
childAnimationBuilder: (widget) => SlideAnimation(
verticalOffset: 50.0,
child: FadeInAnimation(
child: widget,
),
),
children: [
const SizedBox(height: 13),
...pro.servers.map((e) => _buildEachServerCard(e))
],
))),
final autoUpdate =
locator<SettingStore>().serverStatusUpdateInterval.fetch() != 0;
final child = Consumer<ServerProvider>(builder: (_, pro, __) {
if (pro.servers.isEmpty) {
return Center(
child: Text(
s.serverTabEmpty,
textAlign: TextAlign.center,
),
);
}),
}
return ListView.separated(
padding: const EdgeInsets.all(7),
controller: ScrollController(),
itemBuilder: (ctx, idx) {
if (idx == pro.servers.length) {
return SizedBox(height: _media.padding.bottom);
}
return _buildEachServerCard(pro.servers[idx]);
},
itemCount: pro.servers.length + 1,
separatorBuilder: (_, __) => const SizedBox(
height: 3,
),
);
});
return Scaffold(
body: autoUpdate
? child
: SmartRefresher(
controller: _refreshController,
child: child,
onRefresh: () async {
await _serverProvider.refreshData();
_refreshController.refreshCompleted();
},
),
floatingActionButton: FloatingActionButton(
onPressed: () =>
AppRoute(const ServerEditPage(), 'Add server info page')
.go(context),
tooltip: 'add a server',
tooltip: s.addAServer,
heroTag: 'server page fab',
child: const Icon(Icons.add),
),
@@ -88,8 +110,8 @@ class _ServerPageState extends State<ServerPage>
}
Widget _buildEachServerCard(ServerInfo si) {
return Card(
child: InkWell(
return RoundRectCard(
InkWell(
onLongPress: () => AppRoute(
ServerEditPage(
spi: si.info,
@@ -97,103 +119,236 @@ class _ServerPageState extends State<ServerPage>
'Edit server info page')
.go(context),
child: Padding(
padding: const EdgeInsets.all(13),
child:
_buildRealServerCard(si.status, si.info.name, si.connectionState),
),
onTap: () =>
AppRoute(ServerDetailPage(si.client.id!), 'server detail page')
.go(context),
padding: const EdgeInsets.all(13),
child: _buildRealServerCard(
si.status, si.info.name, si.connectionState, si.info)),
onTap: () => AppRoute(ServerDetailPage('${si.info.ip}:${si.info.port}'),
'server detail page')
.go(context),
),
);
}
Widget _buildRealServerCard(
ServerStatus ss, String serverName, ServerConnectionState cs) {
Widget _buildRealServerCard(ServerStatus ss, String serverName,
ServerConnectionState cs, ServerPrivateInfo spi) {
final rootDisk =
ss.disk.firstWhere((element) => element.mountLocation == '/');
final topRightStr =
getTopRightStr(cs, ss.cpu2Status.temp, ss.uptime, ss.failedInfo);
final hasError =
cs == ServerConnectionState.failed && ss.failedInfo != null;
final style = TextStyle(
color: _theme.textTheme.bodyText1!.color!.withAlpha(100), fontSize: 11);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
serverName,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
textScaleFactor: 1.0,
),
Text(getTopRightStr(cs, ss.cpu2Status.temp, ss.uptime, ss.failedInfo),
textScaleFactor: 1.0,
style: TextStyle(
color: _theme.textTheme.bodyText1!.color!.withAlpha(100),
fontSize: 11))
],
Padding(
padding: const EdgeInsets.symmetric(horizontal: 7),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
serverName,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 12),
textScaleFactor: 1.0,
),
const Icon(
Icons.keyboard_arrow_right,
size: 17,
color: Colors.grey,
)
],
),
Row(
children: [
hasError
? ConstrainedBox(
constraints: BoxConstraints(
maxWidth: _media.size.width * 0.57,
maxHeight: 15),
child: Marquee(
accelerationDuration: const Duration(seconds: 3),
accelerationCurve: Curves.linear,
decelerationDuration: const Duration(seconds: 3),
decelerationCurve: Curves.linear,
text: topRightStr,
textScaleFactor: 1.0,
style: style),
)
: Text(topRightStr, style: style, textScaleFactor: 1.0),
_buildMoreBtn(spi),
],
)
],
),
),
const SizedBox(
height: 17,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildPercentCircle(ss.cpu2Status.usedPercent(), 'CPU'),
_buildPercentCircle(ss.memory.used / ss.memory.total * 100, 'Mem'),
_buildIOData('Net', 'Conn:\n' + ss.tcp.maxConn.toString(),
'Fail:\n' + ss.tcp.fail.toString()),
_buildIOData('Disk', 'Total:\n' + rootDisk.size,
'Used:\n' + rootDisk.usedPercent.toString() + '%')
_buildPercentCircle(ss.cpu2Status.usedPercent()),
_buildPercentCircle(ss.memory.used / ss.memory.total * 100),
_buildIOData('Conn:\n${ss.tcp.maxConn}', 'Fail:\n${ss.tcp.fail}'),
_buildIOData(
'Total:\n${rootDisk.size}', 'Used:\n${rootDisk.usedPercent}%')
],
),
const SizedBox(height: 13),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildExplainText('CPU'),
_buildExplainText('Mem'),
_buildExplainText('Net'),
_buildExplainText('Disk'),
],
),
const SizedBox(height: 3),
],
);
}
String getTopRightStr(ServerConnectionState cs, String temp, String upTime, String? failedInfo) {
Widget _buildMoreBtn(ServerPrivateInfo spi) {
return DropdownButtonHideUnderline(
child: DropdownButton2(
customButton: const Padding(
padding: EdgeInsets.symmetric(horizontal: 5, vertical: 1.7),
child: Icon(
Icons.more_vert,
size: 17,
),
),
customItemsIndexes: [ServerTabMenuItems.firstItems.length],
customItemsHeight: 8,
items: [
...ServerTabMenuItems.firstItems.map(
(item) => DropdownMenuItem<DropdownBtnItem>(
value: item,
child: item.build,
),
),
const DropdownMenuItem<Divider>(enabled: false, child: Divider()),
...ServerTabMenuItems.secondItems.map(
(item) => DropdownMenuItem<DropdownBtnItem>(
value: item,
child: item.build,
),
),
],
onChanged: (value) {
final item = value as DropdownBtnItem;
switch (item) {
case ServerTabMenuItems.apt:
AppRoute(AptManagePage(spi), 'apt manage page').go(context);
break;
case ServerTabMenuItems.sftp:
AppRoute(SFTPPage(spi), 'SFTP').go(context);
break;
case ServerTabMenuItems.snippet:
AppRoute(
SnippetListPage(
spi: spi,
),
'snippet list')
.go(context);
break;
case ServerTabMenuItems.edit:
AppRoute(
ServerEditPage(
spi: spi,
),
'Edit server info page')
.go(context);
break;
case ServerTabMenuItems.docker:
AppRoute(DockerManagePage(spi), 'Docker manage page').go(context);
break;
}
},
itemHeight: 37,
itemPadding: const EdgeInsets.only(left: 17, right: 17),
dropdownWidth: 160,
dropdownPadding: const EdgeInsets.symmetric(vertical: 7),
dropdownDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
),
dropdownElevation: 8,
offset: const Offset(0, 8),
),
);
}
Widget _buildExplainText(String text) {
return SizedBox(
width: _media.size.width * 0.2,
child: Text(
text,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
textScaleFactor: 1.0,
),
);
}
String getTopRightStr(ServerConnectionState cs, String temp, String upTime,
String? failedInfo) {
switch (cs) {
case ServerConnectionState.disconnected:
return 'Disconnected';
return s.disconnected;
case ServerConnectionState.connected:
return temp == '' ? (upTime == '' ? 'Loading...' : upTime) : temp;
if (temp == '') {
if (upTime == '') {
return s.serverTabLoading;
} else {
return upTime;
}
} else {
if (upTime == '') {
return temp;
} else {
return '$temp | $upTime';
}
}
case ServerConnectionState.connecting:
return 'Connecting...';
return s.serverTabConnecting;
case ServerConnectionState.failed:
return failedInfo ?? 'Failed';
if (failedInfo == null) {
return s.serverTabFailed;
}
if (failedInfo.contains('encypted')) {
return s.serverTabPlzSave;
}
return failedInfo;
default:
return 'Unknown State';
return s.serverTabUnkown;
}
}
Widget _buildIOData(String title, String up, String down) {
Widget _buildIOData(String up, String down) {
final statusTextStyle = TextStyle(
fontSize: 9, color: _theme.textTheme.bodyText1!.color!.withAlpha(177));
return SizedBox(
width: _media.size.width * 0.2,
height: _media.size.height * 0.1,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SizedBox(),
Column(
children: [
Text(
up,
style: statusTextStyle,
textAlign: TextAlign.center,
textScaleFactor: 1.0,
),
const SizedBox(height: 3),
Text(
down + '\n',
style: statusTextStyle,
textAlign: TextAlign.center,
textScaleFactor: 1.0,
)
],
),
const SizedBox(height: 5),
Text(
title,
style: const TextStyle(fontSize: 12),
up,
style: statusTextStyle,
textAlign: TextAlign.center,
textScaleFactor: 1.0,
),
const SizedBox(height: 3),
Text(
down,
style: statusTextStyle,
textAlign: TextAlign.center,
textScaleFactor: 1.0,
)
@@ -202,41 +357,31 @@ class _ServerPageState extends State<ServerPage>
);
}
Widget _buildPercentCircle(double percent, String title) {
Widget _buildPercentCircle(double percent) {
if (percent <= 0) percent = 0.01;
if (percent >= 100) percent = 99.9;
return SizedBox(
width: _media.size.width * 0.2,
height: _media.size.height * 0.1,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Stack(
children: [
Stack(
children: [
CircleChart(
progressColor: _primaryColor,
progressNumber: percent,
maxNumber: 100,
width: _media.size.width * 0.37,
height: _media.size.height * 0.09,
),
Positioned.fill(
child: Center(
child: Text(
'${percent.toStringAsFixed(1)}%',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 11),
textScaleFactor: 1.0,
),
),
),
],
Center(
child: CircleChart(
progressColor: _primaryColor,
progressNumber: percent,
maxNumber: 100,
width: 53,
height: 53,
),
),
Text(
title,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
textScaleFactor: 1.0,
Positioned.fill(
child: Center(
child: Text(
'${percent.toStringAsFixed(1)}%',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 11),
textScaleFactor: 1.0,
),
),
),
],
),
@@ -251,6 +396,6 @@ class _ServerPageState extends State<ServerPage>
await GetIt.I.allReady();
await _serverProvider.loadLocalData();
await _serverProvider.refreshData();
await _serverProvider.startAutoRefresh();
_serverProvider.startAutoRefresh();
}
}

View File

@@ -2,10 +2,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_material_color_picker/flutter_material_color_picker.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/update.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/provider/app.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/res/build_data.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/padding.dart';
import 'package:toolbox/data/res/tab.dart';
import 'package:toolbox/data/store/setting.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
@@ -19,20 +25,30 @@ class SettingPage extends StatefulWidget {
class _SettingPageState extends State<SettingPage> {
late SettingStore _store;
late int _selectedColorValue;
late int _launchPageIdx;
double _intervalValue = 0;
late Color priColor;
static const textStyle = TextStyle(fontSize: 14);
late final ServerProvider _serverProvider;
late MediaQueryData _media;
late ThemeData _theme;
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
priColor = primaryColor;
_media = MediaQuery.of(context);
_theme = Theme.of(context);
s = S.of(context);
}
@override
void initState() {
super.initState();
_serverProvider = locator<ServerProvider>();
_store = locator<SettingStore>();
_launchPageIdx = _store.launchPage.fetch()!;
_intervalValue = _store.serverStatusUpdateInterval.fetch()!.toDouble();
}
@@ -40,14 +56,15 @@ class _SettingPageState extends State<SettingPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Setting'),
title: Text(s.setting, style: size18),
),
body: ListView(
padding: const EdgeInsets.all(17),
children: [
_buildAppColorPreview(),
_buildUpdateInterval(),
_buildCheckUpdate()
_buildCheckUpdate(),
_buildLaunchPage()
].map((e) => RoundRectCard(e)).toList(),
),
);
@@ -58,15 +75,15 @@ class _SettingPageState extends State<SettingPage> {
String display;
if (app.newestBuild != null) {
if (app.newestBuild! > BuildData.build) {
display = 'Found: v1.0.${app.newestBuild}, click to update';
display = s.versionHaveUpdate(app.newestBuild!);
} else {
display = 'Current: v1.0.${BuildData.build}is up to date';
display = s.versionUpdated(BuildData.build);
}
} else {
display = 'Current: v1.0.${BuildData.build}';
display = s.versionUnknownUpdate(BuildData.build);
}
return ListTile(
contentPadding: EdgeInsets.zero,
contentPadding: roundRectCardPadding,
trailing: const Icon(Icons.keyboard_arrow_right),
title: Text(
display,
@@ -79,19 +96,19 @@ class _SettingPageState extends State<SettingPage> {
Widget _buildUpdateInterval() {
return ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
tilePadding: roundRectCardPadding,
childrenPadding: roundRectCardPadding,
textColor: priColor,
title: const Text(
'Server status update interval',
title: Text(
s.updateServerStatusInterval,
style: textStyle,
textAlign: TextAlign.start,
),
subtitle: const Text(
'Will take effect the next time app launches.',
style: TextStyle(color: Colors.grey),
subtitle: Text(
s.willTakEeffectImmediately,
style: const TextStyle(color: Colors.grey, fontSize: 13),
),
trailing: Text('${_intervalValue.toInt()} s'),
trailing: Text('${_intervalValue.toInt()} ${s.second}'),
children: [
Slider(
thumbColor: priColor,
@@ -104,16 +121,22 @@ class _SettingPageState extends State<SettingPage> {
_intervalValue = newValue;
});
},
onChangeEnd: (val) =>
_store.serverStatusUpdateInterval.put(val.toInt()),
label: '${_intervalValue.toInt()} seconds',
onChangeEnd: (val) {
_store.serverStatusUpdateInterval.put(val.toInt());
_serverProvider.startAutoRefresh();
},
label: '${_intervalValue.toInt()} ${s.second}',
divisions: 10,
),
const SizedBox(
height: 3,
),
_intervalValue == 0.0
? const Text('You set to 0, will not update automatically.')
? Text(
s.updateIntervalEqual0,
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
)
: const SizedBox(),
const SizedBox(
height: 13,
@@ -125,12 +148,8 @@ class _SettingPageState extends State<SettingPage> {
Widget _buildAppColorPreview() {
return ExpansionTile(
textColor: priColor,
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
children: [
_buildAppColorPicker(priColor),
_buildColorPickerConfirmBtn()
],
tilePadding: roundRectCardPadding,
childrenPadding: roundRectCardPadding,
trailing: ClipOval(
child: Container(
color: priColor,
@@ -138,10 +157,14 @@ class _SettingPageState extends State<SettingPage> {
width: 27,
),
),
title: const Text(
'App primary color',
title: Text(
s.appPrimaryColor,
style: textStyle,
));
),
children: [
_buildAppColorPicker(priColor),
_buildColorPickerConfirmBtn()
]);
}
Widget _buildAppColorPicker(Color selected) {
@@ -162,4 +185,49 @@ class _SettingPageState extends State<SettingPage> {
}),
);
}
Widget _buildLaunchPage() {
return ExpansionTile(
textColor: priColor,
tilePadding: roundRectCardPadding,
childrenPadding: roundRectCardPadding,
title: Text(
s.launchPage,
style: textStyle,
),
trailing: ConstrainedBox(
constraints: BoxConstraints(maxWidth: _media.size.width * 0.35),
child: Text(
tabTitleName(context, _launchPageIdx),
style: textStyle,
textAlign: TextAlign.right,
),
),
children: tabs
.map((e) => ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
tabTitleName(context, tabs.indexOf(e)),
style: TextStyle(
fontSize: 14,
color: _theme.textTheme.bodyText2!.color!.withAlpha(177)),
),
trailing: _buildRadio(tabs.indexOf(e)),
))
.toList(),
);
}
Radio _buildRadio(int index) {
return Radio<int>(
value: index,
groupValue: _launchPageIdx,
onChanged: (int? value) {
setState(() {
_launchPageIdx = value!;
_store.launchPage.put(value);
});
},
);
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:toolbox/core/extension/colorx.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/app/path_with_prefix.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/path.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/page/sftp/downloading.dart';
import 'package:toolbox/view/widget/fade_in.dart';
class SFTPDownloadedPage extends StatefulWidget {
const SFTPDownloadedPage({Key? key}) : super(key: key);
@override
State<SFTPDownloadedPage> createState() => _SFTPDownloadedPageState();
}
class _SFTPDownloadedPageState extends State<SFTPDownloadedPage> {
PathWithPrefix? _path;
String? _prefixPath;
late S s;
late ThemeData _theme;
@override
void initState() {
super.initState();
sftpDownloadDir.then((dir) {
_path = PathWithPrefix(dir.path);
_prefixPath = '${dir.path}/';
setState(() {});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
_theme = Theme.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(s.download),
actions: [
IconButton(
icon: const Icon(Icons.downloading),
onPressed: () =>
AppRoute(const SFTPDownloadingPage(), 'sftp downloading')
.go(context),
)
],
),
body: FadeIn(
key: UniqueKey(),
child: _buildBody(),
),
bottomNavigationBar: SafeArea(
child: _buildPath(),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: FloatingActionButton(
onPressed: (() {
if (_path!.path == _prefixPath) {
showSnackBar(context, Text(s.alreadyLastDir));
return;
}
_path!.update('..');
setState(() {});
}),
child: const Icon(Icons.keyboard_arrow_left),
),
);
}
Widget _buildPath() {
final color = _theme.scaffoldBackgroundColor;
return Container(
color: color,
padding: const EdgeInsets.fromLTRB(11, 7, 11, 11),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(),
(_path?.path ?? s.loadingFiles).omitStartStr(
style: TextStyle(
color: color.isBrightColor ? Colors.black : Colors.white),
)
],
),
);
}
Widget _buildBody() {
if (_path == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
final dir = Directory(_path!.path);
final files = dir.listSync();
return ListView.builder(
itemCount: files.length,
itemBuilder: (context, index) {
var file = files[index];
var fileName = file.path.split('/').last;
var stat = file.statSync();
var isDir = stat.type == FileSystemEntityType.directory;
return ListTile(
leading: isDir
? const Icon(Icons.folder)
: const Icon(Icons.insert_drive_file),
title: Text(fileName),
subtitle: isDir ? null : Text(stat.size.convertBytes),
trailing: Text(
stat.modified
.toString()
.substring(0, stat.modified.toString().length - 4),
style: grey,
),
onTap: () {
if (!isDir) {
showFileActionDialog(file);
return;
}
_path!.update(fileName);
setState(() {});
},
);
},
);
}
void showFileActionDialog(FileSystemEntity file) {
final fileName = file.path.split('/').last;
showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.delete),
title: Text(s.delete),
onTap: () {
Navigator.of(context).pop();
showRoundDialog(
context, s.sureDelete(fileName), const SizedBox(), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
file.deleteSync();
setState(() {});
Navigator.of(context).pop();
},
child: Text(s.ok),
),
]);
},
),
ListTile(
leading: const Icon(Icons.open_in_new),
title: Text(s.open),
onTap: () {
shareFiles(context, [file.absolute.path]);
}),
],
),
[
TextButton(
onPressed: (() => Navigator.of(context).pop()),
child: Text(s.close))
]);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/sftp/download_status.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/view/widget/center_loading.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class SFTPDownloadingPage extends StatefulWidget {
const SFTPDownloadingPage({Key? key}) : super(key: key);
@override
_SFTPDownloadingPageState createState() => _SFTPDownloadingPageState();
}
class _SFTPDownloadingPageState extends State<SFTPDownloadingPage> {
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
s.download,
style: size18,
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
return Consumer<SftpDownloadProvider>(builder: (__, pro, _) {
if (pro.status.isEmpty) {
return Center(
child: Text(s.sftpNoDownloadTask),
);
}
return ListView.builder(
padding: const EdgeInsets.all(11),
itemCount: pro.status.length,
itemBuilder: (context, index) {
final status = pro.status[index];
return _buildItem(status);
},
);
});
}
Widget _wrapInCard(SftpDownloadStatus status, String? subtitle,
{Widget? trailing}) {
return RoundRectCard(ListTile(
title: Text(status.fileName),
subtitle: subtitle == null
? null
: Text(
subtitle,
style: grey,
),
trailing: trailing,
));
}
Widget _buildItem(SftpDownloadStatus status) {
if (status.error != null) {
showSnackBar(context, Text(status.error.toString()));
}
switch (status.status) {
case SftpWorkerStatus.finished:
final time = status.spentTime.toString();
return _wrapInCard(status,
'${s.downloadFinished} ${s.spentTime(time == 'null' ? s.unknown : (time.substring(0, time.length - 7)))}',
trailing: IconButton(
onPressed: () => shareFiles(context, [status.item.localPath]),
icon: const Icon(Icons.open_in_new)));
case SftpWorkerStatus.downloading:
return _wrapInCard(
status,
s.downloadStatus((status.progress ?? 0.0).toStringAsFixed(2),
(status.size ?? 0).convertBytes),
trailing:
CircularProgressIndicator(value: (status.progress ?? 0) / 100));
case SftpWorkerStatus.preparing:
return _wrapInCard(status, s.sftpDlPrepare, trailing: loadingIcon);
case SftpWorkerStatus.sshConnectted:
return _wrapInCard(status, s.sftpSSHConnected, trailing: loadingIcon);
default:
return _wrapInCard(status, s.unknown,
trailing: const Icon(
Icons.error,
size: 40,
));
}
}
}

View File

@@ -0,0 +1,431 @@
import 'dart:typed_data';
import 'package:dartssh2/dartssh2.dart';
import 'package:flutter/material.dart';
import 'package:toolbox/core/extension/numx.dart';
import 'package:toolbox/core/extension/stringx.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/server_connection_state.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/sftp/absolute_path.dart';
import 'package:toolbox/data/model/sftp/download_worker.dart';
import 'package:toolbox/data/model/sftp/browser_status.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/sftp_download.dart';
import 'package:toolbox/data/res/path.dart';
import 'package:toolbox/data/store/private_key.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/sftp/downloading.dart';
import 'package:toolbox/view/widget/fade_in.dart';
import 'package:toolbox/view/widget/two_line_text.dart';
class SFTPPage extends StatefulWidget {
final ServerPrivateInfo spi;
const SFTPPage(this.spi, {Key? key}) : super(key: key);
@override
_SFTPPageState createState() => _SFTPPageState();
}
class _SFTPPageState extends State<SFTPPage> {
final SftpBrowserStatus _status = SftpBrowserStatus();
final ScrollController _scrollController = ScrollController();
late MediaQueryData _media;
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_media = MediaQuery.of(context);
s = S.of(context);
}
@override
void initState() {
super.initState();
_status.spi = widget.spi;
_status.selected = true;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: TwoLineText(up: 'SFTP', down: widget.spi.name),
actions: [
IconButton(
onPressed: (() => showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.folder),
title: Text(s.createFolder),
onTap: () => mkdir(context)),
ListTile(
leading: const Icon(Icons.insert_drive_file),
title: Text(s.createFile),
onTap: () => newFile(context)),
],
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.close))
])),
icon: const Icon(Icons.add),
)
]),
body: _buildFileView(),
);
}
Widget get centerCircleLoading => Center(
child: Column(
children: [
SizedBox(
height: _media.size.height * 0.4,
),
const CircularProgressIndicator(),
],
),
);
Widget _buildFileView() {
if (!_status.selected) {
return ListView(
children: [
_buildDestSelector(),
],
);
}
final spi = _status.spi;
final si =
locator<ServerProvider>().servers.firstWhere((s) => s.info == spi);
final client = si.client;
if (client == null ||
si.connectionState != ServerConnectionState.connected) {
return centerCircleLoading;
}
if (_status.files == null) {
_status.path = AbsolutePath('/');
listDir(path: '/', client: client);
return centerCircleLoading;
} else {
return RefreshIndicator(
child: FadeIn(
key: Key(_status.spi!.name + _status.path!.path),
child: ListView.builder(
itemCount: _status.files!.length + 1,
controller: _scrollController,
itemBuilder: (context, index) {
if (index == 0) {
return _buildDestSelector();
}
final file = _status.files![index - 1];
final isDir = file.attr.isDirectory;
return ListTile(
leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file),
title: Text(file.filename),
trailing: Text(
DateTime.fromMillisecondsSinceEpoch(
(file.attr.modifyTime ?? 0) * 1000)
.toString()
.replaceFirst('.000', ''),
style: const TextStyle(color: Colors.grey),
),
subtitle:
isDir ? null : Text((file.attr.size ?? 0).convertBytes),
onTap: () {
if (isDir) {
_status.path?.update(file.filename);
listDir(path: _status.path?.path);
} else {
onItemPress(context, file, true);
}
},
onLongPress: () => onItemPress(context, file, false),
);
},
),
),
onRefresh: () => listDir(path: _status.path?.path));
}
}
void onItemPress(BuildContext context, SftpName file, bool showDownload) {
showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.delete),
title: Text(s.delete),
onTap: () => delete(context, file),
),
ListTile(
leading: const Icon(Icons.edit),
title: Text(s.rename),
onTap: () => rename(context, file),
),
showDownload
? ListTile(
leading: const Icon(Icons.download),
title: Text(s.download),
onTap: () => download(context, file),
)
: const SizedBox()
],
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel))
]);
}
void download(BuildContext context, SftpName name) {
showRoundDialog(context, s.download,
Text('${s.dl2Local(name.filename)}\n${s.keepForeground}'), [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: Text(s.cancel)),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
final prePath = _status.path!.path;
final remotePath =
prePath + (prePath.endsWith('/') ? '' : '/') + name.filename;
final local = '${(await sftpDownloadDir).path}$remotePath';
final pubKeyId = _status.spi!.pubKeyId;
locator<SftpDownloadProvider>().add(
DownloadItem(_status.spi!, remotePath, local),
key: pubKeyId == null
? null
: locator<PrivateKeyStore>().get(pubKeyId).privateKey);
Navigator.of(context).pop();
showRoundDialog(context, s.goSftpDlPage, const SizedBox(), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
Navigator.of(context).pop();
AppRoute(const SFTPDownloadingPage(), 'sftp downloading')
.go(context);
},
child: Text(s.ok))
]);
},
child: Text(s.download))
]);
}
void delete(BuildContext context, SftpName file) {
Navigator.of(context).pop();
showRoundDialog(context, s.attention, Text(s.sureDelete(file.filename)), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel')),
TextButton(
onPressed: () {
_status.client!.remove(file.filename);
Navigator.of(context).pop();
listDir();
},
child: Text(
s.delete,
style: const TextStyle(color: Colors.red),
)),
]);
}
void mkdir(BuildContext context) {
Navigator.of(context).pop();
final textController = TextEditingController();
showRoundDialog(
context,
s.createFolder,
TextField(
controller: textController,
decoration: InputDecoration(
labelText: s.name,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () {
if (textController.text == '') {
showRoundDialog(
context, s.attention, Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
_status.client!
.mkdir('${_status.path!.path}/${textController.text}');
Navigator.of(context).pop();
listDir();
},
child: Text(
s.ok,
style: const TextStyle(color: Colors.red),
)),
]);
}
void newFile(BuildContext context) {
Navigator.of(context).pop();
final textController = TextEditingController();
showRoundDialog(
context,
s.createFile,
TextField(
controller: textController,
decoration: InputDecoration(
labelText: s.name,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async {
if (textController.text == '') {
showRoundDialog(
context, s.attention, Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
(await _status.client!
.open('${_status.path!.path}/${textController.text}'))
.writeBytes(Uint8List(0));
Navigator.of(context).pop();
listDir();
},
child: Text(
s.ok,
style: const TextStyle(color: Colors.red),
)),
]);
}
void rename(BuildContext context, SftpName file) {
Navigator.of(context).pop();
final textController = TextEditingController();
showRoundDialog(
context,
s.rename,
TextField(
controller: textController,
decoration: InputDecoration(
labelText: s.name,
),
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async {
if (textController.text == '') {
showRoundDialog(
context, s.attention, Text(s.fieldMustNotEmpty), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.ok)),
]);
return;
}
await _status.client!
.rename(file.filename, textController.text);
Navigator.of(context).pop();
listDir();
},
child: Text(
s.rename,
style: const TextStyle(color: Colors.red),
)),
]);
}
Future<void> listDir({String? path, SSHClient? client}) async {
if (_status.isBusy) {
return;
}
_status.isBusy = true;
if (client != null) {
final sftpc = await client.sftp();
_status.client = sftpc;
}
try {
final fs =
await _status.client!.listdir(path ?? (_status.path?.path ?? '/'));
fs.sort((a, b) => a.filename.compareTo(b.filename));
fs.removeAt(0);
if (mounted) {
setState(() {
_status.files = fs;
_status.isBusy = false;
});
}
} catch (e) {
await showRoundDialog(context, s.error, Text(e.toString()), [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: Text(s.ok))
]);
if (_status.path!.undo()) {
await listDir();
}
}
}
Widget _buildDestSelector() {
final str = _status.path?.path;
return ExpansionTile(
title: Text(_status.spi?.name ?? s.chooseDestination),
subtitle: _status.selected
? str!.omitStartStr(style: const TextStyle(color: Colors.grey))
: null,
children: locator<ServerProvider>()
.servers
.map((e) => _buildDestSelectorItem(e.info))
.toList());
}
Widget _buildDestSelectorItem(ServerPrivateInfo spi) {
return ListTile(
title: Text(spi.name),
subtitle: Text('${spi.user}@${spi.ip}:${spi.port}'),
onTap: () {
_status.spi = spi;
_status.selected = true;
_status.path = AbsolutePath('/');
listDir(
client: locator<ServerProvider>()
.servers
.firstWhere((s) => s.info == spi)
.client,
path: '/');
},
);
}
}

View File

@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/snippet.dart';
import 'package:toolbox/data/provider/snippet.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/widget/input_decoration.dart';
@@ -21,6 +23,7 @@ class _SnippetEditPageState extends State<SnippetEditPage>
final scriptController = TextEditingController();
late SnippetProvider _provider;
late S s;
@override
void initState() {
@@ -28,16 +31,23 @@ class _SnippetEditPageState extends State<SnippetEditPage>
_provider = locator<SnippetProvider>();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Edit'), actions: [
appBar: AppBar(title: Text(s.edit, style: size18), actions: [
widget.snippet != null
? IconButton(
onPressed: () {
_provider.delInfo(widget.snippet!);
_provider.del(widget.snippet!);
Navigator.of(context).pop();
},
tooltip: s.delete,
icon: const Icon(Icons.delete))
: const SizedBox()
]),
@@ -47,7 +57,7 @@ class _SnippetEditPageState extends State<SnippetEditPage>
TextField(
controller: nameController,
keyboardType: TextInputType.text,
decoration: buildDecoration('Name', icon: Icons.info),
decoration: buildDecoration(s.name, icon: Icons.info),
),
TextField(
controller: scriptController,
@@ -56,7 +66,7 @@ class _SnippetEditPageState extends State<SnippetEditPage>
maxLines: 10,
keyboardType: TextInputType.text,
enableSuggestions: false,
decoration: buildDecoration('Snippet', icon: Icons.code),
decoration: buildDecoration(s.snippet, icon: Icons.code),
),
],
),
@@ -66,14 +76,14 @@ class _SnippetEditPageState extends State<SnippetEditPage>
final name = nameController.text;
final script = scriptController.text;
if (name.isEmpty || script.isEmpty) {
showSnackBar(context, const Text('Two fields must not be empty.'));
showSnackBar(context, Text(s.fieldMustNotEmpty));
return;
}
final snippet = Snippet(name, script);
if (widget.snippet != null) {
_provider.updateInfo(widget.snippet!, snippet);
_provider.update(widget.snippet!, snippet);
} else {
_provider.addInfo(snippet);
_provider.add(snippet);
}
Navigator.of(context).pop();
},

View File

@@ -1,31 +1,55 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:toolbox/core/route.dart';
import 'package:toolbox/core/utils.dart';
import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:toolbox/data/model/server/snippet.dart';
import 'package:toolbox/data/provider/server.dart';
import 'package:toolbox/data/provider/snippet.dart';
import 'package:toolbox/data/res/color.dart';
import 'package:toolbox/data/res/font_style.dart';
import 'package:toolbox/data/res/padding.dart';
import 'package:toolbox/generated/l10n.dart';
import 'package:toolbox/locator.dart';
import 'package:toolbox/view/page/snippet/edit.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
class SnippetListPage extends StatefulWidget {
const SnippetListPage({Key? key}) : super(key: key);
const SnippetListPage({Key? key, this.spi}) : super(key: key);
final ServerPrivateInfo? spi;
@override
_SnippetListPageState createState() => _SnippetListPageState();
}
class _SnippetListPageState extends State<SnippetListPage> {
int _selectedIndex = 0;
late ServerPrivateInfo _selectedIndex;
final _importFieldController = TextEditingController();
final _exportFieldController = TextEditingController();
final _textStyle = TextStyle(color: primaryColor);
late S s;
@override
void didChangeDependencies() {
super.didChangeDependencies();
s = S.of(context);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Snippet List'),
title: Text(s.snippet, style: size18),
actions: [
IconButton(
onPressed: () => _showImportExport(),
tooltip: s.importAndExport,
icon: const Icon(Icons.import_export)),
],
),
body: _buildBody(),
floatingActionButton: FloatingActionButton(
@@ -36,6 +60,97 @@ class _SnippetListPageState extends State<SnippetListPage> {
);
}
Future<void> _showImportExport() async {
await showRoundDialog(
context,
s.choose,
Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(s.import),
leading: const Icon(Icons.download),
onTap: () => _showImportDialog(),
),
ListTile(
title: Text(s.export),
leading: const Icon(Icons.file_upload),
onTap: () => _showExportDialog(),
),
],
),
[]);
}
Future<void> _showExportDialog() async {
Navigator.of(context).pop();
_exportFieldController.text = locator<SnippetProvider>().export;
await showRoundDialog(
context,
s.export,
TextField(
decoration: const InputDecoration(
labelText: 'JSON',
),
maxLines: 3,
controller: _exportFieldController,
),
[
TextButton(
child: Text(s.ok),
onPressed: () => Navigator.pop(context),
),
]);
}
Future<void> _showImportDialog() async {
Navigator.of(context).pop();
await showRoundDialog(
context,
s.import,
TextField(
decoration: InputDecoration(
labelText: s.urlOrJson,
),
maxLines: 2,
controller: _importFieldController,
),
[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(s.cancel)),
TextButton(
onPressed: () async =>
await _import(_importFieldController.text.trim()),
child: const Text('GO'),
)
]);
}
Future<void> _import(String text) async {
if (text.isEmpty) {
showSnackBar(context, Text(s.fieldMustNotEmpty));
return;
}
final snippetProvider = locator<SnippetProvider>();
if (text.startsWith('http')) {
final resp = await Dio().get(text);
if (resp.statusCode != 200) {
showSnackBar(
context, Text(s.httpFailedWithCode(resp.statusCode ?? '-1')));
return;
}
for (final snippet in getSnippetList(resp.data)) {
snippetProvider.add(snippet);
}
} else {
for (final snippet in getSnippetList(text)) {
snippetProvider.add(snippet);
}
}
Navigator.of(context).pop();
}
Widget _buildBody() {
return Consumer<SnippetProvider>(
builder: (_, key, __) {
@@ -45,49 +160,65 @@ class _SnippetListPageState extends State<SnippetListPage> {
itemCount: key.snippets.length,
itemExtent: 57,
itemBuilder: (context, idx) {
return RoundRectCard(Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.snippets[idx].name,
textAlign: TextAlign.center,
),
Row(children: [
TextButton(
onPressed: () => AppRoute(
SnippetEditPage(snippet: key.snippets[idx]),
'snippet edit page')
.go(context),
child: Text(
'Edit',
style: _textStyle,
)),
TextButton(
onPressed: () => _showRunDialog(key.snippets[idx]),
child: Text(
'Run',
style: _textStyle,
))
])
],
return RoundRectCard(Padding(
padding: roundRectCardPadding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
key.snippets[idx].name,
textAlign: TextAlign.center,
),
Row(children: [
TextButton(
onPressed: () => AppRoute(
SnippetEditPage(
snippet: key.snippets[idx]),
'snippet edit page')
.go(context),
child: Text(
s.edit,
style: _textStyle,
)),
TextButton(
onPressed: () {
final snippet = key.snippets[idx];
if (widget.spi == null) {
_showRunDialog(snippet);
return;
}
run(context, snippet);
},
child: Text(
s.run,
style: _textStyle,
))
])
],
),
));
})
: const Center(child: Text('No saved snippets.'));
: Center(child: Text(s.noSavedSnippet));
},
);
}
void _showRunDialog(Snippet snippet) {
showRoundDialog(context, 'Choose destination',
showRoundDialog(context, s.chooseDestination,
Consumer<ServerProvider>(builder: (_, provider, __) {
if (provider.servers.isEmpty) {
return const Text('No server available');
return Text(s.noServerAvailable);
}
_selectedIndex = provider.servers.first.info;
return SizedBox(
height: 111,
child: Stack(children: [
Positioned(
top: 36,
bottom: 36,
left: 0,
right: 0,
child: Container(
height: 37,
decoration: const BoxDecoration(
@@ -95,16 +226,13 @@ class _SnippetListPageState extends State<SnippetListPage> {
color: Colors.black12,
),
),
top: 36,
bottom: 36,
left: 0,
right: 0,
),
ListWheelScrollView.useDelegate(
itemExtent: 37,
diameterRatio: 1.2,
controller: FixedExtentScrollController(initialItem: 0),
onSelectedItemChanged: (idx) => _selectedIndex = idx,
onSelectedItemChanged: (idx) =>
_selectedIndex = provider.servers[idx].info,
physics: const FixedExtentScrollPhysics(),
childDelegate: ListWheelChildBuilderDelegate(
builder: (context, index) => Center(
@@ -118,21 +246,21 @@ class _SnippetListPageState extends State<SnippetListPage> {
]));
}), [
TextButton(
onPressed: () async {
final result = await locator<ServerProvider>()
.runSnippet(_selectedIndex, snippet);
if (result != null) {
showRoundDialog(context, 'Result', Text(result, style: const TextStyle(fontSize: 13)), [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'))
]);
}
},
child: const Text('Run')),
onPressed: () async => run(context, snippet), child: Text(s.run)),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel')),
onPressed: () => Navigator.of(context).pop(), child: Text(s.cancel)),
]);
}
Future<void> run(BuildContext context, Snippet snippet) async {
final result = await locator<ServerProvider>()
.runSnippet(widget.spi ?? _selectedIndex, snippet);
if (result != null) {
showRoundDialog(context, s.result,
Text(result, style: const TextStyle(fontSize: 13)), [
TextButton(
onPressed: () => Navigator.of(context).pop(), child: Text(s.close))
]);
}
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.dart';
const centerLoading = Center(child: CircularProgressIndicator());
const centerSizedLoading = SizedBox(
width: 77,
height: 77,
child: Center(
child: CircularProgressIndicator(),
),
);
final loadingIcon = IconButton(onPressed: () {}, icon: centerLoading);

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
/// 渐隐渐显实现
class FadeIn extends StatefulWidget {
final Widget child;
const FadeIn({Key? key, required this.child}) : super(key: key);
@override
_MyFadeInState createState() => _MyFadeInState();
}
class _MyFadeInState extends State<FadeIn> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 377),
);
_animation = Tween(
begin: 0.0,
end: 1.0,
).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
_controller.forward();
return FadeTransition(
opacity: _animation,
child: widget.child,
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:toolbox/view/widget/round_rect_card.dart';
Widget buildInput(BuildContext context, TextEditingController controller,
{int maxLines = 20,
String? hint,
Function(String)? onSubmitted,
bool? obscureText}) {
return RoundRectCard(
TextField(
maxLines: maxLines,
onSubmitted: onSubmitted,
decoration: InputDecoration(
fillColor: Theme.of(context).cardColor,
hintText: hint,
filled: true,
border: InputBorder.none),
controller: controller,
obscureText: obscureText ?? false,
),
);
}

View File

@@ -8,13 +8,10 @@ class RoundRectCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 17),
child: child,
),
margin: const EdgeInsets.symmetric(vertical: 7),
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(17))),
child: child,
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
class TwoLineText extends StatelessWidget {
const TwoLineText({Key? key, required this.up, required this.down})
: super(key: key);
final String up;
final String down;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
up,
style: const TextStyle(fontSize: 15),
),
Text(
down,
style: const TextStyle(fontSize: 11),
)
],
);
}
}

View File

@@ -8,23 +8,25 @@ const regUrl =
class UrlText extends StatelessWidget {
final String text;
final String? replace;
final TextAlign? textAlign;
final TextStyle style;
const UrlText(
{Key? key,
required this.text,
this.replace,
this.textAlign,
this.style = const TextStyle()})
: super(key: key);
List<InlineSpan> _getTextSpans(bool isDarkMode) {
List<InlineSpan> widgets = <InlineSpan>[];
final reg = RegExp(regUrl);
Iterable<Match> _matches = reg.allMatches(text);
Iterable<Match> matches = reg.allMatches(text);
List<_ResultMatch> resultMatches = <_ResultMatch>[];
int start = 0;
for (Match match in _matches) {
for (Match match in matches) {
final group0 = match.group(0);
if (group0 != null && group0.isNotEmpty) {
if (start != match.start) {
@@ -69,6 +71,7 @@ class UrlText extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RichText(
textAlign: textAlign ?? TextAlign.start,
text: TextSpan(children: _getTextSpans(isDarkMode(context))),
);
}

1
linux/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
flutter/ephemeral

116
linux/CMakeLists.txt Normal file
View File

@@ -0,0 +1,116 @@
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
set(BINARY_NAME "toolbox")
set(APPLICATION_ID "tech.lolli.toolbox")
cmake_policy(SET CMP0063 NEW)
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Configure build options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
# Flutter library and tool build rules.
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Application build
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
apply_standard_settings(${BINARY_NAME})
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(PLUGIN_BUNDLED_LIBRARIES)
install(FILES "${PLUGIN_BUNDLED_LIBRARIES}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@@ -0,0 +1,87 @@
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

Some files were not shown because too many files have changed in this diff Show More