diff --git a/flutter_app/.flutter-plugins-dependencies b/flutter_app/.flutter-plugins-dependencies new file mode 100644 index 0000000..e7d553b --- /dev/null +++ b/flutter_app/.flutter-plugins-dependencies @@ -0,0 +1 @@ +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"camera_avfoundation","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\camera_avfoundation-0.9.23+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_blue_plus_darwin","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_blue_plus_darwin-7.0.3\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_pty","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_pty-0.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_apple-2.3.13\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_apple","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\permission_handler_apple-9.4.7\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_ios","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_ios-6.4.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"webview_flutter_wkwebview","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\webview_flutter_wkwebview-3.24.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"camera_android_camerax","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\camera_android_camerax-0.6.30\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_blue_plus_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_blue_plus_android-7.0.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_plugin_android_lifecycle","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_plugin_android_lifecycle-2.0.34\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_pty","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_pty-0.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_android-4.6.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.23\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\permission_handler_android-12.1.0\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_android-2.4.23\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_android-6.3.29\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"usb_serial","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\usb_serial-0.5.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"webview_flutter_android","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\webview_flutter_android-4.10.15\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"flutter_blue_plus_darwin","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_blue_plus_darwin-7.0.3\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"flutter_pty","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_pty-0.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_apple","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_apple-2.3.13\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_foundation","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_foundation-2.5.6\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false},{"name":"url_launcher_macos","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_macos-3.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"webview_flutter_wkwebview","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\webview_flutter_wkwebview-3.24.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"flutter_blue_plus_linux","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_blue_plus_linux-7.0.3\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"flutter_pty","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_pty-0.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_linux","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_linux-2.4.1\\\\","native_build":false,"dependencies":["path_provider_linux"],"dev_dependency":false},{"name":"url_launcher_linux","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_linux-3.2.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"flutter_pty","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_pty-0.4.2\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"geolocator_windows","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_windows-0.2.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"permission_handler_windows","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\permission_handler_windows-0.2.1\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"shared_preferences_windows","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_windows-2.4.1\\\\","native_build":false,"dependencies":["path_provider_windows"],"dev_dependency":false},{"name":"url_launcher_windows","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_windows-3.1.5\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"web":[{"name":"camera_web","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\camera_web-0.3.5+3\\\\","dependencies":[],"dev_dependency":false},{"name":"flutter_blue_plus_web","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\flutter_blue_plus_web-7.0.2\\\\","dependencies":[],"dev_dependency":false},{"name":"geolocator_web","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\geolocator_web-4.1.3\\\\","dependencies":[],"dev_dependency":false},{"name":"permission_handler_html","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\permission_handler_html-0.1.3+5\\\\","dependencies":[],"dev_dependency":false},{"name":"shared_preferences_web","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\shared_preferences_web-2.4.3\\\\","dependencies":[],"dev_dependency":false},{"name":"url_launcher_web","path":"C:\\\\Users\\\\YouTube\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\url_launcher_web-2.4.2\\\\","dependencies":[],"dev_dependency":false}]},"dependencyGraph":[{"name":"camera","dependencies":["camera_android_camerax","camera_avfoundation","camera_web","flutter_plugin_android_lifecycle"]},{"name":"camera_android_camerax","dependencies":[]},{"name":"camera_avfoundation","dependencies":[]},{"name":"camera_web","dependencies":[]},{"name":"flutter_blue_plus","dependencies":["flutter_blue_plus_android","flutter_blue_plus_darwin","flutter_blue_plus_linux","flutter_blue_plus_web"]},{"name":"flutter_blue_plus_android","dependencies":[]},{"name":"flutter_blue_plus_darwin","dependencies":[]},{"name":"flutter_blue_plus_linux","dependencies":[]},{"name":"flutter_blue_plus_web","dependencies":[]},{"name":"flutter_plugin_android_lifecycle","dependencies":[]},{"name":"flutter_pty","dependencies":[]},{"name":"geolocator","dependencies":["geolocator_android","geolocator_apple","geolocator_web","geolocator_windows"]},{"name":"geolocator_android","dependencies":[]},{"name":"geolocator_apple","dependencies":[]},{"name":"geolocator_web","dependencies":[]},{"name":"geolocator_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"permission_handler","dependencies":["permission_handler_android","permission_handler_apple","permission_handler_html","permission_handler_windows"]},{"name":"permission_handler_android","dependencies":[]},{"name":"permission_handler_apple","dependencies":[]},{"name":"permission_handler_html","dependencies":[]},{"name":"permission_handler_windows","dependencies":[]},{"name":"shared_preferences","dependencies":["shared_preferences_android","shared_preferences_foundation","shared_preferences_linux","shared_preferences_web","shared_preferences_windows"]},{"name":"shared_preferences_android","dependencies":[]},{"name":"shared_preferences_foundation","dependencies":[]},{"name":"shared_preferences_linux","dependencies":["path_provider_linux"]},{"name":"shared_preferences_web","dependencies":[]},{"name":"shared_preferences_windows","dependencies":["path_provider_windows"]},{"name":"url_launcher","dependencies":["url_launcher_android","url_launcher_ios","url_launcher_linux","url_launcher_macos","url_launcher_web","url_launcher_windows"]},{"name":"url_launcher_android","dependencies":[]},{"name":"url_launcher_ios","dependencies":[]},{"name":"url_launcher_linux","dependencies":[]},{"name":"url_launcher_macos","dependencies":[]},{"name":"url_launcher_web","dependencies":[]},{"name":"url_launcher_windows","dependencies":[]},{"name":"usb_serial","dependencies":[]},{"name":"webview_flutter","dependencies":["webview_flutter_android","webview_flutter_wkwebview"]},{"name":"webview_flutter_android","dependencies":[]},{"name":"webview_flutter_wkwebview","dependencies":[]}],"date_created":"2026-03-31 11:11:33.051186","version":"3.41.6","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/flutter_app/android/app/build.gradle b/flutter_app/android/app/build.gradle index fd928c9..bd373e6 100644 --- a/flutter_app/android/app/build.gradle +++ b/flutter_app/android/app/build.gradle @@ -35,7 +35,7 @@ def hasKeystore = keystoreProperties.containsKey('storeFile') && android { namespace = "com.nxg.openclawproot" - compileSdk = 35 + compileSdk = 36 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/flutter_app/android/app/jniLibs/arm64-v8a/libproot.so b/flutter_app/android/app/jniLibs/arm64-v8a/libproot.so new file mode 100644 index 0000000..591fc56 Binary files /dev/null and b/flutter_app/android/app/jniLibs/arm64-v8a/libproot.so differ diff --git a/flutter_app/android/app/jniLibs/arm64-v8a/libprootloader.so b/flutter_app/android/app/jniLibs/arm64-v8a/libprootloader.so new file mode 100644 index 0000000..f123455 Binary files /dev/null and b/flutter_app/android/app/jniLibs/arm64-v8a/libprootloader.so differ diff --git a/flutter_app/android/app/jniLibs/arm64-v8a/libtalloc.so b/flutter_app/android/app/jniLibs/arm64-v8a/libtalloc.so new file mode 100644 index 0000000..e69de29 diff --git a/flutter_app/android/app/jniLibs/armeabi-v7a/libproot.so b/flutter_app/android/app/jniLibs/armeabi-v7a/libproot.so new file mode 100644 index 0000000..a28666c Binary files /dev/null and b/flutter_app/android/app/jniLibs/armeabi-v7a/libproot.so differ diff --git a/flutter_app/android/app/jniLibs/armeabi-v7a/libprootloader.so b/flutter_app/android/app/jniLibs/armeabi-v7a/libprootloader.so new file mode 100644 index 0000000..e7449aa Binary files /dev/null and b/flutter_app/android/app/jniLibs/armeabi-v7a/libprootloader.so differ diff --git a/flutter_app/android/app/jniLibs/armeabi-v7a/libtalloc.so b/flutter_app/android/app/jniLibs/armeabi-v7a/libtalloc.so new file mode 100644 index 0000000..e69de29 diff --git a/flutter_app/android/app/jniLibs/x86_64/libproot.so b/flutter_app/android/app/jniLibs/x86_64/libproot.so new file mode 100644 index 0000000..65e93a0 Binary files /dev/null and b/flutter_app/android/app/jniLibs/x86_64/libproot.so differ diff --git a/flutter_app/android/app/jniLibs/x86_64/libprootloader.so b/flutter_app/android/app/jniLibs/x86_64/libprootloader.so new file mode 100644 index 0000000..391b6b5 Binary files /dev/null and b/flutter_app/android/app/jniLibs/x86_64/libprootloader.so differ diff --git a/flutter_app/android/app/jniLibs/x86_64/libtalloc.so b/flutter_app/android/app/jniLibs/x86_64/libtalloc.so new file mode 100644 index 0000000..e69de29 diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml index 63c1511..9b8c648 100644 --- a/flutter_app/android/app/src/main/AndroidManifest.xml +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -65,6 +65,11 @@ android:exported="false" android:foregroundServiceType="specialUse" /> + + { result.success(TerminalSessionService.isRunning) } + "startNineRouterService" -> { + try { + NineRouterService.start(applicationContext) + result.success(true) + } catch (e: Exception) { + result.error("SERVICE_ERROR", e.message, null) + } + } + "stopNineRouterService" -> { + try { + NineRouterService.stop(applicationContext) + result.success(true) + } catch (e: Exception) { + result.error("SERVICE_ERROR", e.message, null) + } + } + "isNineRouterServiceRunning" -> { + result.success(NineRouterService.isRunning) + } "startNodeService" -> { try { NodeForegroundService.start(applicationContext) diff --git a/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/NineRouterService.kt b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/NineRouterService.kt new file mode 100644 index 0000000..f20343f --- /dev/null +++ b/flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/NineRouterService.kt @@ -0,0 +1,110 @@ +package com.nxg.openclawproot + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.os.PowerManager + +class NineRouterService : Service() { + companion object { + const val NOTIFICATION_ID = 7 + var isRunning = false + private set + + fun start(context: Context) { + val intent = Intent(context, NineRouterService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } + + fun stop(context: Context) { + val intent = Intent(context, NineRouterService::class.java) + context.stopService(intent) + } + } + + private var wakeLock: PowerManager.WakeLock? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForeground(NOTIFICATION_ID, buildNotification()) + if (isRunning) return START_STICKY + isRunning = true + acquireWakeLock() + return START_STICKY + } + + override fun onDestroy() { + isRunning = false + releaseWakeLock() + super.onDestroy() + } + + private fun acquireWakeLock() { + releaseWakeLock() + val pm = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "OpenClaw::NineRouterWakeLock" + ) + wakeLock?.acquire(24 * 60 * 60 * 1000L) + } + + private fun releaseWakeLock() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + GatewayService.CHANNEL_ID, + "OpenClaw Gateway", + NotificationManager.IMPORTANCE_LOW + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun buildNotification(): Notification { + val intent = Intent(this, MainActivity::class.java) + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(this, GatewayService.CHANNEL_ID) + .setContentTitle("9Router") + .setContentText("9Router is running on port 20128") + .setSmallIcon(android.R.drawable.ic_menu_manage) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } else { + @Suppress("DEPRECATION") + Notification.Builder(this) + .setContentTitle("9Router") + .setContentText("9Router is running on port 20128") + .setSmallIcon(android.R.drawable.ic_menu_manage) + .setContentIntent(pendingIntent) + .setOngoing(true) + .build() + } + } +} diff --git a/flutter_app/android/gradle/wrapper/gradle-wrapper.jar b/flutter_app/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/flutter_app/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/flutter_app/android/gradle/wrapper/gradle-wrapper.properties b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties index 7bb2df6..efdcc4a 100644 --- a/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/flutter_app/android/gradlew b/flutter_app/android/gradlew new file mode 100644 index 0000000..9d82f78 --- /dev/null +++ b/flutter_app/android/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/flutter_app/android/gradlew.bat b/flutter_app/android/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/flutter_app/android/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/flutter_app/android/settings.gradle b/flutter_app/android/settings.gradle index 54b690d..b507b94 100644 --- a/flutter_app/android/settings.gradle +++ b/flutter_app/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.1.0" apply false - id "org.jetbrains.kotlin.android" version "1.9.0" apply false + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart index 5732c6a..9512211 100644 --- a/flutter_app/lib/app.dart +++ b/flutter_app/lib/app.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:provider/provider.dart'; import 'providers/setup_provider.dart'; @@ -95,7 +95,7 @@ class OpenClawApp extends StatelessWidget { color: Colors.white, ), ), - cardTheme: CardTheme( + cardTheme: CardThemeData( elevation: 0, color: AppColors.darkSurface, shape: RoundedRectangleBorder( @@ -165,7 +165,7 @@ class OpenClawApp extends StatelessWidget { color: AppColors.darkBorder, space: 1, ), - dialogTheme: DialogTheme( + dialogTheme: DialogThemeData( backgroundColor: AppColors.darkSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -226,7 +226,7 @@ class OpenClawApp extends StatelessWidget { color: const Color(0xFF0A0A0A), ), ), - cardTheme: CardTheme( + cardTheme: CardThemeData( elevation: 0, color: AppColors.lightBg, shape: RoundedRectangleBorder( @@ -296,7 +296,7 @@ class OpenClawApp extends StatelessWidget { color: AppColors.lightBorder, space: 1, ), - dialogTheme: DialogTheme( + dialogTheme: DialogThemeData( backgroundColor: AppColors.lightBg, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), diff --git a/flutter_app/lib/constants.dart b/flutter_app/lib/constants.dart index 3beefbd..44619cf 100644 --- a/flutter_app/lib/constants.dart +++ b/flutter_app/lib/constants.dart @@ -31,7 +31,7 @@ class AppConstants { static const String rootfsArmhf = '${ubuntuRootfsUrl}armhf.tar.gz'; static const String rootfsAmd64 = '${ubuntuRootfsUrl}amd64.tar.gz'; - // Node.js binary tarball — downloaded directly by Flutter, extracted by Java. + // Node.js binary tarball ?downloaded directly by Flutter, extracted by Java. // Bypasses curl/gpg/NodeSource which fail inside proot. static const String nodeVersion = '22.14.0'; static const String nodeBaseUrl = diff --git a/flutter_app/lib/l10n/app_en.arb b/flutter_app/lib/l10n/app_en.arb new file mode 100644 index 0000000..fa1840b --- /dev/null +++ b/flutter_app/lib/l10n/app_en.arb @@ -0,0 +1,171 @@ +{ + "@@locale": "en", + "appName": "OpenClaw", + "cancel": "Cancel", + "confirm": "Confirm", + "retry": "Retry", + "done": "Done", + "save": "Save", + "remove": "Remove", + "install": "Install", + "loading": "Loading...", + "error": "Error", + "copy": "Copy", + "paste": "Paste", + "openUrl": "Open URL", + "screenshot": "Screenshot", + "restart": "Restart", + "later": "Later", + "download": "Download", + "copied": "Copied to clipboard", + "noUrlFound": "No URL found in selection", + "linkCopied": "Link copied", + "openLink": "Open Link", + "aiGatewayForAndroid": "AI Gateway for Android", + "checkingSetup": "Checking setup status...", + "repairingBypass": "Repairing bionic bypass...", + "reinstallingNode": "Reinstalling Node.js...", + "reinstallingOpenClaw": "Reinstalling OpenClaw...", + "quickActions": "QUICK ACTIONS", + "terminal": "Terminal", + "terminalSubtitle": "Open Ubuntu shell with OpenClaw", + "webDashboard": "Web Dashboard", + "webDashboardSubtitle": "Open OpenClaw dashboard in browser", + "startGatewayFirst": "Start gateway first", + "onboarding": "Onboarding", + "onboardingSubtitle": "Configure API keys and binding", + "configure": "Configure", + "configureSubtitle": "Manage gateway settings", + "aiProviders": "AI Providers", + "aiProvidersSubtitle": "Configure models and API keys", + "packages": "Packages", + "packagesSubtitle": "Install optional tools (Go, Homebrew, SSH)", + "sshAccess": "SSH Access", + "sshAccessSubtitle": "Remote terminal access via SSH", + "logs": "Logs", + "logsSubtitle": "View gateway output and errors", + "snapshot": "Snapshot", + "snapshotSubtitle": "Backup or restore your config", + "node": "Node", + "nodeConnected": "Connected to gateway", + "nodeCapabilities": "Device capabilities for AI", + "dashboardUrlCopied": "Dashboard URL copied", + "copyDashboardUrl": "Copy dashboard URL", + "cliProxy": "CLIProxy Manager", + "cliProxySubtitle": "Manage free AI account proxy", + "gateway": "Gateway", + "startGateway": "Start Gateway", + "stopGateway": "Stop Gateway", + "viewLogs": "View Logs", + "urlCopied": "URL copied to clipboard", + "copyUrl": "Copy URL", + "openDashboard": "Open dashboard", + "gatewayRunning": "Running", + "gatewayStarting": "Starting", + "gatewayError": "Error", + "gatewayStopped": "Stopped", + "enableNode": "Enable Node", + "disableNode": "Disable Node", + "reconnect": "Reconnect", + "nodePaired": "Paired", + "nodeConnecting": "Connecting", + "nodeDisconnected": "Disconnected", + "nodeDisabled": "Disabled", + "nodeConfigure": "Configure", + "settings": "Settings", + "general": "GENERAL", + "autoStartGateway": "Auto-start gateway", + "autoStartSubtitle": "Start the gateway when the app opens", + "batteryOptimization": "Battery Optimization", + "batteryOptimized": "Optimized (may kill background sessions)", + "batteryUnrestricted": "Unrestricted (recommended)", + "setupStorage": "Setup Storage", + "storageGranted": "Granted — proot can access /sdcard. Revoke if not needed.", + "storageNotGranted": "Allow access to shared storage", + "nodeSection": "NODE", + "enableNodeTitle": "Enable Node", + "enableNodeSubtitle": "Provide device capabilities to the gateway", + "nodeConfiguration": "Node Configuration", + "nodeConfigSubtitle": "Connection, pairing, and capabilities", + "systemInfo": "SYSTEM INFO", + "architecture": "Architecture", + "prootPath": "PRoot path", + "rootfs": "Rootfs", + "installed": "Installed", + "notInstalled": "Not installed", + "maintenance": "MAINTENANCE", + "exportSnapshot": "Export Snapshot", + "exportSnapshotSubtitle": "Backup config to Downloads", + "importSnapshot": "Import Snapshot", + "importSnapshotSubtitle": "Restore config from backup", + "rerunSetup": "Re-run setup", + "rerunSetupSubtitle": "Reinstall or repair the environment", + "about": "ABOUT", + "checkForUpdates": "Check for Updates", + "checkUpdatesSubtitle": "Check GitHub for a newer release", + "developer": "Developer", + "github": "GitHub", + "contact": "Contact", + "license": "License", + "updateAvailable": "Update Available", + "currentVersion": "Current", + "latestVersion": "Latest", + "alreadyLatest": "You're on the latest version", + "checkUpdateFailed": "Could not check for updates", + "snapshotSaved": "Snapshot saved to", + "exportFailed": "Export failed", + "noSnapshotFound": "No snapshot found at", + "snapshotRestored": "Snapshot restored successfully. Restart the gateway to apply.", + "importFailed": "Import failed", + "setupOpenClaw": "Setup OpenClaw", + "setupRunning": "Setting up the environment. This may take several minutes.", + "setupDescription": "This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.", + "downloadRootfs": "Download Ubuntu rootfs", + "extractRootfs": "Extract rootfs", + "installNode": "Install Node.js", + "installOpenClaw": "Install OpenClaw", + "configureBionicBypass": "Configure Bionic Bypass", + "setupComplete": "Setup complete!", + "configureApiKeys": "Configure API Keys", + "beginSetup": "Begin Setup", + "retrySetup": "Retry Setup", + "storageRequired": "Requires ~500MB of storage and an internet connection", + "optionalPackages": "OPTIONAL PACKAGES", + "gatewayLogs": "Gateway Logs", + "filterLogs": "Filter logs...", + "noLogsYet": "No logs yet. Start the gateway.", + "noMatchingLogs": "No matching logs.", + "copyAllLogs": "Copy all logs", + "autoScrollOn": "Auto-scroll on", + "autoScrollOff": "Auto-scroll off", + "activeModel": "Active Model", + "selectProvider": "Select a provider to configure its API key and model.", + "active": "Active", + "configured": "Configured", + "apiKey": "API Key", + "model": "Model", + "customModel": "Custom...", + "customModelHint": "e.g. meta/llama-3.3-70b-instruct", + "customModelLabel": "Custom model name", + "saveAndActivate": "Save & Activate", + "removeConfiguration": "Remove Configuration", + "apiKeyEmpty": "API key cannot be empty", + "modelEmpty": "Model name cannot be empty", + "configuredAndActivated": "configured and activated", + "saveFailed": "Failed to save", + "removeFailed": "Failed to remove", + "removeProvider": "Remove", + "removeProviderContent": "This will delete the API key and deactivate the model.", + "startingTerminal": "Starting terminal...", + "failedToStartTerminal": "Failed to start terminal", + "openClawOnboarding": "OpenClaw Onboarding", + "startingOnboarding": "Starting onboarding...", + "failedToStartOnboarding": "Failed to start onboarding", + "goToDashboard": "Go to Dashboard", + "cliProxyManagement": "CLIProxy Management", + "cliProxyNotRunning": "CLIProxy service is not running", + "openInBrowser": "Open in browser", + "refresh": "Refresh", + "reconnectProxy": "Reconnect", + "installedBadge": "Installed" +} diff --git a/flutter_app/lib/l10n/app_localizations.dart b/flutter_app/lib/l10n/app_localizations.dart new file mode 100644 index 0000000..186d611 --- /dev/null +++ b/flutter_app/lib/l10n/app_localizations.dart @@ -0,0 +1,1141 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('zh') + ]; + + /// No description provided for @appName. + /// + /// In en, this message translates to: + /// **'OpenClaw'** + String get appName; + + /// No description provided for @cancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// No description provided for @confirm. + /// + /// In en, this message translates to: + /// **'Confirm'** + String get confirm; + + /// No description provided for @retry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get retry; + + /// No description provided for @done. + /// + /// In en, this message translates to: + /// **'Done'** + String get done; + + /// No description provided for @save. + /// + /// In en, this message translates to: + /// **'Save'** + String get save; + + /// No description provided for @remove. + /// + /// In en, this message translates to: + /// **'Remove'** + String get remove; + + /// No description provided for @install. + /// + /// In en, this message translates to: + /// **'Install'** + String get install; + + /// No description provided for @loading. + /// + /// In en, this message translates to: + /// **'Loading...'** + String get loading; + + /// No description provided for @error. + /// + /// In en, this message translates to: + /// **'Error'** + String get error; + + /// No description provided for @copy. + /// + /// In en, this message translates to: + /// **'Copy'** + String get copy; + + /// No description provided for @paste. + /// + /// In en, this message translates to: + /// **'Paste'** + String get paste; + + /// No description provided for @openUrl. + /// + /// In en, this message translates to: + /// **'Open URL'** + String get openUrl; + + /// No description provided for @screenshot. + /// + /// In en, this message translates to: + /// **'Screenshot'** + String get screenshot; + + /// No description provided for @restart. + /// + /// In en, this message translates to: + /// **'Restart'** + String get restart; + + /// No description provided for @later. + /// + /// In en, this message translates to: + /// **'Later'** + String get later; + + /// No description provided for @download. + /// + /// In en, this message translates to: + /// **'Download'** + String get download; + + /// No description provided for @copied. + /// + /// In en, this message translates to: + /// **'Copied to clipboard'** + String get copied; + + /// No description provided for @noUrlFound. + /// + /// In en, this message translates to: + /// **'No URL found in selection'** + String get noUrlFound; + + /// No description provided for @linkCopied. + /// + /// In en, this message translates to: + /// **'Link copied'** + String get linkCopied; + + /// No description provided for @openLink. + /// + /// In en, this message translates to: + /// **'Open Link'** + String get openLink; + + /// No description provided for @aiGatewayForAndroid. + /// + /// In en, this message translates to: + /// **'AI Gateway for Android'** + String get aiGatewayForAndroid; + + /// No description provided for @checkingSetup. + /// + /// In en, this message translates to: + /// **'Checking setup status...'** + String get checkingSetup; + + /// No description provided for @repairingBypass. + /// + /// In en, this message translates to: + /// **'Repairing bionic bypass...'** + String get repairingBypass; + + /// No description provided for @reinstallingNode. + /// + /// In en, this message translates to: + /// **'Reinstalling Node.js...'** + String get reinstallingNode; + + /// No description provided for @reinstallingOpenClaw. + /// + /// In en, this message translates to: + /// **'Reinstalling OpenClaw...'** + String get reinstallingOpenClaw; + + /// No description provided for @quickActions. + /// + /// In en, this message translates to: + /// **'QUICK ACTIONS'** + String get quickActions; + + /// No description provided for @terminal. + /// + /// In en, this message translates to: + /// **'Terminal'** + String get terminal; + + /// No description provided for @terminalSubtitle. + /// + /// In en, this message translates to: + /// **'Open Ubuntu shell with OpenClaw'** + String get terminalSubtitle; + + /// No description provided for @webDashboard. + /// + /// In en, this message translates to: + /// **'Web Dashboard'** + String get webDashboard; + + /// No description provided for @webDashboardSubtitle. + /// + /// In en, this message translates to: + /// **'Open OpenClaw dashboard in browser'** + String get webDashboardSubtitle; + + /// No description provided for @startGatewayFirst. + /// + /// In en, this message translates to: + /// **'Start gateway first'** + String get startGatewayFirst; + + /// No description provided for @onboarding. + /// + /// In en, this message translates to: + /// **'Onboarding'** + String get onboarding; + + /// No description provided for @onboardingSubtitle. + /// + /// In en, this message translates to: + /// **'Configure API keys and binding'** + String get onboardingSubtitle; + + /// No description provided for @configure. + /// + /// In en, this message translates to: + /// **'Configure'** + String get configure; + + /// No description provided for @configureSubtitle. + /// + /// In en, this message translates to: + /// **'Manage gateway settings'** + String get configureSubtitle; + + /// No description provided for @aiProviders. + /// + /// In en, this message translates to: + /// **'AI Providers'** + String get aiProviders; + + /// No description provided for @aiProvidersSubtitle. + /// + /// In en, this message translates to: + /// **'Configure models and API keys'** + String get aiProvidersSubtitle; + + /// No description provided for @packages. + /// + /// In en, this message translates to: + /// **'Packages'** + String get packages; + + /// No description provided for @packagesSubtitle. + /// + /// In en, this message translates to: + /// **'Install optional tools (Go, Homebrew, SSH)'** + String get packagesSubtitle; + + /// No description provided for @sshAccess. + /// + /// In en, this message translates to: + /// **'SSH Access'** + String get sshAccess; + + /// No description provided for @sshAccessSubtitle. + /// + /// In en, this message translates to: + /// **'Remote terminal access via SSH'** + String get sshAccessSubtitle; + + /// No description provided for @logs. + /// + /// In en, this message translates to: + /// **'Logs'** + String get logs; + + /// No description provided for @logsSubtitle. + /// + /// In en, this message translates to: + /// **'View gateway output and errors'** + String get logsSubtitle; + + /// No description provided for @snapshot. + /// + /// In en, this message translates to: + /// **'Snapshot'** + String get snapshot; + + /// No description provided for @snapshotSubtitle. + /// + /// In en, this message translates to: + /// **'Backup or restore your config'** + String get snapshotSubtitle; + + /// No description provided for @node. + /// + /// In en, this message translates to: + /// **'Node'** + String get node; + + /// No description provided for @nodeConnected. + /// + /// In en, this message translates to: + /// **'Connected to gateway'** + String get nodeConnected; + + /// No description provided for @nodeCapabilities. + /// + /// In en, this message translates to: + /// **'Device capabilities for AI'** + String get nodeCapabilities; + + /// No description provided for @dashboardUrlCopied. + /// + /// In en, this message translates to: + /// **'Dashboard URL copied'** + String get dashboardUrlCopied; + + /// No description provided for @copyDashboardUrl. + /// + /// In en, this message translates to: + /// **'Copy dashboard URL'** + String get copyDashboardUrl; + + /// No description provided for @cliProxy. + /// + /// In en, this message translates to: + /// **'CLIProxy Manager'** + String get cliProxy; + + /// No description provided for @cliProxySubtitle. + /// + /// In en, this message translates to: + /// **'Manage free AI account proxy'** + String get cliProxySubtitle; + + /// No description provided for @gateway. + /// + /// In en, this message translates to: + /// **'Gateway'** + String get gateway; + + /// No description provided for @startGateway. + /// + /// In en, this message translates to: + /// **'Start Gateway'** + String get startGateway; + + /// No description provided for @stopGateway. + /// + /// In en, this message translates to: + /// **'Stop Gateway'** + String get stopGateway; + + /// No description provided for @viewLogs. + /// + /// In en, this message translates to: + /// **'View Logs'** + String get viewLogs; + + /// No description provided for @urlCopied. + /// + /// In en, this message translates to: + /// **'URL copied to clipboard'** + String get urlCopied; + + /// No description provided for @copyUrl. + /// + /// In en, this message translates to: + /// **'Copy URL'** + String get copyUrl; + + /// No description provided for @openDashboard. + /// + /// In en, this message translates to: + /// **'Open dashboard'** + String get openDashboard; + + /// No description provided for @gatewayRunning. + /// + /// In en, this message translates to: + /// **'Running'** + String get gatewayRunning; + + /// No description provided for @gatewayStarting. + /// + /// In en, this message translates to: + /// **'Starting'** + String get gatewayStarting; + + /// No description provided for @gatewayError. + /// + /// In en, this message translates to: + /// **'Error'** + String get gatewayError; + + /// No description provided for @gatewayStopped. + /// + /// In en, this message translates to: + /// **'Stopped'** + String get gatewayStopped; + + /// No description provided for @enableNode. + /// + /// In en, this message translates to: + /// **'Enable Node'** + String get enableNode; + + /// No description provided for @disableNode. + /// + /// In en, this message translates to: + /// **'Disable Node'** + String get disableNode; + + /// No description provided for @reconnect. + /// + /// In en, this message translates to: + /// **'Reconnect'** + String get reconnect; + + /// No description provided for @nodePaired. + /// + /// In en, this message translates to: + /// **'Paired'** + String get nodePaired; + + /// No description provided for @nodeConnecting. + /// + /// In en, this message translates to: + /// **'Connecting'** + String get nodeConnecting; + + /// No description provided for @nodeDisconnected. + /// + /// In en, this message translates to: + /// **'Disconnected'** + String get nodeDisconnected; + + /// No description provided for @nodeDisabled. + /// + /// In en, this message translates to: + /// **'Disabled'** + String get nodeDisabled; + + /// No description provided for @nodeConfigure. + /// + /// In en, this message translates to: + /// **'Configure'** + String get nodeConfigure; + + /// No description provided for @settings. + /// + /// In en, this message translates to: + /// **'Settings'** + String get settings; + + /// No description provided for @general. + /// + /// In en, this message translates to: + /// **'GENERAL'** + String get general; + + /// No description provided for @autoStartGateway. + /// + /// In en, this message translates to: + /// **'Auto-start gateway'** + String get autoStartGateway; + + /// No description provided for @autoStartSubtitle. + /// + /// In en, this message translates to: + /// **'Start the gateway when the app opens'** + String get autoStartSubtitle; + + /// No description provided for @batteryOptimization. + /// + /// In en, this message translates to: + /// **'Battery Optimization'** + String get batteryOptimization; + + /// No description provided for @batteryOptimized. + /// + /// In en, this message translates to: + /// **'Optimized (may kill background sessions)'** + String get batteryOptimized; + + /// No description provided for @batteryUnrestricted. + /// + /// In en, this message translates to: + /// **'Unrestricted (recommended)'** + String get batteryUnrestricted; + + /// No description provided for @setupStorage. + /// + /// In en, this message translates to: + /// **'Setup Storage'** + String get setupStorage; + + /// No description provided for @storageGranted. + /// + /// In en, this message translates to: + /// **'Granted — proot can access /sdcard. Revoke if not needed.'** + String get storageGranted; + + /// No description provided for @storageNotGranted. + /// + /// In en, this message translates to: + /// **'Allow access to shared storage'** + String get storageNotGranted; + + /// No description provided for @nodeSection. + /// + /// In en, this message translates to: + /// **'NODE'** + String get nodeSection; + + /// No description provided for @enableNodeTitle. + /// + /// In en, this message translates to: + /// **'Enable Node'** + String get enableNodeTitle; + + /// No description provided for @enableNodeSubtitle. + /// + /// In en, this message translates to: + /// **'Provide device capabilities to the gateway'** + String get enableNodeSubtitle; + + /// No description provided for @nodeConfiguration. + /// + /// In en, this message translates to: + /// **'Node Configuration'** + String get nodeConfiguration; + + /// No description provided for @nodeConfigSubtitle. + /// + /// In en, this message translates to: + /// **'Connection, pairing, and capabilities'** + String get nodeConfigSubtitle; + + /// No description provided for @systemInfo. + /// + /// In en, this message translates to: + /// **'SYSTEM INFO'** + String get systemInfo; + + /// No description provided for @architecture. + /// + /// In en, this message translates to: + /// **'Architecture'** + String get architecture; + + /// No description provided for @prootPath. + /// + /// In en, this message translates to: + /// **'PRoot path'** + String get prootPath; + + /// No description provided for @rootfs. + /// + /// In en, this message translates to: + /// **'Rootfs'** + String get rootfs; + + /// No description provided for @installed. + /// + /// In en, this message translates to: + /// **'Installed'** + String get installed; + + /// No description provided for @notInstalled. + /// + /// In en, this message translates to: + /// **'Not installed'** + String get notInstalled; + + /// No description provided for @maintenance. + /// + /// In en, this message translates to: + /// **'MAINTENANCE'** + String get maintenance; + + /// No description provided for @exportSnapshot. + /// + /// In en, this message translates to: + /// **'Export Snapshot'** + String get exportSnapshot; + + /// No description provided for @exportSnapshotSubtitle. + /// + /// In en, this message translates to: + /// **'Backup config to Downloads'** + String get exportSnapshotSubtitle; + + /// No description provided for @importSnapshot. + /// + /// In en, this message translates to: + /// **'Import Snapshot'** + String get importSnapshot; + + /// No description provided for @importSnapshotSubtitle. + /// + /// In en, this message translates to: + /// **'Restore config from backup'** + String get importSnapshotSubtitle; + + /// No description provided for @rerunSetup. + /// + /// In en, this message translates to: + /// **'Re-run setup'** + String get rerunSetup; + + /// No description provided for @rerunSetupSubtitle. + /// + /// In en, this message translates to: + /// **'Reinstall or repair the environment'** + String get rerunSetupSubtitle; + + /// No description provided for @about. + /// + /// In en, this message translates to: + /// **'ABOUT'** + String get about; + + /// No description provided for @checkForUpdates. + /// + /// In en, this message translates to: + /// **'Check for Updates'** + String get checkForUpdates; + + /// No description provided for @checkUpdatesSubtitle. + /// + /// In en, this message translates to: + /// **'Check GitHub for a newer release'** + String get checkUpdatesSubtitle; + + /// No description provided for @developer. + /// + /// In en, this message translates to: + /// **'Developer'** + String get developer; + + /// No description provided for @github. + /// + /// In en, this message translates to: + /// **'GitHub'** + String get github; + + /// No description provided for @contact. + /// + /// In en, this message translates to: + /// **'Contact'** + String get contact; + + /// No description provided for @license. + /// + /// In en, this message translates to: + /// **'License'** + String get license; + + /// No description provided for @updateAvailable. + /// + /// In en, this message translates to: + /// **'Update Available'** + String get updateAvailable; + + /// No description provided for @currentVersion. + /// + /// In en, this message translates to: + /// **'Current'** + String get currentVersion; + + /// No description provided for @latestVersion. + /// + /// In en, this message translates to: + /// **'Latest'** + String get latestVersion; + + /// No description provided for @alreadyLatest. + /// + /// In en, this message translates to: + /// **'You\'re on the latest version'** + String get alreadyLatest; + + /// No description provided for @checkUpdateFailed. + /// + /// In en, this message translates to: + /// **'Could not check for updates'** + String get checkUpdateFailed; + + /// No description provided for @snapshotSaved. + /// + /// In en, this message translates to: + /// **'Snapshot saved to'** + String get snapshotSaved; + + /// No description provided for @exportFailed. + /// + /// In en, this message translates to: + /// **'Export failed'** + String get exportFailed; + + /// No description provided for @noSnapshotFound. + /// + /// In en, this message translates to: + /// **'No snapshot found at'** + String get noSnapshotFound; + + /// No description provided for @snapshotRestored. + /// + /// In en, this message translates to: + /// **'Snapshot restored successfully. Restart the gateway to apply.'** + String get snapshotRestored; + + /// No description provided for @importFailed. + /// + /// In en, this message translates to: + /// **'Import failed'** + String get importFailed; + + /// No description provided for @setupOpenClaw. + /// + /// In en, this message translates to: + /// **'Setup OpenClaw'** + String get setupOpenClaw; + + /// No description provided for @setupRunning. + /// + /// In en, this message translates to: + /// **'Setting up the environment. This may take several minutes.'** + String get setupRunning; + + /// No description provided for @setupDescription. + /// + /// In en, this message translates to: + /// **'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.'** + String get setupDescription; + + /// No description provided for @downloadRootfs. + /// + /// In en, this message translates to: + /// **'Download Ubuntu rootfs'** + String get downloadRootfs; + + /// No description provided for @extractRootfs. + /// + /// In en, this message translates to: + /// **'Extract rootfs'** + String get extractRootfs; + + /// No description provided for @installNode. + /// + /// In en, this message translates to: + /// **'Install Node.js'** + String get installNode; + + /// No description provided for @installOpenClaw. + /// + /// In en, this message translates to: + /// **'Install OpenClaw'** + String get installOpenClaw; + + /// No description provided for @configureBionicBypass. + /// + /// In en, this message translates to: + /// **'Configure Bionic Bypass'** + String get configureBionicBypass; + + /// No description provided for @setupComplete. + /// + /// In en, this message translates to: + /// **'Setup complete!'** + String get setupComplete; + + /// No description provided for @configureApiKeys. + /// + /// In en, this message translates to: + /// **'Configure API Keys'** + String get configureApiKeys; + + /// No description provided for @beginSetup. + /// + /// In en, this message translates to: + /// **'Begin Setup'** + String get beginSetup; + + /// No description provided for @retrySetup. + /// + /// In en, this message translates to: + /// **'Retry Setup'** + String get retrySetup; + + /// No description provided for @storageRequired. + /// + /// In en, this message translates to: + /// **'Requires ~500MB of storage and an internet connection'** + String get storageRequired; + + /// No description provided for @optionalPackages. + /// + /// In en, this message translates to: + /// **'OPTIONAL PACKAGES'** + String get optionalPackages; + + /// No description provided for @gatewayLogs. + /// + /// In en, this message translates to: + /// **'Gateway Logs'** + String get gatewayLogs; + + /// No description provided for @filterLogs. + /// + /// In en, this message translates to: + /// **'Filter logs...'** + String get filterLogs; + + /// No description provided for @noLogsYet. + /// + /// In en, this message translates to: + /// **'No logs yet. Start the gateway.'** + String get noLogsYet; + + /// No description provided for @noMatchingLogs. + /// + /// In en, this message translates to: + /// **'No matching logs.'** + String get noMatchingLogs; + + /// No description provided for @copyAllLogs. + /// + /// In en, this message translates to: + /// **'Copy all logs'** + String get copyAllLogs; + + /// No description provided for @autoScrollOn. + /// + /// In en, this message translates to: + /// **'Auto-scroll on'** + String get autoScrollOn; + + /// No description provided for @autoScrollOff. + /// + /// In en, this message translates to: + /// **'Auto-scroll off'** + String get autoScrollOff; + + /// No description provided for @activeModel. + /// + /// In en, this message translates to: + /// **'Active Model'** + String get activeModel; + + /// No description provided for @selectProvider. + /// + /// In en, this message translates to: + /// **'Select a provider to configure its API key and model.'** + String get selectProvider; + + /// No description provided for @active. + /// + /// In en, this message translates to: + /// **'Active'** + String get active; + + /// No description provided for @configured. + /// + /// In en, this message translates to: + /// **'Configured'** + String get configured; + + /// No description provided for @apiKey. + /// + /// In en, this message translates to: + /// **'API Key'** + String get apiKey; + + /// No description provided for @model. + /// + /// In en, this message translates to: + /// **'Model'** + String get model; + + /// No description provided for @customModel. + /// + /// In en, this message translates to: + /// **'Custom...'** + String get customModel; + + /// No description provided for @customModelHint. + /// + /// In en, this message translates to: + /// **'e.g. meta/llama-3.3-70b-instruct'** + String get customModelHint; + + /// No description provided for @customModelLabel. + /// + /// In en, this message translates to: + /// **'Custom model name'** + String get customModelLabel; + + /// No description provided for @saveAndActivate. + /// + /// In en, this message translates to: + /// **'Save & Activate'** + String get saveAndActivate; + + /// No description provided for @removeConfiguration. + /// + /// In en, this message translates to: + /// **'Remove Configuration'** + String get removeConfiguration; + + /// No description provided for @apiKeyEmpty. + /// + /// In en, this message translates to: + /// **'API key cannot be empty'** + String get apiKeyEmpty; + + /// No description provided for @modelEmpty. + /// + /// In en, this message translates to: + /// **'Model name cannot be empty'** + String get modelEmpty; + + /// No description provided for @configuredAndActivated. + /// + /// In en, this message translates to: + /// **'configured and activated'** + String get configuredAndActivated; + + /// No description provided for @saveFailed. + /// + /// In en, this message translates to: + /// **'Failed to save'** + String get saveFailed; + + /// No description provided for @removeFailed. + /// + /// In en, this message translates to: + /// **'Failed to remove'** + String get removeFailed; + + /// No description provided for @removeProvider. + /// + /// In en, this message translates to: + /// **'Remove'** + String get removeProvider; + + /// No description provided for @removeProviderContent. + /// + /// In en, this message translates to: + /// **'This will delete the API key and deactivate the model.'** + String get removeProviderContent; + + /// No description provided for @startingTerminal. + /// + /// In en, this message translates to: + /// **'Starting terminal...'** + String get startingTerminal; + + /// No description provided for @failedToStartTerminal. + /// + /// In en, this message translates to: + /// **'Failed to start terminal'** + String get failedToStartTerminal; + + /// No description provided for @openClawOnboarding. + /// + /// In en, this message translates to: + /// **'OpenClaw Onboarding'** + String get openClawOnboarding; + + /// No description provided for @startingOnboarding. + /// + /// In en, this message translates to: + /// **'Starting onboarding...'** + String get startingOnboarding; + + /// No description provided for @failedToStartOnboarding. + /// + /// In en, this message translates to: + /// **'Failed to start onboarding'** + String get failedToStartOnboarding; + + /// No description provided for @goToDashboard. + /// + /// In en, this message translates to: + /// **'Go to Dashboard'** + String get goToDashboard; + + /// No description provided for @cliProxyManagement. + /// + /// In en, this message translates to: + /// **'CLIProxy Management'** + String get cliProxyManagement; + + /// No description provided for @cliProxyNotRunning. + /// + /// In en, this message translates to: + /// **'CLIProxy service is not running'** + String get cliProxyNotRunning; + + /// No description provided for @openInBrowser. + /// + /// In en, this message translates to: + /// **'Open in browser'** + String get openInBrowser; + + /// No description provided for @refresh. + /// + /// In en, this message translates to: + /// **'Refresh'** + String get refresh; + + /// No description provided for @reconnectProxy. + /// + /// In en, this message translates to: + /// **'Reconnect'** + String get reconnectProxy; + + /// No description provided for @installedBadge. + /// + /// In en, this message translates to: + /// **'Installed'** + String get installedBadge; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => + ['en', 'zh'].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.'); +} diff --git a/flutter_app/lib/l10n/app_localizations_en.dart b/flutter_app/lib/l10n/app_localizations_en.dart new file mode 100644 index 0000000..76839e9 --- /dev/null +++ b/flutter_app/lib/l10n/app_localizations_en.dart @@ -0,0 +1,521 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get appName => 'OpenClaw'; + + @override + String get cancel => 'Cancel'; + + @override + String get confirm => 'Confirm'; + + @override + String get retry => 'Retry'; + + @override + String get done => 'Done'; + + @override + String get save => 'Save'; + + @override + String get remove => 'Remove'; + + @override + String get install => 'Install'; + + @override + String get loading => 'Loading...'; + + @override + String get error => 'Error'; + + @override + String get copy => 'Copy'; + + @override + String get paste => 'Paste'; + + @override + String get openUrl => 'Open URL'; + + @override + String get screenshot => 'Screenshot'; + + @override + String get restart => 'Restart'; + + @override + String get later => 'Later'; + + @override + String get download => 'Download'; + + @override + String get copied => 'Copied to clipboard'; + + @override + String get noUrlFound => 'No URL found in selection'; + + @override + String get linkCopied => 'Link copied'; + + @override + String get openLink => 'Open Link'; + + @override + String get aiGatewayForAndroid => 'AI Gateway for Android'; + + @override + String get checkingSetup => 'Checking setup status...'; + + @override + String get repairingBypass => 'Repairing bionic bypass...'; + + @override + String get reinstallingNode => 'Reinstalling Node.js...'; + + @override + String get reinstallingOpenClaw => 'Reinstalling OpenClaw...'; + + @override + String get quickActions => 'QUICK ACTIONS'; + + @override + String get terminal => 'Terminal'; + + @override + String get terminalSubtitle => 'Open Ubuntu shell with OpenClaw'; + + @override + String get webDashboard => 'Web Dashboard'; + + @override + String get webDashboardSubtitle => 'Open OpenClaw dashboard in browser'; + + @override + String get startGatewayFirst => 'Start gateway first'; + + @override + String get onboarding => 'Onboarding'; + + @override + String get onboardingSubtitle => 'Configure API keys and binding'; + + @override + String get configure => 'Configure'; + + @override + String get configureSubtitle => 'Manage gateway settings'; + + @override + String get aiProviders => 'AI Providers'; + + @override + String get aiProvidersSubtitle => 'Configure models and API keys'; + + @override + String get packages => 'Packages'; + + @override + String get packagesSubtitle => 'Install optional tools (Go, Homebrew, SSH)'; + + @override + String get sshAccess => 'SSH Access'; + + @override + String get sshAccessSubtitle => 'Remote terminal access via SSH'; + + @override + String get logs => 'Logs'; + + @override + String get logsSubtitle => 'View gateway output and errors'; + + @override + String get snapshot => 'Snapshot'; + + @override + String get snapshotSubtitle => 'Backup or restore your config'; + + @override + String get node => 'Node'; + + @override + String get nodeConnected => 'Connected to gateway'; + + @override + String get nodeCapabilities => 'Device capabilities for AI'; + + @override + String get dashboardUrlCopied => 'Dashboard URL copied'; + + @override + String get copyDashboardUrl => 'Copy dashboard URL'; + + @override + String get cliProxy => 'CLIProxy Manager'; + + @override + String get cliProxySubtitle => 'Manage free AI account proxy'; + + @override + String get gateway => 'Gateway'; + + @override + String get startGateway => 'Start Gateway'; + + @override + String get stopGateway => 'Stop Gateway'; + + @override + String get viewLogs => 'View Logs'; + + @override + String get urlCopied => 'URL copied to clipboard'; + + @override + String get copyUrl => 'Copy URL'; + + @override + String get openDashboard => 'Open dashboard'; + + @override + String get gatewayRunning => 'Running'; + + @override + String get gatewayStarting => 'Starting'; + + @override + String get gatewayError => 'Error'; + + @override + String get gatewayStopped => 'Stopped'; + + @override + String get enableNode => 'Enable Node'; + + @override + String get disableNode => 'Disable Node'; + + @override + String get reconnect => 'Reconnect'; + + @override + String get nodePaired => 'Paired'; + + @override + String get nodeConnecting => 'Connecting'; + + @override + String get nodeDisconnected => 'Disconnected'; + + @override + String get nodeDisabled => 'Disabled'; + + @override + String get nodeConfigure => 'Configure'; + + @override + String get settings => 'Settings'; + + @override + String get general => 'GENERAL'; + + @override + String get autoStartGateway => 'Auto-start gateway'; + + @override + String get autoStartSubtitle => 'Start the gateway when the app opens'; + + @override + String get batteryOptimization => 'Battery Optimization'; + + @override + String get batteryOptimized => 'Optimized (may kill background sessions)'; + + @override + String get batteryUnrestricted => 'Unrestricted (recommended)'; + + @override + String get setupStorage => 'Setup Storage'; + + @override + String get storageGranted => + 'Granted — proot can access /sdcard. Revoke if not needed.'; + + @override + String get storageNotGranted => 'Allow access to shared storage'; + + @override + String get nodeSection => 'NODE'; + + @override + String get enableNodeTitle => 'Enable Node'; + + @override + String get enableNodeSubtitle => 'Provide device capabilities to the gateway'; + + @override + String get nodeConfiguration => 'Node Configuration'; + + @override + String get nodeConfigSubtitle => 'Connection, pairing, and capabilities'; + + @override + String get systemInfo => 'SYSTEM INFO'; + + @override + String get architecture => 'Architecture'; + + @override + String get prootPath => 'PRoot path'; + + @override + String get rootfs => 'Rootfs'; + + @override + String get installed => 'Installed'; + + @override + String get notInstalled => 'Not installed'; + + @override + String get maintenance => 'MAINTENANCE'; + + @override + String get exportSnapshot => 'Export Snapshot'; + + @override + String get exportSnapshotSubtitle => 'Backup config to Downloads'; + + @override + String get importSnapshot => 'Import Snapshot'; + + @override + String get importSnapshotSubtitle => 'Restore config from backup'; + + @override + String get rerunSetup => 'Re-run setup'; + + @override + String get rerunSetupSubtitle => 'Reinstall or repair the environment'; + + @override + String get about => 'ABOUT'; + + @override + String get checkForUpdates => 'Check for Updates'; + + @override + String get checkUpdatesSubtitle => 'Check GitHub for a newer release'; + + @override + String get developer => 'Developer'; + + @override + String get github => 'GitHub'; + + @override + String get contact => 'Contact'; + + @override + String get license => 'License'; + + @override + String get updateAvailable => 'Update Available'; + + @override + String get currentVersion => 'Current'; + + @override + String get latestVersion => 'Latest'; + + @override + String get alreadyLatest => 'You\'re on the latest version'; + + @override + String get checkUpdateFailed => 'Could not check for updates'; + + @override + String get snapshotSaved => 'Snapshot saved to'; + + @override + String get exportFailed => 'Export failed'; + + @override + String get noSnapshotFound => 'No snapshot found at'; + + @override + String get snapshotRestored => + 'Snapshot restored successfully. Restart the gateway to apply.'; + + @override + String get importFailed => 'Import failed'; + + @override + String get setupOpenClaw => 'Setup OpenClaw'; + + @override + String get setupRunning => + 'Setting up the environment. This may take several minutes.'; + + @override + String get setupDescription => + 'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.'; + + @override + String get downloadRootfs => 'Download Ubuntu rootfs'; + + @override + String get extractRootfs => 'Extract rootfs'; + + @override + String get installNode => 'Install Node.js'; + + @override + String get installOpenClaw => 'Install OpenClaw'; + + @override + String get configureBionicBypass => 'Configure Bionic Bypass'; + + @override + String get setupComplete => 'Setup complete!'; + + @override + String get configureApiKeys => 'Configure API Keys'; + + @override + String get beginSetup => 'Begin Setup'; + + @override + String get retrySetup => 'Retry Setup'; + + @override + String get storageRequired => + 'Requires ~500MB of storage and an internet connection'; + + @override + String get optionalPackages => 'OPTIONAL PACKAGES'; + + @override + String get gatewayLogs => 'Gateway Logs'; + + @override + String get filterLogs => 'Filter logs...'; + + @override + String get noLogsYet => 'No logs yet. Start the gateway.'; + + @override + String get noMatchingLogs => 'No matching logs.'; + + @override + String get copyAllLogs => 'Copy all logs'; + + @override + String get autoScrollOn => 'Auto-scroll on'; + + @override + String get autoScrollOff => 'Auto-scroll off'; + + @override + String get activeModel => 'Active Model'; + + @override + String get selectProvider => + 'Select a provider to configure its API key and model.'; + + @override + String get active => 'Active'; + + @override + String get configured => 'Configured'; + + @override + String get apiKey => 'API Key'; + + @override + String get model => 'Model'; + + @override + String get customModel => 'Custom...'; + + @override + String get customModelHint => 'e.g. meta/llama-3.3-70b-instruct'; + + @override + String get customModelLabel => 'Custom model name'; + + @override + String get saveAndActivate => 'Save & Activate'; + + @override + String get removeConfiguration => 'Remove Configuration'; + + @override + String get apiKeyEmpty => 'API key cannot be empty'; + + @override + String get modelEmpty => 'Model name cannot be empty'; + + @override + String get configuredAndActivated => 'configured and activated'; + + @override + String get saveFailed => 'Failed to save'; + + @override + String get removeFailed => 'Failed to remove'; + + @override + String get removeProvider => 'Remove'; + + @override + String get removeProviderContent => + 'This will delete the API key and deactivate the model.'; + + @override + String get startingTerminal => 'Starting terminal...'; + + @override + String get failedToStartTerminal => 'Failed to start terminal'; + + @override + String get openClawOnboarding => 'OpenClaw Onboarding'; + + @override + String get startingOnboarding => 'Starting onboarding...'; + + @override + String get failedToStartOnboarding => 'Failed to start onboarding'; + + @override + String get goToDashboard => 'Go to Dashboard'; + + @override + String get cliProxyManagement => 'CLIProxy Management'; + + @override + String get cliProxyNotRunning => 'CLIProxy service is not running'; + + @override + String get openInBrowser => 'Open in browser'; + + @override + String get refresh => 'Refresh'; + + @override + String get reconnectProxy => 'Reconnect'; + + @override + String get installedBadge => 'Installed'; +} diff --git a/flutter_app/lib/l10n/app_localizations_zh.dart b/flutter_app/lib/l10n/app_localizations_zh.dart new file mode 100644 index 0000000..1cefea7 --- /dev/null +++ b/flutter_app/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,514 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get appName => 'OpenClaw'; + + @override + String get cancel => '取消'; + + @override + String get confirm => '确认'; + + @override + String get retry => '重试'; + + @override + String get done => '完成'; + + @override + String get save => '保存'; + + @override + String get remove => '移除'; + + @override + String get install => '安装'; + + @override + String get loading => '加载中...'; + + @override + String get error => '错误'; + + @override + String get copy => '复制'; + + @override + String get paste => '粘贴'; + + @override + String get openUrl => '打开链接'; + + @override + String get screenshot => '截图'; + + @override + String get restart => '重启'; + + @override + String get later => '稍后'; + + @override + String get download => '下载'; + + @override + String get copied => '已复制到剪贴板'; + + @override + String get noUrlFound => '未找到链接'; + + @override + String get linkCopied => '链接已复制'; + + @override + String get openLink => '打开链接'; + + @override + String get aiGatewayForAndroid => 'Android AI 网关'; + + @override + String get checkingSetup => '检查安装状态...'; + + @override + String get repairingBypass => '修复 Bionic Bypass...'; + + @override + String get reinstallingNode => '重新安装 Node.js...'; + + @override + String get reinstallingOpenClaw => '重新安装 OpenClaw...'; + + @override + String get quickActions => '快捷操作'; + + @override + String get terminal => '终端'; + + @override + String get terminalSubtitle => '打开 Ubuntu Shell'; + + @override + String get webDashboard => 'Web 控制台'; + + @override + String get webDashboardSubtitle => '在浏览器中打开 OpenClaw 控制台'; + + @override + String get startGatewayFirst => '请先启动网关'; + + @override + String get onboarding => '初始配置'; + + @override + String get onboardingSubtitle => '配置 API 密钥和绑定设置'; + + @override + String get configure => '网关配置'; + + @override + String get configureSubtitle => '管理网关设置'; + + @override + String get aiProviders => 'AI 提供商'; + + @override + String get aiProvidersSubtitle => '配置模型和 API 密钥'; + + @override + String get packages => '扩展包'; + + @override + String get packagesSubtitle => '安装可选工具 (Go, Homebrew, SSH)'; + + @override + String get sshAccess => 'SSH 访问'; + + @override + String get sshAccessSubtitle => '通过 SSH 远程访问终端'; + + @override + String get logs => '日志'; + + @override + String get logsSubtitle => '查看网关输出和错误'; + + @override + String get snapshot => '快照'; + + @override + String get snapshotSubtitle => '备份或恢复配置'; + + @override + String get node => '节点'; + + @override + String get nodeConnected => '已连接到网关'; + + @override + String get nodeCapabilities => '为 AI 提供设备能力'; + + @override + String get dashboardUrlCopied => '控制台链接已复制'; + + @override + String get copyDashboardUrl => '复制控制台链接'; + + @override + String get cliProxy => 'CLIProxy 管理'; + + @override + String get cliProxySubtitle => '管理免费 AI 账号代理'; + + @override + String get gateway => '网关'; + + @override + String get startGateway => '启动网关'; + + @override + String get stopGateway => '停止网关'; + + @override + String get viewLogs => '查看日志'; + + @override + String get urlCopied => '链接已复制'; + + @override + String get copyUrl => '复制链接'; + + @override + String get openDashboard => '打开控制台'; + + @override + String get gatewayRunning => '运行中'; + + @override + String get gatewayStarting => '启动中'; + + @override + String get gatewayError => '错误'; + + @override + String get gatewayStopped => '已停止'; + + @override + String get enableNode => '启用节点'; + + @override + String get disableNode => '禁用节点'; + + @override + String get reconnect => '重新连接'; + + @override + String get nodePaired => '已配对'; + + @override + String get nodeConnecting => '连接中'; + + @override + String get nodeDisconnected => '已断开'; + + @override + String get nodeDisabled => '已禁用'; + + @override + String get nodeConfigure => '配置'; + + @override + String get settings => '设置'; + + @override + String get general => '通用'; + + @override + String get autoStartGateway => '自动启动网关'; + + @override + String get autoStartSubtitle => '应用打开时自动启动网关'; + + @override + String get batteryOptimization => '电池优化'; + + @override + String get batteryOptimized => '已优化(可能终止后台会话)'; + + @override + String get batteryUnrestricted => '无限制(推荐)'; + + @override + String get setupStorage => '存储权限'; + + @override + String get storageGranted => '已授权 — proot 可访问 /sdcard,不需要时请撤销'; + + @override + String get storageNotGranted => '允许访问共享存储'; + + @override + String get nodeSection => '节点'; + + @override + String get enableNodeTitle => '启用节点'; + + @override + String get enableNodeSubtitle => '为网关提供设备能力'; + + @override + String get nodeConfiguration => '节点配置'; + + @override + String get nodeConfigSubtitle => '连接、配对和能力设置'; + + @override + String get systemInfo => '系统信息'; + + @override + String get architecture => '架构'; + + @override + String get prootPath => 'PRoot 路径'; + + @override + String get rootfs => '根文件系统'; + + @override + String get installed => '已安装'; + + @override + String get notInstalled => '未安装'; + + @override + String get maintenance => '维护'; + + @override + String get exportSnapshot => '导出快照'; + + @override + String get exportSnapshotSubtitle => '备份配置到下载目录'; + + @override + String get importSnapshot => '导入快照'; + + @override + String get importSnapshotSubtitle => '从备份恢复配置'; + + @override + String get rerunSetup => '重新安装'; + + @override + String get rerunSetupSubtitle => '重新安装或修复环境'; + + @override + String get about => '关于'; + + @override + String get checkForUpdates => '检查更新'; + + @override + String get checkUpdatesSubtitle => '在 GitHub 检查新版本'; + + @override + String get developer => '开发者'; + + @override + String get github => 'GitHub'; + + @override + String get contact => '联系方式'; + + @override + String get license => '许可证'; + + @override + String get updateAvailable => '发现新版本'; + + @override + String get currentVersion => '当前版本'; + + @override + String get latestVersion => '最新版本'; + + @override + String get alreadyLatest => '已是最新版本'; + + @override + String get checkUpdateFailed => '无法检查更新'; + + @override + String get snapshotSaved => '快照已保存至'; + + @override + String get exportFailed => '导出失败'; + + @override + String get noSnapshotFound => '未找到快照文件'; + + @override + String get snapshotRestored => '快照已恢复,重启网关以应用更改'; + + @override + String get importFailed => '导入失败'; + + @override + String get setupOpenClaw => '安装 OpenClaw'; + + @override + String get setupRunning => '正在配置环境,可能需要几分钟。'; + + @override + String get setupDescription => '将下载 Ubuntu、Node.js 和 OpenClaw 到独立环境中。'; + + @override + String get downloadRootfs => '下载 Ubuntu 根文件系统'; + + @override + String get extractRootfs => '解压根文件系统'; + + @override + String get installNode => '安装 Node.js'; + + @override + String get installOpenClaw => '安装 OpenClaw'; + + @override + String get configureBionicBypass => '配置 Bionic Bypass'; + + @override + String get setupComplete => '安装完成!'; + + @override + String get configureApiKeys => '配置 API 密钥'; + + @override + String get beginSetup => '开始安装'; + + @override + String get retrySetup => '重试安装'; + + @override + String get storageRequired => '需要约 500MB 存储空间和网络连接'; + + @override + String get optionalPackages => '可选扩展包'; + + @override + String get gatewayLogs => '网关日志'; + + @override + String get filterLogs => '过滤日志...'; + + @override + String get noLogsYet => '暂无日志,请启动网关。'; + + @override + String get noMatchingLogs => '没有匹配的日志。'; + + @override + String get copyAllLogs => '复制全部日志'; + + @override + String get autoScrollOn => '自动滚动已开启'; + + @override + String get autoScrollOff => '自动滚动已关闭'; + + @override + String get activeModel => '当前模型'; + + @override + String get selectProvider => '选择提供商配置 API 密钥和模型。'; + + @override + String get active => '使用中'; + + @override + String get configured => '已配置'; + + @override + String get apiKey => 'API 密钥'; + + @override + String get model => '模型'; + + @override + String get customModel => '自定义...'; + + @override + String get customModelHint => '例如:meta/llama-3.3-70b-instruct'; + + @override + String get customModelLabel => '自定义模型名称'; + + @override + String get saveAndActivate => '保存并激活'; + + @override + String get removeConfiguration => '移除配置'; + + @override + String get apiKeyEmpty => 'API 密钥不能为空'; + + @override + String get modelEmpty => '模型名称不能为空'; + + @override + String get configuredAndActivated => '已配置并激活'; + + @override + String get saveFailed => '保存失败'; + + @override + String get removeFailed => '移除失败'; + + @override + String get removeProvider => '移除提供商'; + + @override + String get removeProviderContent => '这将删除 API 密钥并停用该模型。'; + + @override + String get startingTerminal => '正在启动终端...'; + + @override + String get failedToStartTerminal => '启动终端失败'; + + @override + String get openClawOnboarding => 'OpenClaw 初始配置'; + + @override + String get startingOnboarding => '正在启动配置向导...'; + + @override + String get failedToStartOnboarding => '启动配置向导失败'; + + @override + String get goToDashboard => '前往控制台'; + + @override + String get cliProxyManagement => 'CLIProxy 管理中心'; + + @override + String get cliProxyNotRunning => 'CLIProxy 服务未运行'; + + @override + String get openInBrowser => '在浏览器中打开'; + + @override + String get refresh => '刷新'; + + @override + String get reconnectProxy => '重新连接'; + + @override + String get installedBadge => '已安装'; +} diff --git a/flutter_app/lib/l10n/app_strings.dart b/flutter_app/lib/l10n/app_strings.dart new file mode 100644 index 0000000..4c42dc4 --- /dev/null +++ b/flutter_app/lib/l10n/app_strings.dart @@ -0,0 +1,381 @@ +import 'dart:ui'; + +class AppStrings { + AppStrings._(); + + static bool get _isChinese => + PlatformDispatcher.instance.locale.languageCode == 'zh'; + + static bool get isChinese => _isChinese; + + static String get appName => 'OpenClaw'; + static String get cancel => _isChinese ? '取消' : 'Cancel'; + static String get retry => _isChinese ? '重试' : 'Retry'; + static String get remove => _isChinese ? '移除' : 'Remove'; + static String get done => _isChinese ? '完成' : 'Done'; + static String get install => _isChinese ? '安装' : 'Install'; + static String get loading => _isChinese ? '加载中...' : 'Loading...'; + static String get copy => _isChinese ? '复制' : 'Copy'; + static String get paste => _isChinese ? '粘贴' : 'Paste'; + static String get openUrl => _isChinese ? '打开链接' : 'Open URL'; + static String get screenshot => _isChinese ? '截图' : 'Screenshot'; + static String get restart => _isChinese ? '重启' : 'Restart'; + static String get later => _isChinese ? '稍后' : 'Later'; + static String get download => _isChinese ? '下载' : 'Download'; + static String get copied => _isChinese ? '已复制到剪贴板' : 'Copied to clipboard'; + static String get noUrlFound => + _isChinese ? '未找到链接' : 'No URL found in selection'; + static String get linkCopied => _isChinese ? '链接已复制' : 'Link copied'; + + static String get aiGatewayForAndroid => + _isChinese ? 'Android AI 网关' : 'AI Gateway for Android'; + static String get checkingSetup => + _isChinese ? '检查安装状态...' : 'Checking setup status...'; + static String get repairingBypass => + _isChinese ? '修复 Bionic Bypass...' : 'Repairing bionic bypass...'; + static String get reinstallingNode => + _isChinese ? '重新安装 Node.js...' : 'Reinstalling Node.js...'; + static String get reinstallingOpenClaw => + _isChinese ? '重新安装 OpenClaw...' : 'Reinstalling OpenClaw...'; + + static String get quickActions => _isChinese ? '快捷操作' : 'QUICK ACTIONS'; + static String get terminal => _isChinese ? '终端' : 'Terminal'; + static String get terminalSubtitle => + _isChinese ? '打开 Ubuntu Shell' : 'Open Ubuntu shell with OpenClaw'; + static String get webDashboard => _isChinese ? 'Web 控制台' : 'Web Dashboard'; + static String get webDashboardSubtitle => _isChinese + ? '在浏览器中打开 OpenClaw 控制台' + : 'Open OpenClaw dashboard in browser'; + static String get startGatewayFirst => + _isChinese ? '请先启动网关' : 'Start gateway first'; + static String get onboarding => _isChinese ? '初始配置' : 'Onboarding'; + static String get onboardingSubtitle => + _isChinese ? '配置 API 密钥和绑定设置' : 'Configure API keys and binding'; + static String get configure => _isChinese ? '网关配置' : 'Configure'; + static String get configureSubtitle => + _isChinese ? '管理网关设置' : 'Manage gateway settings'; + static String get aiProviders => _isChinese ? 'AI 提供商' : 'AI Providers'; + static String get aiProvidersSubtitle => + _isChinese ? '配置模型和 API 密钥' : 'Configure models and API keys'; + static String get packages => _isChinese ? '扩展包' : 'Packages'; + static String get packagesSubtitle => _isChinese + ? '安装可选工具 (Go, Homebrew, SSH)' + : 'Install optional tools (Go, Homebrew, SSH)'; + static String get sshAccess => _isChinese ? 'SSH 访问' : 'SSH Access'; + static String get sshAccessSubtitle => + _isChinese ? '通过 SSH 远程访问终端' : 'Remote terminal access via SSH'; + static String get logs => _isChinese ? '日志' : 'Logs'; + static String get logsSubtitle => + _isChinese ? '查看网关输出和错误' : 'View gateway output and errors'; + static String get snapshot => _isChinese ? '快照' : 'Snapshot'; + static String get snapshotSubtitle => + _isChinese ? '备份或恢复配置' : 'Backup or restore your config'; + static String get node => _isChinese ? '节点' : 'Node'; + static String get nodeConnected => + _isChinese ? '已连接到网关' : 'Connected to gateway'; + static String get nodeCapabilities => + _isChinese ? '为 AI 提供设备能力' : 'Device capabilities for AI'; + static String get dashboardUrlCopied => + _isChinese ? '控制台链接已复制' : 'Dashboard URL copied'; + static String get copyDashboardUrl => + _isChinese ? '复制控制台链接' : 'Copy dashboard URL'; + static String get cliProxy => _isChinese ? 'CLIProxy 管理' : 'CLIProxy Manager'; + static String get cliProxySubtitle => + _isChinese ? '管理免费 AI 账号代理' : 'Manage free AI account proxy'; + + static String get gateway => _isChinese ? '网关' : 'Gateway'; + static String get startGateway => _isChinese ? '启动网关' : 'Start Gateway'; + static String get stopGateway => _isChinese ? '停止网关' : 'Stop Gateway'; + static String get viewLogs => _isChinese ? '查看日志' : 'View Logs'; + static String get urlCopied => + _isChinese ? '链接已复制' : 'URL copied to clipboard'; + static String get copyUrl => _isChinese ? '复制链接' : 'Copy URL'; + static String get openDashboard => _isChinese ? '打开控制台' : 'Open dashboard'; + static String get gatewayRunning => _isChinese ? '运行中' : 'Running'; + static String get gatewayStarting => _isChinese ? '启动中' : 'Starting'; + static String get gatewayError => _isChinese ? '错误' : 'Error'; + static String get gatewayStopped => _isChinese ? '已停止' : 'Stopped'; + + static String get enableNode => _isChinese ? '启用节点' : 'Enable Node'; + static String get disableNode => _isChinese ? '禁用节点' : 'Disable Node'; + static String get reconnect => _isChinese ? '重新连接' : 'Reconnect'; + static String get nodePaired => _isChinese ? '已配对' : 'Paired'; + static String get nodeConnecting => _isChinese ? '连接中' : 'Connecting'; + static String get nodeDisconnected => _isChinese ? '已断开' : 'Disconnected'; + static String get nodeDisabled => _isChinese ? '已禁用' : 'Disabled'; + static String get nodeConfigure => _isChinese ? '配置' : 'Configure'; + + static String get settings => _isChinese ? '设置' : 'Settings'; + static String get general => _isChinese ? '通用' : 'GENERAL'; + static String get autoStartGateway => + _isChinese ? '自动启动网关' : 'Auto-start gateway'; + static String get autoStartSubtitle => + _isChinese ? '应用打开时自动启动网关' : 'Start the gateway when the app opens'; + static String get batteryOptimization => + _isChinese ? '电池优化' : 'Battery Optimization'; + static String get batteryOptimized => + _isChinese ? '已优化(可能终止后台会话)' : 'Optimized (may kill background sessions)'; + static String get batteryUnrestricted => + _isChinese ? '无限制(推荐)' : 'Unrestricted (recommended)'; + static String get setupStorage => _isChinese ? '存储权限' : 'Setup Storage'; + static String get storageGranted => _isChinese + ? '已授权 — proot 可访问 /sdcard,不需要时请撤销' + : 'Granted — proot can access /sdcard. Revoke if not needed.'; + static String get storageNotGranted => + _isChinese ? '允许访问共享存储' : 'Allow access to shared storage'; + static String get nodeSection => _isChinese ? '节点' : 'NODE'; + static String get enableNodeTitle => _isChinese ? '启用节点' : 'Enable Node'; + static String get enableNodeSubtitle => + _isChinese ? '为网关提供设备能力' : 'Provide device capabilities to the gateway'; + static String get nodeConfiguration => + _isChinese ? '节点配置' : 'Node Configuration'; + static String get nodeConfigSubtitle => + _isChinese ? '连接、配对和能力设置' : 'Connection, pairing, and capabilities'; + static String get systemInfo => _isChinese ? '系统信息' : 'SYSTEM INFO'; + static String get architecture => _isChinese ? '架构' : 'Architecture'; + static String get prootPath => _isChinese ? 'PRoot 路径' : 'PRoot path'; + static String get rootfs => _isChinese ? '根文件系统' : 'Rootfs'; + static String get installed => _isChinese ? '已安装' : 'Installed'; + static String get notInstalled => _isChinese ? '未安装' : 'Not installed'; + static String get maintenance => _isChinese ? '维护' : 'MAINTENANCE'; + static String get exportSnapshot => _isChinese ? '导出快照' : 'Export Snapshot'; + static String get exportSnapshotSubtitle => + _isChinese ? '备份配置到下载目录' : 'Backup config to Downloads'; + static String get importSnapshot => _isChinese ? '导入快照' : 'Import Snapshot'; + static String get importSnapshotSubtitle => + _isChinese ? '从备份恢复配置' : 'Restore config from backup'; + static String get rerunSetup => _isChinese ? '重新安装' : 'Re-run setup'; + static String get rerunSetupSubtitle => + _isChinese ? '重新安装或修复环境' : 'Reinstall or repair the environment'; + static String get about => _isChinese ? '关于' : 'ABOUT'; + static String get checkForUpdates => + _isChinese ? '检查更新' : 'Check for Updates'; + static String get checkUpdatesSubtitle => + _isChinese ? '在 GitHub 检查新版本' : 'Check GitHub for a newer release'; + static String get developer => _isChinese ? '开发者' : 'Developer'; + static String get github => 'GitHub'; + static String get contact => _isChinese ? '联系方式' : 'Contact'; + static String get license => _isChinese ? '许可证' : 'License'; + static String get updateAvailable => + _isChinese ? '发现新版本' : 'Update Available'; + static String get currentVersion => _isChinese ? '当前版本' : 'Current'; + static String get latestVersion => _isChinese ? '最新版本' : 'Latest'; + static String get alreadyLatest => + _isChinese ? '已是最新版本' : "You're on the latest version"; + static String get checkUpdateFailed => + _isChinese ? '无法检查更新' : 'Could not check for updates'; + static String get snapshotSaved => + _isChinese ? '快照已保存至' : 'Snapshot saved to'; + static String get exportFailed => _isChinese ? '导出失败' : 'Export failed'; + static String get noSnapshotFound => + _isChinese ? '未找到快照文件' : 'No snapshot found at'; + static String get snapshotRestored => _isChinese + ? '快照已恢复,重启网关以应用更改' + : 'Snapshot restored successfully. Restart the gateway to apply.'; + static String get importFailed => _isChinese ? '导入失败' : 'Import failed'; + + static String get setupOpenClaw => + _isChinese ? '安装 OpenClaw' : 'Setup OpenClaw'; + static String get setupRunning => _isChinese + ? '正在配置环境,可能需要几分钟。' + : 'Setting up the environment. This may take several minutes.'; + static String get setupDescription => _isChinese + ? '将下载 Ubuntu、Node.js 和 OpenClaw 到独立环境中。' + : 'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.'; + static String get downloadRootfs => + _isChinese ? '下载 Ubuntu 根文件系统' : 'Download Ubuntu rootfs'; + static String get extractRootfs => _isChinese ? '解压根文件系统' : 'Extract rootfs'; + static String get installNode => + _isChinese ? '安装 Node.js' : 'Install Node.js'; + static String get installOpenClaw => + _isChinese ? '安装 OpenClaw' : 'Install OpenClaw'; + static String get configureBionicBypass => + _isChinese ? '配置 Bionic Bypass' : 'Configure Bionic Bypass'; + static String get setupComplete => _isChinese ? '安装完成!' : 'Setup complete!'; + static String get configureApiKeys => + _isChinese ? '配置 API 密钥' : 'Configure API Keys'; + static String get beginSetup => _isChinese ? '开始安装' : 'Begin Setup'; + static String get retrySetup => _isChinese ? '重试安装' : 'Retry Setup'; + static String get storageRequired => _isChinese + ? '需要约 500MB 存储空间和网络连接' + : 'Requires ~500MB of storage and an internet connection'; + static String get optionalPackages => + _isChinese ? '可选扩展包' : 'OPTIONAL PACKAGES'; + + static String get gatewayLogs => _isChinese ? '网关日志' : 'Gateway Logs'; + static String get filterLogs => _isChinese ? '过滤日志...' : 'Filter logs...'; + static String get noLogsYet => + _isChinese ? '暂无日志,请启动网关。' : 'No logs yet. Start the gateway.'; + static String get noMatchingLogs => + _isChinese ? '没有匹配的日志。' : 'No matching logs.'; + static String get copyAllLogs => _isChinese ? '复制全部日志' : 'Copy all logs'; + static String get autoScrollOn => _isChinese ? '自动滚动已开启' : 'Auto-scroll on'; + static String get autoScrollOff => _isChinese ? '自动滚动已关闭' : 'Auto-scroll off'; + + static String get activeModel => _isChinese ? '当前模型' : 'Active Model'; + static String get selectProvider => _isChinese + ? '选择提供商配置 API 密钥和模型。' + : 'Select a provider to configure its API key and model.'; + static String get active => _isChinese ? '使用中' : 'Active'; + static String get configured => _isChinese ? '已配置' : 'Configured'; + static String get apiKey => _isChinese ? 'API 密钥' : 'API Key'; + static String get model => _isChinese ? '模型' : 'Model'; + static String get customModel => _isChinese ? '自定义...' : 'Custom...'; + static String get customModelHint => _isChinese + ? '例如:meta/llama-3.3-70b-instruct' + : 'e.g. meta/llama-3.3-70b-instruct'; + static String get customModelLabel => + _isChinese ? '自定义模型名称' : 'Custom model name'; + static String get saveAndActivate => _isChinese ? '保存并激活' : 'Save & Activate'; + static String get removeConfiguration => + _isChinese ? '移除配置' : 'Remove Configuration'; + static String get apiKeyEmpty => + _isChinese ? 'API 密钥不能为空' : 'API key cannot be empty'; + static String get modelEmpty => + _isChinese ? '模型名称不能为空' : 'Model name cannot be empty'; + static String get configuredAndActivated => + _isChinese ? '已配置并激活' : 'configured and activated'; + static String get saveFailed => _isChinese ? '保存失败' : 'Failed to save'; + static String get removeFailed => _isChinese ? '移除失败' : 'Failed to remove'; + static String get removeProvider => _isChinese ? '移除' : 'Remove'; + static String get removeProviderContent => _isChinese + ? '这将删除 API 密钥并停用该模型。' + : 'This will delete the API key and deactivate the model.'; + + static String get startingTerminal => + _isChinese ? '正在启动终端...' : 'Starting terminal...'; + static String get failedToStartTerminal => + _isChinese ? '启动终端失败' : 'Failed to start terminal'; + + static String get openClawOnboarding => + _isChinese ? 'OpenClaw 初始配置' : 'OpenClaw Onboarding'; + static String get startingOnboarding => + _isChinese ? '正在启动配置向导...' : 'Starting onboarding...'; + static String get goToDashboard => _isChinese ? '前往控制台' : 'Go to Dashboard'; + + static String get cliProxyManagement => + _isChinese ? 'CLIProxy 管理中心' : 'CLIProxy Management'; + static String get cliProxyNotRunning => + _isChinese ? 'CLIProxy 服务未运行' : 'CLIProxy service is not running'; + static String get openInBrowser => _isChinese ? '在浏览器中打开' : 'Open in browser'; + + static String translateError(String error) { + if (!_isChinese) return error; + if (error.contains('Download failed') || error.contains('download')) { + return '下载失败:请检查网络连接后重试。\n$error'; + } + if (error.contains('PROOT_ERROR') || error.contains('libproot')) { + return '运行环境错误:proot 库缺失或不兼容当前设备架构。\n$error'; + } + if (error.contains('No such file or directory')) { + return '文件不存在:安装包可能不完整,请重试安装。\n$error'; + } + if (error.contains('Permission denied')) { + return '权限不足:请检查存储权限设置。\n$error'; + } + if (error.contains('timeout') || error.contains('Timeout')) { + return '连接超时:请检查网络后重试。\n$error'; + } + if (error.contains('Setup failed')) { + return '安装失败:$error'; + } + return error; + } + +// Node Screen + static String get gatewayConnection => + _isChinese ? '网关连接' : 'GATEWAY CONNECTION'; + static String get localGateway => _isChinese ? '本地网关' : 'Local Gateway'; + static String get localGatewaySubtitle => + _isChinese ? '自动与本设备上的网关配对' : 'Auto-pair with gateway on this device'; + static String get remoteGateway => _isChinese ? '远程网关' : 'Remote Gateway'; + static String get remoteGatewaySubtitle => + _isChinese ? '连接到另一台设备上的网关' : 'Connect to a gateway on another device'; + static String get gatewayHost => _isChinese ? '网关地址' : 'Gateway Host'; + static String get gatewayPort => _isChinese ? '网关端口' : 'Gateway Port'; + static String get gatewayToken => _isChinese ? '网关令牌' : 'Gateway Token'; + static String get gatewayTokenHint => _isChinese + ? '从网关控制台 URL 中粘贴令牌' + : 'Paste token from gateway dashboard URL'; + static String get gatewayTokenHelper => _isChinese + ? '在控制台 URL 的 #token= 后面找到' + : 'Found in dashboard URL after #token='; + static String get connect => _isChinese ? '连接' : 'Connect'; + static String get pairing => _isChinese ? '配对' : 'PAIRING'; + static String get pairingPrompt => + _isChinese ? '在网关上批准此配对码:' : 'Approve this code on the gateway:'; + static String get capabilities => _isChinese ? '设备能力' : 'CAPABILITIES'; + static String get capCamera => _isChinese ? '摄像头' : 'Camera'; + static String get capCameraDesc => + _isChinese ? '拍摄照片和视频' : 'Capture photos and video clips'; + static String get capCanvas => _isChinese ? '画布' : 'Canvas'; + static String get capCanvasDesc => + _isChinese ? '移动端不可用' : 'Not available on mobile'; + static String get capLocation => _isChinese ? '位置' : 'Location'; + static String get capLocationDesc => + _isChinese ? '获取设备 GPS 坐标' : 'Get device GPS coordinates'; + static String get capScreen => _isChinese ? '屏幕录制' : 'Screen Recording'; + static String get capScreenDesc => _isChinese + ? '录制设备屏幕(每次需要授权)' + : 'Record device screen (requires consent each time)'; + static String get capFlash => _isChinese ? '手电筒' : 'Flashlight'; + static String get capFlashDesc => + _isChinese ? '开关设备手电筒' : 'Toggle device torch on/off'; + static String get capVibration => _isChinese ? '振动' : 'Vibration'; + static String get capVibrationDesc => _isChinese + ? '触发触觉反馈和振动' + : 'Trigger haptic feedback and vibration patterns'; + static String get capSensors => _isChinese ? '传感器' : 'Sensors'; + static String get capSensorsDesc => _isChinese + ? '读取加速度计、陀螺仪、磁力计、气压计' + : 'Read accelerometer, gyroscope, magnetometer, barometer'; + static String get capSerial => _isChinese ? '串口' : 'Serial'; + static String get capSerialDesc => + _isChinese ? '蓝牙和 USB 串口通信' : 'Bluetooth and USB serial communication'; + static String get deviceInfo => _isChinese ? '设备信息' : 'DEVICE INFO'; + static String get deviceId => _isChinese ? '设备 ID' : 'Device ID'; + static String get nodeLogs => _isChinese ? '节点日志' : 'NODE LOGS'; + static String get noLogsYetNode => _isChinese ? '暂无日志' : 'No logs yet'; + + // Provider Detail + static String get baseUrl => 'Base URL'; + static String get baseUrlHelper => _isChinese + ? 'CLIProxy 默认: http://127.0.0.1:18790/v1' + : 'CLIProxy default: http://127.0.0.1:18790/v1'; + + // AI Provider descriptions + static String get providerAnthropicDesc => _isChinese + ? 'Claude 系列模型,擅长推理和编程' + : 'Claude models for advanced reasoning and coding'; + static String get providerCustomDesc => _isChinese + ? '任意 OpenAI 兼容 API(如 CLIProxy 18790 端口)' + : 'Any OpenAI-compatible API (e.g. CLIProxy on port 18790)'; + // CLIProxy Screen + static String get cliProxyRunning => _isChinese ? '运行中' : 'Running'; + static String get cliProxyStopped => _isChinese ? '已停止' : 'Stopped'; + static String get cliProxyRefresh => _isChinese ? '刷新' : 'Refresh'; + static String get cliProxyStop => + _isChinese ? '停止 CLIProxy' : 'Stop CLIProxy'; + static String get cliProxyStart => + _isChinese ? '启动 CLIProxy' : 'Start CLIProxy'; + static String get cliProxyStarting => _isChinese ? '正在启动...' : 'Starting...'; + static String get cliProxyInstall => + _isChinese ? '安装 CLIProxy' : 'Install CLIProxy'; + static String get cliProxyInstallTitle => + _isChinese ? '安装 CLIProxy' : 'Install CLIProxy'; + static String get cliProxyInstallDone => + _isChinese ? '安装完成,返回' : 'Done, go back'; + static String get cliProxyInstallStarting => + _isChinese ? '正在启动安装...' : 'Starting install...'; + static String get cliProxyGuide => _isChinese + ? '点击下方按钮启动服务,或先安装 CLIProxy。' + : 'Tap the button below to start the service, or install CLIProxy first.'; + // CLIProxy Screen + static String get cliProxyInstallBtn => + _isChinese ? '安装 CLIProxy' : 'Install CLIProxy'; + // 9Router + static String get nineRouterTerminal => _isChinese ? '9Router 终端' : '9Router Terminal'; + static String get nineRouterTerminalSubtitle => _isChinese ? '启动免费 AI 账号代理服务' : 'Start free AI account proxy'; + static String get nineRouterConsole => _isChinese ? '9Router 控制台' : '9Router Console'; + static String get nineRouterConsoleSubtitle => _isChinese ? '打开 9Router Web 管理界面' : 'Open 9Router web dashboard'; +} \ No newline at end of file diff --git a/flutter_app/lib/l10n/app_zh.arb b/flutter_app/lib/l10n/app_zh.arb new file mode 100644 index 0000000..ec3432f --- /dev/null +++ b/flutter_app/lib/l10n/app_zh.arb @@ -0,0 +1,171 @@ +{ + "@@locale": "zh", + "appName": "OpenClaw", + "cancel": "取消", + "confirm": "确认", + "retry": "重试", + "done": "完成", + "save": "保存", + "remove": "移除", + "install": "安装", + "loading": "加载中...", + "error": "错误", + "copy": "复制", + "paste": "粘贴", + "openUrl": "打开链接", + "screenshot": "截图", + "restart": "重启", + "later": "稍后", + "download": "下载", + "copied": "已复制到剪贴板", + "noUrlFound": "未找到链接", + "linkCopied": "链接已复制", + "openLink": "打开链接", + "aiGatewayForAndroid": "Android AI 网关", + "checkingSetup": "检查安装状态...", + "repairingBypass": "修复 Bionic Bypass...", + "reinstallingNode": "重新安装 Node.js...", + "reinstallingOpenClaw": "重新安装 OpenClaw...", + "quickActions": "快捷操作", + "terminal": "终端", + "terminalSubtitle": "打开 Ubuntu Shell", + "webDashboard": "Web 控制台", + "webDashboardSubtitle": "在浏览器中打开 OpenClaw 控制台", + "startGatewayFirst": "请先启动网关", + "onboarding": "初始配置", + "onboardingSubtitle": "配置 API 密钥和绑定设置", + "configure": "网关配置", + "configureSubtitle": "管理网关设置", + "aiProviders": "AI 提供商", + "aiProvidersSubtitle": "配置模型和 API 密钥", + "packages": "扩展包", + "packagesSubtitle": "安装可选工具 (Go, Homebrew, SSH)", + "sshAccess": "SSH 访问", + "sshAccessSubtitle": "通过 SSH 远程访问终端", + "logs": "日志", + "logsSubtitle": "查看网关输出和错误", + "snapshot": "快照", + "snapshotSubtitle": "备份或恢复配置", + "node": "节点", + "nodeConnected": "已连接到网关", + "nodeCapabilities": "为 AI 提供设备能力", + "dashboardUrlCopied": "控制台链接已复制", + "copyDashboardUrl": "复制控制台链接", + "cliProxy": "CLIProxy 管理", + "cliProxySubtitle": "管理免费 AI 账号代理", + "gateway": "网关", + "startGateway": "启动网关", + "stopGateway": "停止网关", + "viewLogs": "查看日志", + "urlCopied": "链接已复制", + "copyUrl": "复制链接", + "openDashboard": "打开控制台", + "gatewayRunning": "运行中", + "gatewayStarting": "启动中", + "gatewayError": "错误", + "gatewayStopped": "已停止", + "enableNode": "启用节点", + "disableNode": "禁用节点", + "reconnect": "重新连接", + "nodePaired": "已配对", + "nodeConnecting": "连接中", + "nodeDisconnected": "已断开", + "nodeDisabled": "已禁用", + "nodeConfigure": "配置", + "settings": "设置", + "general": "通用", + "autoStartGateway": "自动启动网关", + "autoStartSubtitle": "应用打开时自动启动网关", + "batteryOptimization": "电池优化", + "batteryOptimized": "已优化(可能终止后台会话)", + "batteryUnrestricted": "无限制(推荐)", + "setupStorage": "存储权限", + "storageGranted": "已授权 — proot 可访问 /sdcard,不需要时请撤销", + "storageNotGranted": "允许访问共享存储", + "nodeSection": "节点", + "enableNodeTitle": "启用节点", + "enableNodeSubtitle": "为网关提供设备能力", + "nodeConfiguration": "节点配置", + "nodeConfigSubtitle": "连接、配对和能力设置", + "systemInfo": "系统信息", + "architecture": "架构", + "prootPath": "PRoot 路径", + "rootfs": "根文件系统", + "installed": "已安装", + "notInstalled": "未安装", + "maintenance": "维护", + "exportSnapshot": "导出快照", + "exportSnapshotSubtitle": "备份配置到下载目录", + "importSnapshot": "导入快照", + "importSnapshotSubtitle": "从备份恢复配置", + "rerunSetup": "重新安装", + "rerunSetupSubtitle": "重新安装或修复环境", + "about": "关于", + "checkForUpdates": "检查更新", + "checkUpdatesSubtitle": "在 GitHub 检查新版本", + "developer": "开发者", + "github": "GitHub", + "contact": "联系方式", + "license": "许可证", + "updateAvailable": "发现新版本", + "currentVersion": "当前版本", + "latestVersion": "最新版本", + "alreadyLatest": "已是最新版本", + "checkUpdateFailed": "无法检查更新", + "snapshotSaved": "快照已保存至", + "exportFailed": "导出失败", + "noSnapshotFound": "未找到快照文件", + "snapshotRestored": "快照已恢复,重启网关以应用更改", + "importFailed": "导入失败", + "setupOpenClaw": "安装 OpenClaw", + "setupRunning": "正在配置环境,可能需要几分钟。", + "setupDescription": "将下载 Ubuntu、Node.js 和 OpenClaw 到独立环境中。", + "downloadRootfs": "下载 Ubuntu 根文件系统", + "extractRootfs": "解压根文件系统", + "installNode": "安装 Node.js", + "installOpenClaw": "安装 OpenClaw", + "configureBionicBypass": "配置 Bionic Bypass", + "setupComplete": "安装完成!", + "configureApiKeys": "配置 API 密钥", + "beginSetup": "开始安装", + "retrySetup": "重试安装", + "storageRequired": "需要约 500MB 存储空间和网络连接", + "optionalPackages": "可选扩展包", + "gatewayLogs": "网关日志", + "filterLogs": "过滤日志...", + "noLogsYet": "暂无日志,请启动网关。", + "noMatchingLogs": "没有匹配的日志。", + "copyAllLogs": "复制全部日志", + "autoScrollOn": "自动滚动已开启", + "autoScrollOff": "自动滚动已关闭", + "activeModel": "当前模型", + "selectProvider": "选择提供商配置 API 密钥和模型。", + "active": "使用中", + "configured": "已配置", + "apiKey": "API 密钥", + "model": "模型", + "customModel": "自定义...", + "customModelHint": "例如:meta/llama-3.3-70b-instruct", + "customModelLabel": "自定义模型名称", + "saveAndActivate": "保存并激活", + "removeConfiguration": "移除配置", + "apiKeyEmpty": "API 密钥不能为空", + "modelEmpty": "模型名称不能为空", + "configuredAndActivated": "已配置并激活", + "saveFailed": "保存失败", + "removeFailed": "移除失败", + "removeProvider": "移除提供商", + "removeProviderContent": "这将删除 API 密钥并停用该模型。", + "startingTerminal": "正在启动终端...", + "failedToStartTerminal": "启动终端失败", + "openClawOnboarding": "OpenClaw 初始配置", + "startingOnboarding": "正在启动配置向导...", + "failedToStartOnboarding": "启动配置向导失败", + "goToDashboard": "前往控制台", + "cliProxyManagement": "CLIProxy 管理中心", + "cliProxyNotRunning": "CLIProxy 服务未运行", + "openInBrowser": "在浏览器中打开", + "refresh": "刷新", + "reconnectProxy": "重新连接", + "installedBadge": "已安装" +} diff --git a/flutter_app/lib/models/ai_provider.dart b/flutter_app/lib/models/ai_provider.dart index d24f8a5..22f2ce9 100644 --- a/flutter_app/lib/models/ai_provider.dart +++ b/flutter_app/lib/models/ai_provider.dart @@ -26,7 +26,7 @@ class AiProvider { static const anthropic = AiProvider( id: 'anthropic', name: 'Anthropic', - description: 'Claude models — advanced reasoning and coding', + description: 'Claude models �?advanced reasoning and coding', icon: Icons.psychology, color: Color(0xFFD97706), baseUrl: 'https://api.anthropic.com/v1', @@ -133,6 +133,30 @@ class AiProvider { apiKeyHint: 'xai-...', ); + static const custom = AiProvider( + id: 'custom', + name: 'Custom / CLIProxy', + description: 'Any OpenAI-compatible API (e.g. CLIProxy on port 18790)', + icon: Icons.tune, + color: Color(0xFF8B5CF6), + baseUrl: 'http://127.0.0.1:18790/v1', + defaultModels: [ + 'claude-sonnet-4-20250514', + 'gemini-2.5-pro', + 'gpt-4o', + ], + apiKeyHint: 'your-api-key (or any string if not required)', + ); + /// All available AI providers. - static const all = [anthropic, openai, google, openrouter, nvidia, deepseek, xai]; + static const all = [ + anthropic, + openai, + google, + openrouter, + nvidia, + deepseek, + xai, + custom + ]; } diff --git a/flutter_app/lib/providers/node_provider.dart b/flutter_app/lib/providers/node_provider.dart index 2848636..6c240a0 100644 --- a/flutter_app/lib/providers/node_provider.dart +++ b/flutter_app/lib/providers/node_provider.dart @@ -63,7 +63,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { text = 'Node reconnecting...'; break; case NodeStatus.error: - text = 'Node error — retrying'; + text = 'Node error ?retrying'; break; default: return; @@ -84,7 +84,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { } } - /// App returned to foreground — force connection health check. + /// App returned to foreground ?force connection health check. /// Dart timers freeze while backgrounded, so the watchdog and ping /// timers won't have fired. We must check and reconnect manually. Future _onAppResumed() async { @@ -99,7 +99,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (_) {} if (_state.isPaired && _nodeService.isConnectionStale) { - // WebSocket went stale while in background — force reconnect + // WebSocket went stale while in background ?force reconnect await _nodeService.disconnect(); await _nodeService.connect(); } else if (!_state.isPaired && !_state.isConnecting) { @@ -111,7 +111,7 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { _startWatchdog(); } - /// App going to background — ensure the foreground service is running + /// App going to background ?ensure the foreground service is running /// so Android keeps our process alive. Future _onAppPaused() async { if (_state.isDisabled) return; @@ -254,10 +254,10 @@ class NodeProvider extends ChangeNotifier with WidgetsBindingObserver { } catch (_) {} if (!_state.isPaired && !_state.isConnecting) { - // Connection dropped — reconnect + // Connection dropped ?reconnect _nodeService.connect(); } else if (_state.isPaired && _nodeService.isConnectionStale) { - // Connection appears alive but no data received — force reconnect + // Connection appears alive but no data received ?force reconnect _nodeService.disconnect().then((_) => _nodeService.connect()); } }); diff --git a/flutter_app/lib/screens/cliproxy_install_screen.dart b/flutter_app/lib/screens/cliproxy_install_screen.dart new file mode 100644 index 0000000..a4e0449 --- /dev/null +++ b/flutter_app/lib/screens/cliproxy_install_screen.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:xterm/xterm.dart'; +import 'package:flutter_pty/flutter_pty.dart'; +import '../l10n/app_strings.dart'; +import '../services/native_bridge.dart'; +import '../services/terminal_service.dart'; +import '../widgets/terminal_toolbar.dart'; + +class CliProxyInstallScreen extends StatefulWidget { + const CliProxyInstallScreen({super.key}); + + @override + State createState() => _CliProxyInstallScreenState(); +} + +class _CliProxyInstallScreenState extends State { + late final Terminal _terminal; + late final TerminalController _controller; + Pty? _pty; + bool _loading = true; + bool _finished = false; + String? _error; + final _ctrlNotifier = ValueNotifier(false); + final _altNotifier = ValueNotifier(false); + + static const _sentinel = 'CLIPROXY_INSTALL_COMPLETE'; + + static const _installCommand = 'set -e; ' + 'echo ">>> Installing 9Router (Node.js AI proxy)..."; ' + 'node --version; ' + 'npm --version; ' + 'echo ">>> Installing 9router globally..."; ' + 'npm install -g 9router; ' + 'echo ">>> 9Router installed"; ' + '9router --version 2>/dev/null || echo "9router ready"; ' + 'echo ">>> CLIPROXY_INSTALL_COMPLETE"'; + + static const _fontFallback = [ + 'monospace', + 'Noto Sans Mono', + 'Noto Sans Mono CJK SC', + 'sans-serif', + ]; + + @override + void initState() { + super.initState(); + _terminal = Terminal(maxLines: 10000); + _controller = TerminalController(); + NativeBridge.startTerminalService(); + WidgetsBinding.instance.addPostFrameCallback((_) => _startInstall()); + } + + Future _startInstall() async { + _pty?.kill(); + _pty = null; + try { + try { + await NativeBridge.setupDirs(); + } catch (_) {} + try { + await NativeBridge.writeResolv(); + } catch (_) {} + try { + final filesDir = await NativeBridge.getFilesDir(); + const rc = 'nameserver 8.8.8.8\nnameserver 8.8.4.4\n'; + final rf = File('$filesDir/config/resolv.conf'); + if (!rf.existsSync()) { + Directory('$filesDir/config').createSync(recursive: true); + rf.writeAsStringSync(rc); + } + final rr = File('$filesDir/rootfs/ubuntu/etc/resolv.conf'); + if (!rr.existsSync()) { + rr.parent.createSync(recursive: true); + rr.writeAsStringSync(rc); + } + } catch (_) {} + + final config = await TerminalService.getProotShellConfig(); + final args = TerminalService.buildProotArgs( + config, + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + final cmdArgs = List.from(args) + ..removeLast() + ..removeLast() + ..addAll(['/bin/bash', '-lc', _installCommand]); + + _pty = Pty.start( + config['executable']!, + arguments: cmdArgs, + environment: TerminalService.buildHostEnv(config), + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + + _pty!.output.cast>().listen((data) { + final text = utf8.decode(data, allowMalformed: true); + _terminal.write(text); + if (!_finished && text.contains(_sentinel)) { + if (mounted) setState(() => _finished = true); + } + }); + + _pty!.exitCode.then((code) { + _terminal.write('\r\n[Process exited with code $code]\r\n'); + if (mounted && !_finished) setState(() => _finished = true); + }); + + _terminal.onOutput = (data) { + if (_ctrlNotifier.value && data.length == 1) { + final code = data.toLowerCase().codeUnitAt(0); + if (code >= 97 && code <= 122) { + _pty?.write(Uint8List.fromList([code - 96])); + _ctrlNotifier.value = false; + return; + } + } + if (_altNotifier.value && data.isNotEmpty) { + _pty?.write(utf8.encode('\x1b$data')); + _altNotifier.value = false; + return; + } + _pty?.write(utf8.encode(data)); + }; + _terminal.onResize = (w, h, pw, ph) => _pty?.resize(h, w); + setState(() => _loading = false); + } catch (e) { + setState(() { + _loading = false; + _error = 'Failed: $e'; + }); + } + } + + @override + void dispose() { + _ctrlNotifier.dispose(); + _altNotifier.dispose(); + _controller.dispose(); + _pty?.kill(); + NativeBridge.stopTerminalService(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppStrings.cliProxyInstallTitle), + automaticallyImplyLeading: false, + ), + body: Column( + children: [ + if (_loading) + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(AppStrings.cliProxyInstallStarting), + ], + ), + ), + ) + else if (_error != null) + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48, color: Theme.of(context).colorScheme.error), + const SizedBox(height: 16), + Text(_error!, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error)), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () => setState(() { + _loading = true; + _error = null; + _finished = false; + _startInstall(); + }), + icon: const Icon(Icons.refresh), + label: Text(AppStrings.retry), + ), + ], + ), + ), + ), + ) + else ...[ + Expanded( + child: TerminalView( + _terminal, + controller: _controller, + textStyle: const TerminalStyle( + fontSize: 11, + height: 1.0, + fontFamily: 'DejaVuSansMono', + fontFamilyFallback: _fontFallback, + ), + ), + ), + TerminalToolbar( + pty: _pty, + ctrlNotifier: _ctrlNotifier, + altNotifier: _altNotifier, + ), + ], + if (_finished) + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => Navigator.of(context).pop(true), + icon: const Icon(Icons.check), + label: Text(AppStrings.cliProxyInstallDone), + ), + ), + ), + ], + ), + ); + } +} diff --git a/flutter_app/lib/screens/cliproxy_screen.dart b/flutter_app/lib/screens/cliproxy_screen.dart new file mode 100644 index 0000000..ed46871 --- /dev/null +++ b/flutter_app/lib/screens/cliproxy_screen.dart @@ -0,0 +1,494 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:xterm/xterm.dart' as xterm; +import 'package:flutter_pty/flutter_pty.dart' as flutter_pty; +import '../app.dart'; +import '../l10n/app_strings.dart'; +import '../services/native_bridge.dart'; +import '../services/terminal_service.dart'; +import '../utils/responsive.dart'; +import 'cliproxy_install_screen.dart'; + +class CliProxyScreen extends StatefulWidget { + const CliProxyScreen({super.key}); + + @override + State createState() => _CliProxyScreenState(); +} + +class _CliProxyScreenState extends State { + static const String _cliProxyUrl = 'http://127.0.0.1:20128/'; + static const int _cliProxyPort = 20128; + + late final WebViewController _controller; + bool _loading = true; + bool _hasError = false; + bool _isRunning = false; + bool _isStarting = false; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onPageStarted: (_) => setState(() { + _loading = true; + _hasError = false; + }), + onPageFinished: (_) => setState(() { + _loading = false; + _isRunning = true; + }), + onWebResourceError: (error) { + if (error.isForMainFrame ?? true) { + setState(() { + _loading = false; + _hasError = true; + _isRunning = false; + }); + } + }, + ), + ) + ..loadRequest(Uri.parse(_cliProxyUrl)); + _checkRunning(); + } + + Future _checkRunning() async { + try { + final socket = await Socket.connect('127.0.0.1', _cliProxyPort, + timeout: const Duration(seconds: 2)); + socket.destroy(); + if (mounted) setState(() => _isRunning = true); + } catch (_) { + if (mounted) setState(() => _isRunning = false); + } + } + + Future _startCliProxy() async { + // 跳转到 OpenClaw 终端,自动输入启动命令 + // 用户在终端里看到 9router 启动后,返回此页面点重新连接 + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppStrings.isChinese ? '启动 9Router' : 'Start 9Router'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(AppStrings.isChinese + ? '请在终端中运行以下命令启动 9Router:' + : 'Run the following command in the terminal:'), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(8), + ), + child: const SelectableText( + '9router --port 20128 &', + style: TextStyle( + fontFamily: 'monospace', + color: Colors.greenAccent, + fontSize: 13, + ), + ), + ), + const SizedBox(height: 12), + Text( + AppStrings.isChinese + ? '启动后选择 Hide to Tray,然后返回点重新连接。' + : 'Select "Hide to Tray", then come back and tap Reconnect.', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(AppStrings.cancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(ctx); + // 跳转到终端并自动执行命令 + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => const _NineRouterTerminalScreen()), + ) + .then((_) { + Future.delayed(const Duration(seconds: 3), () { + _checkRunning().then((_) { + if (_isRunning && mounted) { + setState(() => _hasError = false); + _controller.reload(); + } + }); + }); + }); + }, + child: Text(AppStrings.isChinese ? '打开终端' : 'Open Terminal'), + ), + ], + ), + ); + } + + Future _stopCliProxy() async { + try { + await NativeBridge.runInProot( + 'pkill -f "9router" 2>/dev/null || true', + timeout: 10, + ); + await Future.delayed(const Duration(seconds: 1)); + setState(() { + _isRunning = false; + _hasError = true; + }); + } catch (_) {} + } + + Future _showCliProxyLog() async { + String log = ''; + try { + final result = await NativeBridge.runInProot( + 'cat /tmp/9router.log 2>/dev/null || echo "No log file found"', + timeout: 10, + ); + log = result; + } catch (e) { + log = 'Failed to read log: $e'; + } + if (!mounted) return; + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: + Text(AppStrings.isChinese ? '9Router 启动日志' : '9Router Startup Log'), + content: SizedBox( + width: double.maxFinite, + height: 300, + child: SingleChildScrollView( + child: SelectableText( + log.isEmpty ? (AppStrings.isChinese ? '暂无日志' : 'No logs') : log, + style: const TextStyle(fontFamily: 'monospace', fontSize: 11), + ), + ), + ), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(AppStrings.done), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isTablet = Responsive.isTablet(context); + + return Scaffold( + appBar: AppBar( + title: Text(AppStrings.cliProxyManagement), + actions: [ + if (_isStarting) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (_isRunning) + IconButton( + icon: const Icon(Icons.stop_circle_outlined), + tooltip: AppStrings.cliProxyStop, + onPressed: _stopCliProxy, + ) + else + IconButton( + icon: const Icon(Icons.play_circle_outlined), + tooltip: AppStrings.cliProxyStart, + onPressed: _startCliProxy, + ), + Padding( + padding: const EdgeInsets.only(right: 4), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: (_isRunning + ? AppColors.statusGreen + : AppColors.statusGrey) + .withAlpha(25), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: (_isRunning + ? AppColors.statusGreen + : AppColors.statusGrey) + .withAlpha(60), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _isRunning ? Icons.circle : Icons.circle_outlined, + size: 8, + color: _isRunning + ? AppColors.statusGreen + : AppColors.statusGrey, + ), + const SizedBox(width: 4), + Text( + _isRunning + ? AppStrings.cliProxyRunning + : AppStrings.cliProxyStopped, + style: theme.textTheme.labelSmall?.copyWith( + color: _isRunning + ? AppColors.statusGreen + : AppColors.statusGrey, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: AppStrings.cliProxyRefresh, + onPressed: () { + setState(() { + _loading = true; + _hasError = false; + }); + _controller.reload(); + _checkRunning(); + }, + ), + IconButton( + icon: const Icon(Icons.open_in_browser), + tooltip: AppStrings.openInBrowser, + onPressed: () => launchUrl(Uri.parse(_cliProxyUrl), + mode: LaunchMode.externalApplication), + ), + ], + ), + body: _hasError + ? _buildErrorView(context, isTablet) + : Stack( + children: [ + WebViewWidget(controller: _controller), + if (_loading) const Center(child: CircularProgressIndicator()), + ], + ), + ); + } + + Widget _buildErrorView(BuildContext context, bool isTablet) { + final theme = Theme.of(context); + return Responsive.constrain( + Center( + child: Padding( + padding: EdgeInsets.all(isTablet ? 48 : 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, + size: isTablet ? 80 : 64, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 24), + Text(AppStrings.cliProxyNotRunning, + style: theme.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center), + const SizedBox(height: 12), + Text(AppStrings.cliProxyGuide, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + textAlign: TextAlign.center), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isStarting ? null : _startCliProxy, + icon: _isStarting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.play_arrow), + label: Text(_isStarting + ? AppStrings.cliProxyStarting + : AppStrings.cliProxyStart), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CliProxyInstallScreen()), + ); + if (result == true) _startCliProxy(); + }, + icon: const Icon(Icons.download), + label: Text(AppStrings.cliProxyInstallBtn), + ), + ), + const SizedBox(height: 12), + TextButton.icon( + onPressed: () { + setState(() { + _loading = true; + _hasError = false; + }); + _controller.reload(); + _checkRunning(); + }, + icon: const Icon(Icons.refresh, size: 18), + label: Text(AppStrings.reconnect), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: _showCliProxyLog, + icon: const Icon(Icons.article_outlined, size: 18), + label: + Text(AppStrings.isChinese ? '查看启动日志' : 'View startup log'), + ), + ], + ), + ), + ), + ); + } +} + +// 终端界面:在 proot 里前台运行 9router +class _NineRouterTerminalScreen extends StatefulWidget { + const _NineRouterTerminalScreen(); + + @override + State<_NineRouterTerminalScreen> createState() => + _NineRouterTerminalScreenState(); +} + +class _NineRouterTerminalScreenState extends State<_NineRouterTerminalScreen> { + static const _fontFallback = ['monospace', 'Noto Sans Mono', 'sans-serif']; + + late final xterm.Terminal _terminal; + late final xterm.TerminalController _controller; + flutter_pty.Pty? _pty; + bool _loading = true; + + @override + void initState() { + super.initState(); + _terminal = xterm.Terminal(maxLines: 5000); + _controller = xterm.TerminalController(); + NativeBridge.startTerminalService(); + WidgetsBinding.instance.addPostFrameCallback((_) => _start()); + } + + Future _start() async { + try { + try { + await NativeBridge.setupDirs(); + } catch (_) {} + try { + await NativeBridge.writeResolv(); + } catch (_) {} + final config = await TerminalService.getProotShellConfig(); + final args = TerminalService.buildProotArgs( + config, + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + final cmdArgs = List.from(args) + ..removeLast() + ..removeLast() + ..addAll([ + '/bin/bash', + '-lc', + 'echo "Starting 9Router on port 20128..."; ' + 'nohup 9router --port 20128 > /tmp/9router.log 2>&1 & ' + 'sleep 2 && echo "9Router started" && cat /tmp/9router.log' + ]); + + _pty = flutter_pty.Pty.start( + config['executable']!, + arguments: cmdArgs, + environment: TerminalService.buildHostEnv(config), + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + _pty!.output.cast>().listen((data) { + _terminal.write(utf8.decode(data, allowMalformed: true)); + }); + _pty!.exitCode.then((code) { + _terminal.write('\r\n[Process exited with code $code]\r\n'); + }); + _terminal.onOutput = (data) => _pty?.write(utf8.encode(data)); + _terminal.onResize = (w, h, pw, ph) => _pty?.resize(h, w); + setState(() => _loading = false); + } catch (e) { + setState(() => _loading = false); + _terminal.write('Error: $e\r\n'); + } + } + + @override + void dispose() { + _controller.dispose(); + _pty?.kill(); + NativeBridge.stopTerminalService(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppStrings.isChinese ? '9Router 运行终端' : '9Router Terminal'), + actions: [ + IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: AppStrings.isChinese ? '返回管理界面' : 'Back', + onPressed: () => Navigator.of(context).pop(), + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : xterm.TerminalView( + _terminal, + controller: _controller, + textStyle: const xterm.TerminalStyle( + fontSize: 11, + height: 1.0, + fontFamily: 'DejaVuSansMono', + fontFamilyFallback: _fontFallback, + ), + ), + ); + } +} diff --git a/flutter_app/lib/screens/configure_screen.dart b/flutter_app/lib/screens/configure_screen.dart index f695700..60fb8da 100644 --- a/flutter_app/lib/screens/configure_screen.dart +++ b/flutter_app/lib/screens/configure_screen.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -297,7 +297,7 @@ class _ConfigureScreenState extends State { body: Column( children: [ if (_loading) - const Expanded( + Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/flutter_app/lib/screens/dashboard_screen.dart b/flutter_app/lib/screens/dashboard_screen.dart index dc8d5c1..35d5787 100644 --- a/flutter_app/lib/screens/dashboard_screen.dart +++ b/flutter_app/lib/screens/dashboard_screen.dart @@ -1,9 +1,11 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../providers/gateway_provider.dart'; import '../providers/node_provider.dart'; +import '../utils/responsive.dart'; import '../widgets/gateway_controls.dart'; import '../widgets/status_card.dart'; import 'node_screen.dart'; @@ -16,6 +18,8 @@ import 'packages_screen.dart'; import 'providers_screen.dart'; import 'settings_screen.dart'; import 'ssh_screen.dart'; +import 'nine_router_terminal_screen.dart'; +import 'nine_router_webview_screen.dart'; class DashboardScreen extends StatelessWidget { const DashboardScreen({super.key}); @@ -23,10 +27,11 @@ class DashboardScreen extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final isTablet = Responsive.isTablet(context); return Scaffold( appBar: AppBar( - title: const Text('OpenClaw'), + title: Text(AppStrings.appName), actions: [ IconButton( icon: const Icon(Icons.settings), @@ -36,181 +41,238 @@ class DashboardScreen extends StatelessWidget { ), ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const GatewayControls(), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - 'QUICK ACTIONS', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 1.2, - ), - ), - ), - StatusCard( - title: 'Terminal', - subtitle: 'Open Ubuntu shell with OpenClaw', - icon: Icons.terminal, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const TerminalScreen()), - ), - ), - Consumer( - builder: (context, provider, _) { - final url = provider.state.dashboardUrl; - final token = url != null - ? RegExp(r'#token=([0-9a-f]+)').firstMatch(url)?.group(1) - : null; - final subtitle = provider.state.isRunning - ? (token != null - ? 'Token: ${token.substring(0, (token.length > 8 ? 8 : token.length))}...' - : 'Open OpenClaw dashboard in browser') - : 'Start gateway first'; - return StatusCard( - title: 'Web Dashboard', - subtitle: subtitle, - icon: Icons.dashboard, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (token != null) - IconButton( - icon: const Icon(Icons.copy, size: 18), - tooltip: 'Copy dashboard URL', - onPressed: () { - Clipboard.setData(ClipboardData(text: url!)); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Dashboard URL copied')), - ); - }, - ), - const Icon(Icons.chevron_right), - ], + body: Responsive.constrain( + SingleChildScrollView( + padding: EdgeInsets.all(isTablet ? 24 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const GatewayControls(), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + AppStrings.quickActions, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, ), - onTap: provider.state.isRunning - ? () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => WebDashboardScreen( - url: url, - ), - ), - ) - : null, - ); - }, - ), - StatusCard( - title: 'Onboarding', - subtitle: 'Configure API keys and binding', - icon: Icons.vpn_key, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const OnboardingScreen()), - ), - ), - StatusCard( - title: 'Configure', - subtitle: 'Manage gateway settings', - icon: Icons.tune, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ConfigureScreen()), - ), - ), - StatusCard( - title: 'AI Providers', - subtitle: 'Configure models and API keys', - icon: Icons.model_training, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const ProvidersScreen()), - ), - ), - StatusCard( - title: 'Packages', - subtitle: 'Install optional tools (Go, Homebrew, SSH)', - icon: Icons.extension, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const PackagesScreen()), - ), - ), - StatusCard( - title: 'SSH Access', - subtitle: 'Remote terminal access via SSH', - icon: Icons.terminal, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SshScreen()), - ), - ), - StatusCard( - title: 'Logs', - subtitle: 'View gateway output and errors', - icon: Icons.article_outlined, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LogsScreen()), - ), - ), - StatusCard( - title: 'Snapshot', - subtitle: 'Backup or restore your config', - icon: Icons.backup, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), ), - ), - Consumer( - builder: (context, nodeProvider, _) { - final nodeState = nodeProvider.state; - return StatusCard( - title: 'Node', - subtitle: nodeState.isPaired - ? 'Connected to gateway' - : nodeState.isDisabled - ? 'Device capabilities for AI' - : nodeState.statusText, - icon: Icons.devices, - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NodeScreen()), - ), - ); - }, - ), - const SizedBox(height: 24), - Center( - child: Column( - children: [ - Text( - 'OpenClaw v${AppConstants.version}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (isTablet) + _buildTabletGrid(context, theme) + else + _buildPhoneList(context, theme), + const SizedBox(height: 24), + Center( + child: Column( + children: [ + Text( + 'OpenClaw v${AppConstants.version}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(height: 2), - Text( - 'by ${AppConstants.authorName} | ${AppConstants.orgName}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: 2), + Text( + 'by ${AppConstants.authorName} | ${AppConstants.orgName}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ); } + + // 手机:竖向列�? + Widget _buildPhoneList(BuildContext context, ThemeData theme) { + return Column( + children: _buildCards(context), + ); + } + + // 平板�?列网�? + Widget _buildTabletGrid(BuildContext context, ThemeData theme) { + final cards = _buildCards(context); + final rows = []; + for (int i = 0; i < cards.length; i += 2) { + rows.add( + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: cards[i]), + const SizedBox(width: 12), + Expanded( + child: i + 1 < cards.length ? cards[i + 1] : const SizedBox()), + ], + ), + ); + } + return Column(children: rows); + } + + List _buildCards(BuildContext context) { + return [ + StatusCard( + title: AppStrings.terminal, + subtitle: AppStrings.terminalSubtitle, + icon: Icons.terminal, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const TerminalScreen()), + ), + ), + Consumer( + builder: (context, provider, _) { + final url = provider.state.dashboardUrl; + final token = url != null + ? RegExp(r'#token=([0-9a-f]+)').firstMatch(url)?.group(1) + : null; + final subtitle = provider.state.isRunning + ? (token != null + ? 'Token: ${token.substring(0, (token.length > 8 ? 8 : token.length))}...' + : AppStrings.webDashboardSubtitle) + : AppStrings.startGatewayFirst; + return StatusCard( + title: AppStrings.webDashboard, + subtitle: subtitle, + icon: Icons.dashboard, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (token != null) + IconButton( + icon: const Icon(Icons.copy, size: 18), + tooltip: AppStrings.copyDashboardUrl, + onPressed: () { + Clipboard.setData(ClipboardData(text: url!)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppStrings.dashboardUrlCopied)), + ); + }, + ), + const Icon(Icons.chevron_right), + ], + ), + onTap: provider.state.isRunning + ? () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WebDashboardScreen(url: url), + ), + ) + : null, + ); + }, + ), + StatusCard( + title: AppStrings.isChinese ? '9Router 终端' : '9Router Terminal', + subtitle: AppStrings.isChinese + ? '启动免费 AI 账号代理服务' + : 'Start free AI account proxy', + icon: Icons.swap_horiz, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NineRouterTerminalScreen()), + ), + ), + StatusCard( + title: AppStrings.isChinese ? '9Router 控制台' : '9Router Console', + subtitle: + AppStrings.isChinese ? '打开 Web 管理界面' : 'Open web management UI', + icon: Icons.dashboard_customize, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NineRouterWebviewScreen()), + ), + ), + StatusCard( + title: AppStrings.onboarding, + subtitle: AppStrings.onboardingSubtitle, + icon: Icons.vpn_key, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const OnboardingScreen()), + ), + ), + StatusCard( + title: AppStrings.configure, + subtitle: AppStrings.configureSubtitle, + icon: Icons.tune, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ConfigureScreen()), + ), + ), + StatusCard( + title: AppStrings.aiProviders, + subtitle: AppStrings.aiProvidersSubtitle, + icon: Icons.model_training, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProvidersScreen()), + ), + ), + StatusCard( + title: AppStrings.packages, + subtitle: AppStrings.packagesSubtitle, + icon: Icons.extension, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const PackagesScreen()), + ), + ), + StatusCard( + title: AppStrings.sshAccess, + subtitle: AppStrings.sshAccessSubtitle, + icon: Icons.terminal, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SshScreen()), + ), + ), + StatusCard( + title: AppStrings.logs, + subtitle: AppStrings.logsSubtitle, + icon: Icons.article_outlined, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const LogsScreen()), + ), + ), + StatusCard( + title: AppStrings.snapshot, + subtitle: AppStrings.snapshotSubtitle, + icon: Icons.backup, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SettingsScreen()), + ), + ), + Consumer( + builder: (context, nodeProvider, _) { + final nodeState = nodeProvider.state; + return StatusCard( + title: AppStrings.node, + subtitle: nodeState.isPaired + ? AppStrings.nodeConnected + : nodeState.isDisabled + ? AppStrings.nodeCapabilities + : nodeState.statusText, + icon: Icons.devices, + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NodeScreen()), + ), + ); + }, + ), + ]; + } } diff --git a/flutter_app/lib/screens/logs_screen.dart b/flutter_app/lib/screens/logs_screen.dart index eca00e2..acf729a 100644 --- a/flutter_app/lib/screens/logs_screen.dart +++ b/flutter_app/lib/screens/logs_screen.dart @@ -1,9 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_strings.dart'; import '../providers/gateway_provider.dart'; import '../services/screenshot_service.dart'; +import '../utils/log_parser.dart'; +import '../utils/responsive.dart'; class LogsScreen extends StatefulWidget { const LogsScreen({super.key}); @@ -17,6 +20,7 @@ class _LogsScreenState extends State { final _searchController = TextEditingController(); final _screenshotKey = GlobalKey(); bool _autoScroll = true; + bool _friendlyMode = true; // 友好模式 vs 原始模式 String _filter = ''; @override @@ -32,104 +36,215 @@ class _LogsScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Gateway Logs'), + title: Text(AppStrings.gatewayLogs), actions: [ + // 切换友好/原始模式 + IconButton( + icon: Icon(_friendlyMode ? Icons.code : Icons.auto_awesome), + tooltip: _friendlyMode + ? (AppStrings.isChinese ? '切换到原始日志' : 'Raw logs') + : (AppStrings.isChinese ? '切换到友好模式' : 'Friendly mode'), + onPressed: () => setState(() => _friendlyMode = !_friendlyMode), + ), IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: AppStrings.screenshot, onPressed: _takeScreenshot, ), IconButton( icon: Icon( - _autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_top, + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_top, ), - tooltip: _autoScroll ? 'Auto-scroll on' : 'Auto-scroll off', + tooltip: _autoScroll + ? AppStrings.autoScrollOn + : AppStrings.autoScrollOff, onPressed: () => setState(() => _autoScroll = !_autoScroll), ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy all logs', + tooltip: AppStrings.copyAllLogs, onPressed: () => _copyLogs(context), ), ], ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Filter logs...', - prefixIcon: const Icon(Icons.search), - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + body: Responsive.constrain( + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: AppStrings.filterLogs, + prefixIcon: const Icon(Icons.search), + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + suffixIcon: _filter.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() => _filter = ''); + }, + ) + : null, ), - suffixIcon: _filter.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - setState(() => _filter = ''); - }, - ) - : null, + onChanged: (value) => setState(() => _filter = value), ), - onChanged: (value) => setState(() => _filter = value), ), - ), - Expanded( - child: RepaintBoundary( - key: _screenshotKey, - child: Consumer( - builder: (context, provider, _) { - final logs = provider.state.logs; - final filtered = _filter.isEmpty - ? logs - : logs.where((l) => - l.toLowerCase().contains(_filter.toLowerCase())).toList(); - - if (filtered.isEmpty) { - return Center( - child: Text( - logs.isEmpty ? 'No logs yet. Start the gateway.' : 'No matching logs.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ); - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_autoScroll && _scrollController.hasClients) { - _scrollController.jumpTo( - _scrollController.position.maxScrollExtent, - ); - } - }); - - return ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: filtered.length, - itemBuilder: (context, index) { - final line = filtered[index]; - return Text( - line, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: _logColor(line, theme), - ), + Expanded( + child: RepaintBoundary( + key: _screenshotKey, + child: Consumer( + builder: (context, provider, _) { + final logs = provider.state.logs; + final filtered = _filter.isEmpty + ? logs + : logs + .where((l) => + l.toLowerCase().contains(_filter.toLowerCase())) + .toList(); + + if (filtered.isEmpty) { + return Center( + child: Text( + logs.isEmpty + ? AppStrings.noLogsYet + : AppStrings.noMatchingLogs, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_autoScroll && _scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent, + ); + } + }); + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + itemCount: filtered.length, + itemBuilder: (context, index) { + final line = filtered[index]; + if (_friendlyMode) { + return _buildFriendlyLogItem(line, theme); + } + return _buildRawLogItem(line, theme); + }, ); }, - ); - }, + ), + ), ), + ], + ), + ), + ); + } + + // 友好模式:卡片样式 + Widget _buildFriendlyLogItem(String line, ThemeData theme) { + final parsed = LogParser.parse(line, theme); + final isDark = theme.brightness == Brightness.dark; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Container( + decoration: BoxDecoration( + color: parsed.color.withAlpha(isDark ? 15 : 10), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: parsed.color.withAlpha(40)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: parsed.detail != null ? () => _showRawLog(line) : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(parsed.icon, size: 16, color: parsed.color), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + parsed.friendlyMessage, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + if (parsed.time.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + parsed.time, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ], + ], + ), + ), + if (parsed.detail != null) + Icon(Icons.chevron_right, + size: 14, color: theme.colorScheme.onSurfaceVariant), + ], ), ), + ), + ), + ); + } + + // 原始模式:等宽字体文本 + Widget _buildRawLogItem(String line, ThemeData theme) { + return Text( + line, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + color: _logColor(line, theme), + ), + ); + } + + void _showRawLog(String line) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(AppStrings.isChinese ? '原始日志' : 'Raw Log'), + content: SingleChildScrollView( + child: SelectableText( + line, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + actions: [ + TextButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: line)); + Navigator.pop(ctx); + }, + child: Text(AppStrings.copy), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx), + child: Text(AppStrings.done), + ), ], ), ); @@ -142,20 +257,17 @@ class _LogsScreenState extends State { if (line.contains('[WARN]') || line.contains('WARNING')) { return AppColors.statusAmber; } - if (line.contains('[INFO]')) { - return AppColors.mutedText; - } + if (line.contains('[INFO]')) return AppColors.mutedText; return theme.colorScheme.onSurface; } Future _takeScreenshot() async { - final path = await ScreenshotService.capture(_screenshotKey, prefix: 'logs'); + final path = + await ScreenshotService.capture(_screenshotKey, prefix: 'logs'); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(path != null - ? 'Screenshot saved: ${path.split('/').last}' - : 'Failed to capture screenshot'), + content: Text(path != null ? '截图已保存: ${path.split('/').last}' : '截图失败'), ), ); } @@ -165,7 +277,7 @@ class _LogsScreenState extends State { final text = provider.state.logs.join('\n'); Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Logs copied to clipboard')), + SnackBar(content: Text(AppStrings.copied)), ); } } diff --git a/flutter_app/lib/screens/nine_router_terminal_screen.dart b/flutter_app/lib/screens/nine_router_terminal_screen.dart new file mode 100644 index 0000000..46f0747 --- /dev/null +++ b/flutter_app/lib/screens/nine_router_terminal_screen.dart @@ -0,0 +1,221 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:xterm/xterm.dart'; +import 'package:flutter_pty/flutter_pty.dart'; +import '../l10n/app_strings.dart'; +import '../services/native_bridge.dart'; +import '../services/terminal_service.dart'; +import '../widgets/terminal_toolbar.dart'; + +/// 9Router 专属终端,使用独立的 NineRouterService 保活进程 +class NineRouterTerminalScreen extends StatefulWidget { + const NineRouterTerminalScreen({super.key}); + + @override + State createState() => + _NineRouterTerminalScreenState(); +} + +class _NineRouterTerminalScreenState extends State { + late final Terminal _terminal; + late final TerminalController _controller; + Pty? _pty; + bool _loading = true; + String? _error; + final _ctrlNotifier = ValueNotifier(false); + final _altNotifier = ValueNotifier(false); + + static const _fontFallback = [ + 'monospace', + 'Noto Sans Mono', + 'Noto Sans Mono CJK SC', + 'Noto Color Emoji', + 'sans-serif', + ]; + + // 自动执行的启动命令 + static const _autoCommand = + 'nohup 9router --port 20128 > /tmp/9router.log 2>&1 & echo "9Router starting..." && sleep 2 && curl -s http://127.0.0.1:20128/ > /dev/null && echo "9Router is running on port 20128!" || echo "Starting in background, check CLIProxy console."\n'; + + @override + void initState() { + super.initState(); + _terminal = Terminal(maxLines: 10000); + _controller = TerminalController(); + // 使用独立的 NineRouterService + NativeBridge.startNineRouterService(); + WidgetsBinding.instance.addPostFrameCallback((_) => _startPty()); + } + + Future _startPty() async { + _pty?.kill(); + _pty = null; + try { + try { + await NativeBridge.setupDirs(); + } catch (_) {} + try { + await NativeBridge.writeResolv(); + } catch (_) {} + try { + final filesDir = await NativeBridge.getFilesDir(); + const rc = 'nameserver 8.8.8.8\nnameserver 8.8.4.4\n'; + final rf = File('$filesDir/config/resolv.conf'); + if (!rf.existsSync()) { + Directory('$filesDir/config').createSync(recursive: true); + rf.writeAsStringSync(rc); + } + final rr = File('$filesDir/rootfs/ubuntu/etc/resolv.conf'); + if (!rr.existsSync()) { + rr.parent.createSync(recursive: true); + rr.writeAsStringSync(rc); + } + } catch (_) {} + + final config = await TerminalService.getProotShellConfig(); + final args = TerminalService.buildProotArgs( + config, + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + + _pty = Pty.start( + config['executable']!, + arguments: args, + environment: TerminalService.buildHostEnv(config), + columns: _terminal.viewWidth, + rows: _terminal.viewHeight, + ); + + _pty!.output.cast>().listen((data) { + _terminal.write(utf8.decode(data, allowMalformed: true)); + }); + + _pty!.exitCode.then((code) { + _terminal.write('\r\n[Process exited with code $code]\r\n'); + }); + + _terminal.onOutput = (data) { + if (_ctrlNotifier.value && data.length == 1) { + final code = data.toLowerCase().codeUnitAt(0); + if (code >= 97 && code <= 122) { + _pty?.write(Uint8List.fromList([code - 96])); + _ctrlNotifier.value = false; + return; + } + } + if (_altNotifier.value && data.isNotEmpty) { + _pty?.write(utf8.encode('\x1b$data')); + _altNotifier.value = false; + return; + } + _pty?.write(utf8.encode(data)); + }; + + _terminal.onResize = (w, h, pw, ph) => _pty?.resize(h, w); + + setState(() => _loading = false); + + // 等 shell 就绪后自动执行启动命令 + await Future.delayed(const Duration(milliseconds: 1500)); + _pty?.write(utf8.encode(_autoCommand)); + } catch (e) { + setState(() { + _loading = false; + _error = 'Failed to start: $e'; + }); + } + } + + @override + void dispose() { + _ctrlNotifier.dispose(); + _altNotifier.dispose(); + _controller.dispose(); + _pty?.kill(); + // 不停止 NineRouterService,让 9router 继续在后台运行 + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppStrings.isChinese ? '9Router 终端' : '9Router Terminal'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: AppStrings.restart, + onPressed: () { + _pty?.kill(); + setState(() { + _loading = true; + _error = null; + }); + _startPty(); + }, + ), + ], + ), + body: _loading + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('正在启动 9Router...'), + ], + )) + : _error != null + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48, color: Theme.of(context).colorScheme.error), + const SizedBox(height: 16), + Text(_error!, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () { + setState(() { + _loading = true; + _error = null; + }); + _startPty(); + }, + icon: const Icon(Icons.refresh), + label: Text(AppStrings.retry), + ), + ], + ), + )) + : Column( + children: [ + Expanded( + child: TerminalView( + _terminal, + controller: _controller, + textStyle: const TerminalStyle( + fontSize: 11, + height: 1.0, + fontFamily: 'DejaVuSansMono', + fontFamilyFallback: _fontFallback, + ), + ), + ), + TerminalToolbar( + pty: _pty, + ctrlNotifier: _ctrlNotifier, + altNotifier: _altNotifier, + ), + ], + ), + ); + } +} diff --git a/flutter_app/lib/screens/nine_router_webview_screen.dart b/flutter_app/lib/screens/nine_router_webview_screen.dart new file mode 100644 index 0000000..4ca4ee3 --- /dev/null +++ b/flutter_app/lib/screens/nine_router_webview_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../l10n/app_strings.dart'; +import '../utils/responsive.dart'; + +class NineRouterWebviewScreen extends StatefulWidget { + const NineRouterWebviewScreen({super.key}); + + @override + State createState() => + _NineRouterWebviewScreenState(); +} + +class _NineRouterWebviewScreenState extends State { + static const String _url = 'http://127.0.0.1:20128/'; + + late final WebViewController _controller; + bool _loading = true; + bool _hasError = false; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate(NavigationDelegate( + onPageStarted: (_) => setState(() { + _loading = true; + _hasError = false; + }), + onPageFinished: (_) => setState(() => _loading = false), + onWebResourceError: (error) { + if (error.isForMainFrame ?? true) { + setState(() { + _loading = false; + _hasError = true; + }); + } + }, + )) + ..loadRequest(Uri.parse(_url)); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isTablet = Responsive.isTablet(context); + + return Scaffold( + appBar: AppBar( + title: Text(AppStrings.isChinese ? '9Router 控制台' : '9Router Console'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: AppStrings.isChinese ? '刷新' : 'Refresh', + onPressed: () { + setState(() { + _loading = true; + _hasError = false; + }); + _controller.reload(); + }, + ), + IconButton( + icon: const Icon(Icons.open_in_browser), + tooltip: AppStrings.openInBrowser, + onPressed: () => launchUrl(Uri.parse(_url), + mode: LaunchMode.externalApplication), + ), + ], + ), + body: _hasError + ? Responsive.constrain(Center( + child: Padding( + padding: EdgeInsets.all(isTablet ? 48 : 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.cloud_off, + size: isTablet ? 80 : 64, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 24), + Text( + AppStrings.isChinese + ? '9Router 未运行' + : '9Router is not running', + style: theme.textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.w600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + AppStrings.isChinese + ? '请先在"9Router 终端"中启动服务,然后返回此页面。' + : 'Please start the service in "9Router Terminal" first.', + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton.icon( + onPressed: () { + setState(() { + _loading = true; + _hasError = false; + }); + _controller.reload(); + }, + icon: const Icon(Icons.refresh), + label: Text(AppStrings.reconnect), + ), + ], + ), + ), + )) + : Stack( + children: [ + WebViewWidget(controller: _controller), + if (_loading) const Center(child: CircularProgressIndicator()), + ], + ), + ); + } +} diff --git a/flutter_app/lib/screens/node_screen.dart b/flutter_app/lib/screens/node_screen.dart index f9b012d..133dde0 100644 --- a/flutter_app/lib/screens/node_screen.dart +++ b/flutter_app/lib/screens/node_screen.dart @@ -1,8 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_strings.dart'; import '../providers/node_provider.dart'; import '../services/preferences_service.dart'; +import '../utils/responsive.dart'; import '../widgets/node_controls.dart'; class NodeScreen extends StatefulWidget { @@ -53,226 +55,183 @@ class _NodeScreenState extends State { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Node Configuration')), + appBar: AppBar(title: Text(AppStrings.nodeConfiguration)), body: _loading ? const Center(child: CircularProgressIndicator()) - : Consumer( - builder: (context, provider, _) { - final state = provider.state; - - return ListView( - padding: const EdgeInsets.all(16), - children: [ - const NodeControls(), - const SizedBox(height: 16), - - // Gateway Connection - _sectionHeader(theme, 'GATEWAY CONNECTION'), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RadioListTile( - title: const Text('Local Gateway'), - subtitle: const Text('Auto-pair with gateway on this device'), - value: true, - groupValue: _isLocal, - onChanged: (value) { - setState(() => _isLocal = value!); - }, - ), - RadioListTile( - title: const Text('Remote Gateway'), - subtitle: const Text('Connect to a gateway on another device'), - value: false, - groupValue: _isLocal, - onChanged: (value) { - setState(() => _isLocal = value!); - }, - ), - if (!_isLocal) ...[ - const SizedBox(height: 12), - TextField( - controller: _hostController, - decoration: const InputDecoration( - labelText: 'Gateway Host', - hintText: '192.168.1.100', - ), - ), - const SizedBox(height: 12), - TextField( - controller: _portController, - decoration: const InputDecoration( - labelText: 'Gateway Port', - hintText: '18789', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 12), - TextField( - controller: _tokenController, - decoration: const InputDecoration( - labelText: 'Gateway Token', - hintText: 'Paste token from gateway dashboard URL', - helperText: 'Found in dashboard URL after #token=', - prefixIcon: Icon(Icons.key), - ), - obscureText: true, - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () { - final host = _hostController.text.trim(); - final port = int.tryParse(_portController.text.trim()) ?? 18789; - final token = _tokenController.text.trim(); - if (host.isNotEmpty) { - provider.connectRemote(host, port, - token: token.isNotEmpty ? token : null); - } - }, - icon: const Icon(Icons.link), - label: const Text('Connect'), - ), - ], - ], - ), - ), - ), - const SizedBox(height: 16), - - // Pairing Status - if (state.pairingCode != null) ...[ - _sectionHeader(theme, 'PAIRING'), + : Responsive.constrain( + Consumer( + builder: (context, provider, _) { + final state = provider.state; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + const NodeControls(), + const SizedBox(height: 16), + _sectionHeader(theme, AppStrings.gatewayConnection), Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.qr_code, size: 48), - const SizedBox(height: 8), - Text( - 'Approve this code on the gateway:', - style: theme.textTheme.bodyMedium, + RadioListTile( + title: Text(AppStrings.localGateway), + subtitle: Text(AppStrings.localGatewaySubtitle), + value: true, + groupValue: _isLocal, + onChanged: (v) => setState(() => _isLocal = v!), ), - const SizedBox(height: 8), - SelectableText( - state.pairingCode!, - style: theme.textTheme.headlineMedium?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), + RadioListTile( + title: Text(AppStrings.remoteGateway), + subtitle: + Text(AppStrings.remoteGatewaySubtitle), + value: false, + groupValue: _isLocal, + onChanged: (v) => setState(() => _isLocal = v!), ), + if (!_isLocal) ...[ + const SizedBox(height: 12), + TextField( + controller: _hostController, + decoration: InputDecoration( + labelText: AppStrings.gatewayHost, + hintText: '192.168.1.100', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _portController, + decoration: InputDecoration( + labelText: AppStrings.gatewayPort, + hintText: '18789', + ), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: _tokenController, + decoration: InputDecoration( + labelText: AppStrings.gatewayToken, + hintText: AppStrings.gatewayTokenHint, + helperText: AppStrings.gatewayTokenHelper, + prefixIcon: const Icon(Icons.key), + ), + obscureText: true, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () { + final host = _hostController.text.trim(); + final port = int.tryParse( + _portController.text.trim()) ?? + 18789; + final token = _tokenController.text.trim(); + if (host.isNotEmpty) { + provider.connectRemote(host, port, + token: + token.isNotEmpty ? token : null); + } + }, + icon: const Icon(Icons.link), + label: Text(AppStrings.connect), + ), + ], ], ), ), ), const SizedBox(height: 16), - ], - - // Capabilities - _sectionHeader(theme, 'CAPABILITIES'), - _capabilityTile( - theme, - 'Camera', - 'Capture photos and video clips', - Icons.camera_alt, - ), - _capabilityTile( - theme, - 'Canvas', - 'Not available on mobile', - Icons.web, - available: false, - ), - _capabilityTile( - theme, - 'Location', - 'Get device GPS coordinates', - Icons.location_on, - ), - _capabilityTile( - theme, - 'Screen Recording', - 'Record device screen (requires consent each time)', - Icons.screen_share, - ), - _capabilityTile( - theme, - 'Flashlight', - 'Toggle device torch on/off', - Icons.flashlight_on, - ), - _capabilityTile( - theme, - 'Vibration', - 'Trigger haptic feedback and vibration patterns', - Icons.vibration, - ), - _capabilityTile( - theme, - 'Sensors', - 'Read accelerometer, gyroscope, magnetometer, barometer', - Icons.sensors, - ), - _capabilityTile( - theme, - 'Serial', - 'Bluetooth and USB serial communication', - Icons.usb, - ), - const SizedBox(height: 16), - - // Device Info - if (state.deviceId != null) ...[ - _sectionHeader(theme, 'DEVICE INFO'), - ListTile( - title: const Text('Device ID'), - subtitle: SelectableText( - state.deviceId!, - style: const TextStyle(fontFamily: 'monospace', fontSize: 12), - ), - leading: const Icon(Icons.fingerprint), - ), - ], - const SizedBox(height: 16), - - // Logs - _sectionHeader(theme, 'NODE LOGS'), - Card( - child: Container( - height: 200, - padding: const EdgeInsets.all(12), - child: state.logs.isEmpty - ? Center( - child: Text( - 'No logs yet', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + if (state.pairingCode != null) ...[ + _sectionHeader(theme, AppStrings.pairing), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + const Icon(Icons.qr_code, size: 48), + const SizedBox(height: 8), + Text(AppStrings.pairingPrompt, + style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + SelectableText( + state.pairingCode!, + style: + theme.textTheme.headlineMedium?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, ), ), - ) - : ListView.builder( - reverse: true, - itemCount: state.logs.length, - itemBuilder: (context, index) { - final log = state.logs[state.logs.length - 1 - index]; - return Text( - log, - style: const TextStyle( - fontFamily: 'monospace', - fontSize: 11, + ], + ), + ), + ), + const SizedBox(height: 16), + ], + _sectionHeader(theme, AppStrings.capabilities), + _capabilityTile(theme, AppStrings.capCamera, + AppStrings.capCameraDesc, Icons.camera_alt), + _capabilityTile(theme, AppStrings.capCanvas, + AppStrings.capCanvasDesc, Icons.web, + available: false), + _capabilityTile(theme, AppStrings.capLocation, + AppStrings.capLocationDesc, Icons.location_on), + _capabilityTile(theme, AppStrings.capScreen, + AppStrings.capScreenDesc, Icons.screen_share), + _capabilityTile(theme, AppStrings.capFlash, + AppStrings.capFlashDesc, Icons.flashlight_on), + _capabilityTile(theme, AppStrings.capVibration, + AppStrings.capVibrationDesc, Icons.vibration), + _capabilityTile(theme, AppStrings.capSensors, + AppStrings.capSensorsDesc, Icons.sensors), + _capabilityTile(theme, AppStrings.capSerial, + AppStrings.capSerialDesc, Icons.usb), + const SizedBox(height: 16), + if (state.deviceId != null) ...[ + _sectionHeader(theme, AppStrings.deviceInfo), + ListTile( + title: Text(AppStrings.deviceId), + subtitle: SelectableText( + state.deviceId!, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12), + ), + leading: const Icon(Icons.fingerprint), + ), + ], + const SizedBox(height: 16), + _sectionHeader(theme, AppStrings.nodeLogs), + Card( + child: Container( + height: 200, + padding: const EdgeInsets.all(12), + child: state.logs.isEmpty + ? Center( + child: Text( + AppStrings.noLogsYetNode, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - ); - }, - ), + ), + ) + : ListView.builder( + reverse: true, + itemCount: state.logs.length, + itemBuilder: (context, index) { + final log = state + .logs[state.logs.length - 1 - index]; + return Text(log, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 11)); + }, + ), + ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } @@ -300,16 +259,9 @@ class _NodeScreenState extends State { title: Text(title), subtitle: Text(subtitle), trailing: available - ? const Icon( - Icons.check_circle, - color: AppColors.statusGreen, - size: 20, - ) - : const Icon( - Icons.block, - color: AppColors.statusAmber, - size: 20, - ), + ? const Icon(Icons.check_circle, + color: AppColors.statusGreen, size: 20) + : const Icon(Icons.block, color: AppColors.statusAmber, size: 20), ), ); } diff --git a/flutter_app/lib/screens/onboarding_screen.dart b/flutter_app/lib/screens/onboarding_screen.dart index b2e409e..685cd98 100644 --- a/flutter_app/lib/screens/onboarding_screen.dart +++ b/flutter_app/lib/screens/onboarding_screen.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -6,6 +6,7 @@ import 'package:xterm/xterm.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:url_launcher/url_launcher.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../services/native_bridge.dart'; import '../services/screenshot_service.dart'; import '../services/terminal_service.dart'; @@ -69,7 +70,7 @@ class _OnboardingScreenState extends State { // Defer PTY start until after the first frame so TerminalView has been // laid out and _terminal.viewWidth/viewHeight reflect real screen // dimensions instead of the 80×24 default. This is critical for QR - // codes — the shell must know the actual column count to avoid wrapping. + // codes �?the shell must know the actual column count to avoid wrapping. WidgetsBinding.instance.addPostFrameCallback((_) { _startOnboarding(); }); @@ -415,9 +416,9 @@ class _OnboardingScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('OpenClaw Onboarding'), + title: Text(AppStrings.openClawOnboarding), leading: widget.isFirstRun - ? null // no back button during first-run + ? null : IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), @@ -426,22 +427,22 @@ class _OnboardingScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: AppStrings.screenshot, onPressed: _takeScreenshot, ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy', + tooltip: AppStrings.copy, onPressed: _copySelection, ), IconButton( icon: const Icon(Icons.open_in_browser), - tooltip: 'Open URL', + tooltip: AppStrings.openUrl, onPressed: _openSelection, ), IconButton( icon: const Icon(Icons.paste), - tooltip: 'Paste', + tooltip: AppStrings.paste, onPressed: _paste, ), ], @@ -449,14 +450,14 @@ class _OnboardingScreenState extends State { body: Column( children: [ if (_loading) - const Expanded( + Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), - Text('Starting onboarding...'), + Text(AppStrings.startingOnboarding), ], ), ), @@ -534,8 +535,8 @@ class _OnboardingScreenState extends State { ? Icons.arrow_forward : Icons.check), label: Text(widget.isFirstRun - ? 'Go to Dashboard' - : 'Done'), + ? AppStrings.goToDashboard + : AppStrings.done), ), ), ), diff --git a/flutter_app/lib/screens/package_install_screen.dart b/flutter_app/lib/screens/package_install_screen.dart index 9cb543d..1edbfe9 100644 --- a/flutter_app/lib/screens/package_install_screen.dart +++ b/flutter_app/lib/screens/package_install_screen.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -210,7 +210,7 @@ class _PackageInstallScreenState extends State { body: Column( children: [ if (_loading) - const Expanded( + Expanded( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/flutter_app/lib/screens/packages_screen.dart b/flutter_app/lib/screens/packages_screen.dart index 9324309..3d97ebb 100644 --- a/flutter_app/lib/screens/packages_screen.dart +++ b/flutter_app/lib/screens/packages_screen.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import '../app.dart'; import '../models/optional_package.dart'; import '../services/package_service.dart'; @@ -82,7 +82,7 @@ class _PackagesScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('Optional Packages')), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? Center(child: CircularProgressIndicator()) : ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/flutter_app/lib/screens/provider_detail_screen.dart b/flutter_app/lib/screens/provider_detail_screen.dart index 8601b88..09633cd 100644 --- a/flutter_app/lib/screens/provider_detail_screen.dart +++ b/flutter_app/lib/screens/provider_detail_screen.dart @@ -1,9 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import '../app.dart'; +import '../l10n/app_strings.dart'; import '../models/ai_provider.dart'; import '../services/provider_config_service.dart'; +import '../utils/responsive.dart'; -/// Form screen to configure API key and model for a single AI provider. class ProviderDetailScreen extends StatefulWidget { final AiProvider provider; final String? existingApiKey; @@ -25,29 +26,38 @@ class _ProviderDetailScreenState extends State { late final TextEditingController _apiKeyController; late final TextEditingController _customModelController; + late final TextEditingController _baseUrlController; late String _selectedModel; bool _isCustomModel = false; bool _obscureKey = true; bool _saving = false; bool _removing = false; - bool get _isConfigured => widget.existingApiKey != null && widget.existingApiKey!.isNotEmpty; + bool get _isCustomProvider => widget.provider.id == 'custom'; + + bool get _isConfigured => + widget.existingApiKey != null && widget.existingApiKey!.isNotEmpty; - /// Returns the effective model name to save. String get _effectiveModel => _isCustomModel ? _customModelController.text.trim() : _selectedModel; + String get _effectiveBaseUrl => _isCustomProvider + ? _baseUrlController.text.trim() + : widget.provider.baseUrl; + @override void initState() { super.initState(); - _apiKeyController = TextEditingController(text: widget.existingApiKey ?? ''); + _apiKeyController = + TextEditingController(text: widget.existingApiKey ?? ''); _customModelController = TextEditingController(); + _baseUrlController = TextEditingController(text: widget.provider.baseUrl); - final existing = widget.existingModel ?? widget.provider.defaultModels.first; + final existing = + widget.existingModel ?? widget.provider.defaultModels.first; if (widget.provider.defaultModels.contains(existing)) { _selectedModel = existing; } else { - // Existing model is not in the predefined list — treat as custom _selectedModel = _customModelSentinel; _isCustomModel = true; _customModelController.text = existing; @@ -56,6 +66,7 @@ class _ProviderDetailScreenState extends State { @override void dispose() { + _baseUrlController.dispose(); _apiKeyController.dispose(); _customModelController.dispose(); super.dispose(); @@ -65,14 +76,14 @@ class _ProviderDetailScreenState extends State { final apiKey = _apiKeyController.text.trim(); if (apiKey.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('API key cannot be empty')), + SnackBar(content: Text(AppStrings.apiKeyEmpty)), ); return; } final model = _effectiveModel; if (model.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Model name cannot be empty')), + SnackBar(content: Text(AppStrings.modelEmpty)), ); return; } @@ -83,17 +94,20 @@ class _ProviderDetailScreenState extends State { provider: widget.provider, apiKey: apiKey, model: model, + baseUrl: _effectiveBaseUrl, ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${widget.provider.name} configured and activated')), + SnackBar( + content: Text( + '${widget.provider.name} ${AppStrings.configuredAndActivated}')), ); Navigator.of(context).pop(true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save: $e')), + SnackBar(content: Text('${AppStrings.saveFailed}: $e')), ); } } finally { @@ -105,16 +119,16 @@ class _ProviderDetailScreenState extends State { final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( - title: Text('Remove ${widget.provider.name}?'), - content: const Text('This will delete the API key and deactivate the model.'), + title: Text('${AppStrings.removeProvider} ${widget.provider.name}?'), + content: Text(AppStrings.removeProviderContent), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), + child: Text(AppStrings.cancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Remove'), + child: Text(AppStrings.remove), ), ], ), @@ -124,17 +138,19 @@ class _ProviderDetailScreenState extends State { setState(() => _removing = true); try { - await ProviderConfigService.removeProviderConfig(provider: widget.provider); + await ProviderConfigService.removeProviderConfig( + provider: widget.provider); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${widget.provider.name} removed')), + SnackBar( + content: Text('${widget.provider.name} ${AppStrings.remove}')), ); Navigator.of(context).pop(true); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to remove: $e')), + SnackBar(content: Text('${AppStrings.removeFailed}: $e')), ); } } finally { @@ -150,134 +166,150 @@ class _ProviderDetailScreenState extends State { return Scaffold( appBar: AppBar(title: Text(widget.provider.name)), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // Provider header - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: iconBg, - borderRadius: BorderRadius.circular(12), + body: Responsive.constrain( + ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: iconBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(widget.provider.icon, + color: widget.provider.color), ), - child: Icon(widget.provider.icon, color: widget.provider.color), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.provider.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.provider.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: 4), - Text( - widget.provider.description, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: 4), + Text( + widget.provider.description, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: 24), - - // API Key - Text( - 'API Key', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - TextField( - controller: _apiKeyController, - obscureText: _obscureKey, - decoration: InputDecoration( - hintText: widget.provider.apiKeyHint, - suffixIcon: IconButton( - icon: Icon(_obscureKey ? Icons.visibility_off : Icons.visibility), - onPressed: () => setState(() => _obscureKey = !_obscureKey), - ), + const SizedBox(height: 24), + // Base URL - 仅 custom 提供商可编辑,其他只读显示 + Text( + 'Base URL', + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), ), - ), - const SizedBox(height: 24), - - // Model selection - Text( - 'Model', - style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _selectedModel, - isExpanded: true, - decoration: const InputDecoration(), - items: [ - ...widget.provider.defaultModels - .map((m) => DropdownMenuItem(value: m, child: Text(m))), - const DropdownMenuItem( - value: _customModelSentinel, - child: Text('Custom...'), + const SizedBox(height: 8), + TextField( + controller: _baseUrlController, + readOnly: !_isCustomProvider, + decoration: InputDecoration( + hintText: 'http://127.0.0.1:18790/v1', + helperText: _isCustomProvider ? AppStrings.baseUrlHelper : null, ), - ], - onChanged: (value) { - if (value != null) { - setState(() { - _selectedModel = value; - _isCustomModel = value == _customModelSentinel; - }); - } - }, - ), - if (_isCustomModel) ...[ - const SizedBox(height: 12), + ), + const SizedBox(height: 24), + Text( + AppStrings.apiKey, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), TextField( - controller: _customModelController, - decoration: const InputDecoration( - hintText: 'e.g. meta/llama-3.3-70b-instruct', - labelText: 'Custom model name', + controller: _apiKeyController, + obscureText: _obscureKey, + decoration: InputDecoration( + hintText: widget.provider.apiKeyHint, + suffixIcon: IconButton( + icon: Icon( + _obscureKey ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _obscureKey = !_obscureKey), + ), ), ), - ], - const SizedBox(height: 32), - - // Actions - FilledButton( - onPressed: _saving ? null : _save, - child: _saving - ? const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text('Save & Activate'), - ), - if (_isConfigured) ...[ - const SizedBox(height: 12), - OutlinedButton( - onPressed: _removing ? null : _remove, - child: _removing + const SizedBox(height: 24), + Text( + AppStrings.model, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedModel, + isExpanded: true, + decoration: const InputDecoration(), + items: [ + ...widget.provider.defaultModels + .map((m) => DropdownMenuItem(value: m, child: Text(m))), + DropdownMenuItem( + value: _customModelSentinel, + child: Text(AppStrings.customModel), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() { + _selectedModel = value; + _isCustomModel = value == _customModelSentinel; + }); + } + }, + ), + if (_isCustomModel) ...[ + const SizedBox(height: 12), + TextField( + controller: _customModelController, + decoration: InputDecoration( + hintText: AppStrings.customModelHint, + labelText: AppStrings.customModelLabel, + ), + ), + ], + const SizedBox(height: 32), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving ? const SizedBox( height: 20, width: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), ) - : const Text('Remove Configuration'), + : Text(AppStrings.saveAndActivate), ), + if (_isConfigured) ...[ + const SizedBox(height: 12), + OutlinedButton( + onPressed: _removing ? null : _remove, + child: _removing + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(AppStrings.removeConfiguration), + ), + ], ], - ], + ), ), ); } diff --git a/flutter_app/lib/screens/providers_screen.dart b/flutter_app/lib/screens/providers_screen.dart index 3bbb535..baa8f82 100644 --- a/flutter_app/lib/screens/providers_screen.dart +++ b/flutter_app/lib/screens/providers_screen.dart @@ -1,7 +1,9 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import '../app.dart'; +import '../l10n/app_strings.dart'; import '../models/ai_provider.dart'; import '../services/provider_config_service.dart'; +import '../utils/responsive.dart'; import 'provider_detail_screen.dart'; /// Lists all AI providers with their configuration status. @@ -53,13 +55,12 @@ class _ProvidersScreenState extends State { String _statusLabel(AiProvider provider) { final isConfigured = _providers.containsKey(provider.id); if (!isConfigured) return ''; - // Check if the active model belongs to this provider if (_activeModel != null) { final isActive = provider.defaultModels.any((m) => _activeModel!.contains(m)) || _activeModel!.contains(provider.id); - if (isActive) return 'Active'; + if (isActive) return AppStrings.active; } - return 'Configured'; + return AppStrings.configured; } @override @@ -68,68 +69,69 @@ class _ProvidersScreenState extends State { final isDark = theme.brightness == Brightness.dark; return Scaffold( - appBar: AppBar(title: const Text('AI Providers')), + appBar: AppBar(title: Text(AppStrings.aiProviders)), body: _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(16), - children: [ - // Active model card - if (_activeModel != null && _activeModel!.isNotEmpty) ...[ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.statusGreen.withAlpha(25), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon( - Icons.check_circle, - color: AppColors.statusGreen, + ? Center(child: CircularProgressIndicator()) + : Responsive.constrain( + ListView( + padding: const EdgeInsets.all(16), + children: [ + if (_activeModel != null && _activeModel!.isNotEmpty) ...[ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.statusGreen.withAlpha(25), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.check_circle, + color: AppColors.statusGreen, + ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Active Model', - style: theme.textTheme.labelSmall?.copyWith( - color: AppColors.statusGreen, - fontWeight: FontWeight.w600, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppStrings.activeModel, + style: theme.textTheme.labelSmall?.copyWith( + color: AppColors.statusGreen, + fontWeight: FontWeight.w600, + ), ), - ), - const SizedBox(height: 2), - Text( - _activeModel!, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + const SizedBox(height: 2), + Text( + _activeModel!, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), + const SizedBox(height: 16), + ], + Text( + AppStrings.selectProvider, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 16), + for (final provider in AiProvider.all) + _buildProviderCard(theme, provider, isDark), ], - Text( - 'Select a provider to configure its API key and model.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - for (final provider in AiProvider.all) - _buildProviderCard(theme, provider, isDark), - ], + ), ), ); } @@ -177,7 +179,7 @@ class _ProvidersScreenState extends State { vertical: 2, ), decoration: BoxDecoration( - color: (status == 'Active' + color: (status == AppStrings.active ? AppColors.statusGreen : AppColors.statusAmber) .withAlpha(25), @@ -186,7 +188,7 @@ class _ProvidersScreenState extends State { child: Text( status, style: theme.textTheme.labelSmall?.copyWith( - color: status == 'Active' + color: status == AppStrings.active ? AppColors.statusGreen : AppColors.statusAmber, fontWeight: FontWeight.w600, diff --git a/flutter_app/lib/screens/settings_screen.dart b/flutter_app/lib/screens/settings_screen.dart index 3aad664..dffeb49 100644 --- a/flutter_app/lib/screens/settings_screen.dart +++ b/flutter_app/lib/screens/settings_screen.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; @@ -6,10 +6,12 @@ import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../providers/node_provider.dart'; import '../services/native_bridge.dart'; import '../services/preferences_service.dart'; import '../services/update_service.dart'; +import '../utils/responsive.dart'; import 'node_screen.dart'; import 'setup_wizard_screen.dart'; @@ -85,253 +87,206 @@ class _SettingsScreenState extends State { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: const Text('Settings')), + appBar: AppBar(title: Text(AppStrings.settings)), body: _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - children: [ - _sectionHeader(theme, 'GENERAL'), - SwitchListTile( - title: const Text('Auto-start gateway'), - subtitle: const Text('Start the gateway when the app opens'), - value: _autoStart, - onChanged: (value) { - setState(() => _autoStart = value); - _prefs.autoStartGateway = value; - }, - ), - ListTile( - title: const Text('Battery Optimization'), - subtitle: Text(_batteryOptimized - ? 'Optimized (may kill background sessions)' - : 'Unrestricted (recommended)'), - leading: const Icon(Icons.battery_alert), - trailing: _batteryOptimized - ? const Icon(Icons.warning, color: AppColors.statusAmber) - : const Icon(Icons.check_circle, color: AppColors.statusGreen), - onTap: () async { - await NativeBridge.requestBatteryOptimization(); - // Refresh status after returning from settings - final optimized = await NativeBridge.isBatteryOptimized(); - setState(() => _batteryOptimized = optimized); - }, - ), - ListTile( - title: const Text('Setup Storage'), - subtitle: Text(_storageGranted - ? 'Granted — proot can access /sdcard. Revoke if not needed.' - : 'Allow access to shared storage'), - leading: const Icon(Icons.sd_storage), - trailing: _storageGranted - ? const Icon(Icons.warning_amber, color: AppColors.statusAmber) - : const Icon(Icons.warning, color: AppColors.statusAmber), - onTap: () async { - await NativeBridge.requestStoragePermission(); - // Refresh after returning from permission screen - final granted = await NativeBridge.hasStoragePermission(); - setState(() => _storageGranted = granted); - }, - ), - const Divider(), - _sectionHeader(theme, 'NODE'), - SwitchListTile( - title: const Text('Enable Node'), - subtitle: const Text('Provide device capabilities to the gateway'), - value: _nodeEnabled, - onChanged: (value) { - setState(() => _nodeEnabled = value); - _prefs.nodeEnabled = value; - final nodeProvider = context.read(); - if (value) { - nodeProvider.enable(); - } else { - nodeProvider.disable(); - } - }, - ), - ListTile( - title: const Text('Node Configuration'), - subtitle: const Text('Connection, pairing, and capabilities'), - leading: const Icon(Icons.devices), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const NodeScreen()), + ? Center(child: CircularProgressIndicator()) + : Responsive.constrain( + ListView( + children: [ + _sectionHeader(theme, AppStrings.general.toUpperCase()), + SwitchListTile( + title: Text(AppStrings.autoStartGateway), + subtitle: Text(AppStrings.autoStartSubtitle), + value: _autoStart, + onChanged: (value) { + setState(() => _autoStart = value); + _prefs.autoStartGateway = value; + }, ), - ), - const Divider(), - _sectionHeader(theme, 'SYSTEM INFO'), - ListTile( - title: const Text('Architecture'), - subtitle: Text(_arch), - leading: const Icon(Icons.memory), - ), - ListTile( - title: const Text('PRoot path'), - subtitle: Text(_prootPath), - leading: const Icon(Icons.folder), - ), - ListTile( - title: const Text('Rootfs'), - subtitle: Text(_status['rootfsExists'] == true - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.storage), - ), - ListTile( - title: const Text('Node.js'), - subtitle: Text(_status['nodeInstalled'] == true - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.code), - ), - ListTile( - title: const Text('OpenClaw'), - subtitle: Text(_status['openclawInstalled'] == true - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.cloud), - ), - ListTile( - title: const Text('Go (Golang)'), - subtitle: Text(_goInstalled - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.integration_instructions), - ), - ListTile( - title: const Text('Homebrew'), - subtitle: Text(_brewInstalled - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.science), - ), - ListTile( - title: const Text('OpenSSH'), - subtitle: Text(_sshInstalled - ? 'Installed' - : 'Not installed'), - leading: const Icon(Icons.vpn_key), - ), - const Divider(), - _sectionHeader(theme, 'MAINTENANCE'), - ListTile( - title: const Text('Export Snapshot'), - subtitle: const Text('Backup config to Downloads'), - leading: const Icon(Icons.upload_file), - trailing: const Icon(Icons.chevron_right), - onTap: _exportSnapshot, - ), - ListTile( - title: const Text('Import Snapshot'), - subtitle: const Text('Restore config from backup'), - leading: const Icon(Icons.download), - trailing: const Icon(Icons.chevron_right), - onTap: _importSnapshot, - ), - ListTile( - title: const Text('Re-run setup'), - subtitle: const Text('Reinstall or repair the environment'), - leading: const Icon(Icons.build), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => const SetupWizardScreen(), + ListTile( + title: Text(AppStrings.batteryOptimization), + subtitle: Text(_batteryOptimized + ? AppStrings.batteryOptimized + : AppStrings.batteryUnrestricted), + leading: const Icon(Icons.battery_alert), + trailing: _batteryOptimized + ? const Icon(Icons.warning, color: AppColors.statusAmber) + : const Icon(Icons.check_circle, color: AppColors.statusGreen), + onTap: () async { + await NativeBridge.requestBatteryOptimization(); + final optimized = await NativeBridge.isBatteryOptimized(); + setState(() => _batteryOptimized = optimized); + }, + ), + ListTile( + title: Text(AppStrings.setupStorage), + subtitle: Text(_storageGranted + ? AppStrings.storageGranted + : AppStrings.storageNotGranted), + leading: const Icon(Icons.sd_storage), + trailing: _storageGranted + ? const Icon(Icons.warning_amber, color: AppColors.statusAmber) + : const Icon(Icons.warning, color: AppColors.statusAmber), + onTap: () async { + await NativeBridge.requestStoragePermission(); + final granted = await NativeBridge.hasStoragePermission(); + setState(() => _storageGranted = granted); + }, + ), + const Divider(), + _sectionHeader(theme, AppStrings.nodeSection.toUpperCase()), + SwitchListTile( + title: Text(AppStrings.enableNodeTitle), + subtitle: Text(AppStrings.enableNodeSubtitle), + value: _nodeEnabled, + onChanged: (value) { + setState(() => _nodeEnabled = value); + _prefs.nodeEnabled = value; + final nodeProvider = context.read(); + if (value) { + nodeProvider.enable(); + } else { + nodeProvider.disable(); + } + }, + ), + ListTile( + title: Text(AppStrings.nodeConfiguration), + subtitle: Text(AppStrings.nodeConfigSubtitle), + leading: const Icon(Icons.devices), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NodeScreen()), ), ), - ), - const Divider(), - _sectionHeader(theme, 'ABOUT'), - const ListTile( - title: Text('OpenClaw'), - subtitle: Text( - 'AI Gateway for Android\nVersion ${AppConstants.version}', + const Divider(), + _sectionHeader(theme, AppStrings.systemInfo.toUpperCase()), + ListTile( + title: Text(AppStrings.architecture), + subtitle: Text(_arch), + leading: const Icon(Icons.memory), ), - leading: Icon(Icons.info_outline), - isThreeLine: true, - ), - ListTile( - title: const Text('Check for Updates'), - subtitle: const Text('Check GitHub for a newer release'), - leading: _checkingUpdate - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.system_update), - onTap: _checkingUpdate ? null : _checkForUpdates, - ), - const ListTile( - title: Text('Developer'), - subtitle: Text(AppConstants.authorName), - leading: Icon(Icons.person), - ), - ListTile( - title: const Text('GitHub'), - subtitle: const Text('mithun50/openclaw-termux'), - leading: const Icon(Icons.code), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse(AppConstants.githubUrl), - mode: LaunchMode.externalApplication, + ListTile( + title: Text(AppStrings.prootPath), + subtitle: Text(_prootPath), + leading: const Icon(Icons.folder), ), - ), - ListTile( - title: const Text('Contact'), - subtitle: const Text(AppConstants.authorEmail), - leading: const Icon(Icons.email), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse('mailto:${AppConstants.authorEmail}'), + ListTile( + title: Text(AppStrings.rootfs), + subtitle: Text(_status['rootfsExists'] == true + ? AppStrings.installed + : AppStrings.notInstalled), + leading: const Icon(Icons.storage), ), - ), - const ListTile( - title: Text('License'), - subtitle: Text(AppConstants.license), - leading: Icon(Icons.description), - ), - const Divider(), - _sectionHeader(theme, AppConstants.orgName.toUpperCase()), - ListTile( - title: const Text('Instagram'), - subtitle: const Text('@nexgenxplorer_nxg'), - leading: const Icon(Icons.camera_alt), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse(AppConstants.instagramUrl), - mode: LaunchMode.externalApplication, + ListTile( + title: const Text('Node.js'), + subtitle: Text(_status['nodeInstalled'] == true + ? AppStrings.installed + : AppStrings.notInstalled), + leading: const Icon(Icons.code), ), - ), - ListTile( - title: const Text('YouTube'), - subtitle: const Text('@nexgenxplorer'), - leading: const Icon(Icons.play_circle_fill), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse(AppConstants.youtubeUrl), - mode: LaunchMode.externalApplication, + ListTile( + title: const Text('OpenClaw'), + subtitle: Text(_status['openclawInstalled'] == true + ? AppStrings.installed + : AppStrings.notInstalled), + leading: const Icon(Icons.cloud), ), - ), - ListTile( - title: const Text('Play Store'), - subtitle: const Text('NextGenX Apps'), - leading: const Icon(Icons.shop), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse(AppConstants.playStoreUrl), - mode: LaunchMode.externalApplication, + ListTile( + title: const Text('Go (Golang)'), + subtitle: Text(_goInstalled ? AppStrings.installed : AppStrings.notInstalled), + leading: const Icon(Icons.integration_instructions), + ), + ListTile( + title: const Text('Homebrew'), + subtitle: Text(_brewInstalled ? AppStrings.installed : AppStrings.notInstalled), + leading: const Icon(Icons.science), + ), + ListTile( + title: const Text('OpenSSH'), + subtitle: Text(_sshInstalled ? AppStrings.installed : AppStrings.notInstalled), + leading: const Icon(Icons.vpn_key), + ), + const Divider(), + _sectionHeader(theme, AppStrings.maintenance.toUpperCase()), + ListTile( + title: Text(AppStrings.exportSnapshot), + subtitle: Text(AppStrings.exportSnapshotSubtitle), + leading: const Icon(Icons.upload_file), + trailing: const Icon(Icons.chevron_right), + onTap: _exportSnapshot, + ), + ListTile( + title: Text(AppStrings.importSnapshot), + subtitle: Text(AppStrings.importSnapshotSubtitle), + leading: const Icon(Icons.download), + trailing: const Icon(Icons.chevron_right), + onTap: _importSnapshot, ), - ), - ListTile( - title: const Text('Email'), - subtitle: const Text(AppConstants.orgEmail), - leading: const Icon(Icons.email_outlined), - trailing: const Icon(Icons.open_in_new, size: 18), - onTap: () => launchUrl( - Uri.parse('mailto:${AppConstants.orgEmail}'), + ListTile( + title: Text(AppStrings.rerunSetup), + subtitle: Text(AppStrings.rerunSetupSubtitle), + leading: const Icon(Icons.build), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => const SetupWizardScreen(), + ), + ), + ), + const Divider(), + _sectionHeader(theme, AppStrings.about.toUpperCase()), + const ListTile( + title: Text('OpenClaw'), + subtitle: Text( + 'AI Gateway for Android\nVersion ${AppConstants.version}', + ), + leading: Icon(Icons.info_outline), + isThreeLine: true, ), - ), - ], + ListTile( + title: Text(AppStrings.checkForUpdates), + subtitle: Text(AppStrings.checkUpdatesSubtitle), + leading: _checkingUpdate + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update), + onTap: _checkingUpdate ? null : _checkForUpdates, + ), + ListTile( + title: Text(AppStrings.developer), + subtitle: const Text(AppConstants.authorName), + leading: const Icon(Icons.person), + ), + ListTile( + title: Text(AppStrings.github), + subtitle: const Text('mithun50/openclaw-termux'), + leading: const Icon(Icons.code), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => launchUrl( + Uri.parse(AppConstants.githubUrl), + mode: LaunchMode.externalApplication, + ), + ), + ListTile( + title: Text(AppStrings.contact), + subtitle: const Text(AppConstants.authorEmail), + leading: const Icon(Icons.email), + trailing: const Icon(Icons.open_in_new, size: 18), + onTap: () => launchUrl( + Uri.parse('mailto:${AppConstants.authorEmail}'), + ), + ), + ListTile( + title: Text(AppStrings.license), + subtitle: const Text(AppConstants.license), + leading: const Icon(Icons.description), + ), + ], + ), ), ); } @@ -373,12 +328,12 @@ class _SettingsScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Snapshot saved to $path')), + SnackBar(content: Text('${AppStrings.snapshotSaved}: $path')), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Export failed: $e')), + SnackBar(content: Text('${AppStrings.exportFailed}: $e')), ); } } @@ -391,7 +346,7 @@ class _SettingsScreenState extends State { if (!await file.exists()) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('No snapshot found at $path')), + SnackBar(content: Text('${AppStrings.noSnapshotFound}: $path')), ); return; } @@ -433,12 +388,12 @@ class _SettingsScreenState extends State { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Snapshot restored successfully. Restart the gateway to apply.')), + SnackBar(content: Text(AppStrings.snapshotRestored)), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Import failed: $e')), + SnackBar(content: Text('${AppStrings.importFailed}: $e')), ); } } @@ -452,16 +407,15 @@ class _SettingsScreenState extends State { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Update Available'), + title: Text(AppStrings.updateAvailable), content: Text( - 'A new version is available.\n\n' - 'Current: ${AppConstants.version}\n' - 'Latest: ${result.latest}', + '${AppStrings.currentVersion}: ${AppConstants.version}\n' + '${AppStrings.latestVersion}: ${result.latest}', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), - child: const Text('Later'), + child: Text(AppStrings.later), ), FilledButton( onPressed: () { @@ -471,20 +425,20 @@ class _SettingsScreenState extends State { mode: LaunchMode.externalApplication, ); }, - child: const Text('Download'), + child: Text(AppStrings.download), ), ], ), ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("You're on the latest version")), + SnackBar(content: Text(AppStrings.alreadyLatest)), ); } } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not check for updates')), + SnackBar(content: Text(AppStrings.checkUpdateFailed)), ); } finally { if (mounted) setState(() => _checkingUpdate = false); diff --git a/flutter_app/lib/screens/setup_wizard_screen.dart b/flutter_app/lib/screens/setup_wizard_screen.dart index dc18c5d..a79ed76 100644 --- a/flutter_app/lib/screens/setup_wizard_screen.dart +++ b/flutter_app/lib/screens/setup_wizard_screen.dart @@ -1,11 +1,13 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../models/setup_state.dart'; import '../models/optional_package.dart'; import '../providers/setup_provider.dart'; import '../services/package_service.dart'; +import '../utils/responsive.dart'; import '../widgets/progress_step.dart'; import 'onboarding_screen.dart'; import 'package_install_screen.dart'; @@ -46,114 +48,116 @@ class _SetupWizardScreenState extends State { builder: (context, provider, _) { final state = provider.state; - // Load package statuses once setup completes if (state.isComplete && _pkgStatuses.isEmpty) { _refreshPkgStatuses(); } return Padding( padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 32), - Image.asset( - 'assets/ic_launcher.png', - width: 64, - height: 64, - ), - const SizedBox(height: 16), - Text( - 'Setup OpenClaw', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, + child: Responsive.constrain( + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + Image.asset('assets/ic_launcher.png', + width: 64, height: 64), + const SizedBox(height: 16), + Text( + AppStrings.setupOpenClaw, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), ), - ), - const SizedBox(height: 8), - Text( - _started - ? 'Setting up the environment. This may take several minutes.' - : 'This will download Ubuntu, Node.js, and OpenClaw into a self-contained environment.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + const SizedBox(height: 8), + Text( + _started + ? AppStrings.setupRunning + : AppStrings.setupDescription, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - const SizedBox(height: 32), - Expanded( - child: _buildSteps(state, theme, isDark), - ), - if (state.hasError) ...[ - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 160), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), - const SizedBox(width: 8), - Expanded( - child: SingleChildScrollView( - child: Text( - state.error ?? 'Unknown error', - style: TextStyle(color: theme.colorScheme.onErrorContainer), + const SizedBox(height: 32), + Expanded(child: _buildSteps(state, theme, isDark)), + if (state.hasError) ...[ + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 160), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, + color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + child: Text( + AppStrings.translateError( + state.error ?? 'Unknown error'), + style: TextStyle( + color: + theme.colorScheme.onErrorContainer), + ), ), ), - ), - ], + ], + ), ), ), - ), - const SizedBox(height: 16), - ], - if (state.isComplete) - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: () => _goToOnboarding(context), - icon: const Icon(Icons.arrow_forward), - label: const Text('Configure API Keys'), + const SizedBox(height: 16), + ], + if (state.isComplete) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _goToOnboarding(context), + icon: const Icon(Icons.arrow_forward), + label: Text(AppStrings.configureApiKeys), + ), + ) + else if (!_started || state.hasError) + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: provider.isRunning + ? null + : () { + setState(() => _started = true); + provider.runSetup(); + }, + icon: const Icon(Icons.download), + label: Text(_started + ? AppStrings.retrySetup + : AppStrings.beginSetup), + ), ), - ) - else if (!_started || state.hasError) - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: provider.isRunning - ? null - : () { - setState(() => _started = true); - provider.runSetup(); - }, - icon: const Icon(Icons.download), - label: Text(_started ? 'Retry Setup' : 'Begin Setup'), + if (!_started) ...[ + const SizedBox(height: 8), + Center( + child: Text( + AppStrings.storageRequired, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), ), - ), - if (!_started) ...[ - const SizedBox(height: 8), + ], + const SizedBox(height: 16), Center( child: Text( - 'Requires ~500MB of storage and an internet connection', + 'by ${AppConstants.authorName} | ${AppConstants.orgName}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], - const SizedBox(height: 16), - Center( - child: Text( - 'by ${AppConstants.authorName} | ${AppConstants.orgName}', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - ], + ), ), ); }, @@ -164,11 +168,11 @@ class _SetupWizardScreenState extends State { Widget _buildSteps(SetupState state, ThemeData theme, bool isDark) { final steps = [ - (1, 'Download Ubuntu rootfs', SetupStep.downloadingRootfs), - (2, 'Extract rootfs', SetupStep.extractingRootfs), - (3, 'Install Node.js', SetupStep.installingNode), - (4, 'Install OpenClaw', SetupStep.installingOpenClaw), - (5, 'Configure Bionic Bypass', SetupStep.configuringBypass), + (1, AppStrings.downloadRootfs, SetupStep.downloadingRootfs), + (2, AppStrings.extractRootfs, SetupStep.extractingRootfs), + (3, AppStrings.installNode, SetupStep.installingNode), + (4, AppStrings.installOpenClaw, SetupStep.installingOpenClaw), + (5, AppStrings.configureBionicBypass, SetupStep.configuringBypass), ]; return ListView( @@ -183,16 +187,16 @@ class _SetupWizardScreenState extends State { progress: state.step == step ? state.progress : null, ), if (state.isComplete) ...[ - const ProgressStep( + ProgressStep( stepNumber: 6, - label: 'Setup complete!', + label: AppStrings.setupComplete, isComplete: true, ), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( - 'OPTIONAL PACKAGES', + AppStrings.optionalPackages, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, @@ -208,7 +212,8 @@ class _SetupWizardScreenState extends State { ); } - Widget _buildPackageTile(ThemeData theme, OptionalPackage package, bool isDark) { + Widget _buildPackageTile( + ThemeData theme, OptionalPackage package, bool isDark) { final installed = _pkgStatuses[package.id] ?? false; final iconBg = isDark ? AppColors.darkSurfaceAlt : const Color(0xFFF3F4F6); @@ -222,7 +227,8 @@ class _SetupWizardScreenState extends State { color: iconBg, borderRadius: BorderRadius.circular(10), ), - child: Icon(package.icon, color: theme.colorScheme.onSurfaceVariant, size: 22), + child: Icon(package.icon, + color: theme.colorScheme.onSurfaceVariant, size: 22), ), title: Row( children: [ @@ -231,17 +237,18 @@ class _SetupWizardScreenState extends State { if (installed) ...[ const SizedBox(width: 8), Container( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: AppColors.statusGreen.withAlpha(25), borderRadius: BorderRadius.circular(8), ), - child: Text('Installed', - style: theme.textTheme.labelSmall?.copyWith( - color: AppColors.statusGreen, - fontWeight: FontWeight.w600, - )), + child: Text( + AppStrings.installed, + style: theme.textTheme.labelSmall?.copyWith( + color: AppColors.statusGreen, + fontWeight: FontWeight.w600, + ), + ), ), ], ], @@ -251,7 +258,7 @@ class _SetupWizardScreenState extends State { ? const Icon(Icons.check_circle, color: AppColors.statusGreen) : OutlinedButton( onPressed: () => _installPackage(package), - child: const Text('Install'), + child: Text(AppStrings.install), ), ), ); diff --git a/flutter_app/lib/screens/splash_screen.dart b/flutter_app/lib/screens/splash_screen.dart index c6b5387..5639e84 100644 --- a/flutter_app/lib/screens/splash_screen.dart +++ b/flutter_app/lib/screens/splash_screen.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../services/native_bridge.dart'; import '../services/preferences_service.dart'; import 'setup_wizard_screen.dart'; @@ -18,7 +19,7 @@ class SplashScreen extends StatefulWidget { class _SplashScreenState extends State with SingleTickerProviderStateMixin { - String _status = 'Loading...'; + String _status = AppStrings.loading; late final AnimationController _fadeController; late final Animation _fadeAnimation; @@ -47,7 +48,7 @@ class _SplashScreenState extends State await Future.delayed(const Duration(milliseconds: 500)); try { - setState(() => _status = 'Checking setup status...'); + setState(() => _status = AppStrings.checkingSetup); // Ensure directories and resolv.conf exist on every app open. // Android may clear the files directory during update or reinstall (#40). @@ -126,17 +127,17 @@ class _SplashScreenState extends State final openclawOk = status['openclawInstalled'] == true; final bypassOk = status['bypassInstalled'] == true; - // Core rootfs must exist — can't repair without it + // Core rootfs must exist ?can't repair without it if (rootfsOk && bashOk) { // Regenerate bionic bypass if missing if (!bypassOk) { - setState(() => _status = 'Repairing bionic bypass...'); + setState(() => _status = AppStrings.repairingBypass); await NativeBridge.installBionicBypass(); } // Reinstall node if binary is missing (#97) if (!nodeOk) { - setState(() => _status = 'Reinstalling Node.js...'); + setState(() => _status = AppStrings.reinstallingNode); try { final arch = await NativeBridge.getArch(); final nodeTarUrl = AppConstants.getNodeTarballUrl(arch); @@ -150,7 +151,7 @@ class _SplashScreenState extends State // Reinstall openclaw if package.json is missing (#97) if (!openclawOk && nodeOk) { - setState(() => _status = 'Reinstalling OpenClaw...'); + setState(() => _status = AppStrings.reinstallingOpenClaw); try { const wrapper = '/root/.openclaw/node-wrapper.js'; const nodeRun = 'node $wrapper'; @@ -213,7 +214,7 @@ class _SplashScreenState extends State ), const SizedBox(height: 8), Text( - 'AI Gateway for Android', + AppStrings.aiGatewayForAndroid, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/flutter_app/lib/screens/ssh_screen.dart b/flutter_app/lib/screens/ssh_screen.dart index d14698d..ce95815 100644 --- a/flutter_app/lib/screens/ssh_screen.dart +++ b/flutter_app/lib/screens/ssh_screen.dart @@ -1,10 +1,10 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../app.dart'; import '../services/ssh_service.dart'; import 'packages_screen.dart'; -/// SSH server management screen — start/stop sshd, set password, show connection info. +/// SSH server management screen �?start/stop sshd, set password, show connection info. class SshScreen extends StatefulWidget { const SshScreen({super.key}); @@ -127,7 +127,7 @@ class _SshScreenState extends State { return Scaffold( appBar: AppBar(title: const Text('SSH Access')), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? Center(child: CircularProgressIndicator()) : _installed ? _buildInstalledView(theme, isDark) : _buildNotInstalledView(theme), diff --git a/flutter_app/lib/screens/terminal_screen.dart b/flutter_app/lib/screens/terminal_screen.dart index 25bdeb9..5ca89b0 100644 --- a/flutter_app/lib/screens/terminal_screen.dart +++ b/flutter_app/lib/screens/terminal_screen.dart @@ -1,10 +1,11 @@ -import 'dart:convert'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../l10n/app_strings.dart'; import '../services/native_bridge.dart'; import '../services/screenshot_service.dart'; import '../services/terminal_service.dart'; @@ -107,14 +108,14 @@ class _TerminalScreenState extends State { if (_ctrlNotifier.value && data.length == 1) { final code = data.toLowerCase().codeUnitAt(0); if (code >= 97 && code <= 122) { - // Ctrl+a-z → bytes 1-26 + // Ctrl+a-z �?bytes 1-26 _pty?.write(Uint8List.fromList([code - 96])); _ctrlNotifier.value = false; return; } } if (_altNotifier.value && data.isNotEmpty) { - // Alt+key → ESC + key + // Alt+key �?ESC + key _pty?.write(utf8.encode('\x1b$data')); _altNotifier.value = false; return; @@ -195,10 +196,10 @@ class _TerminalScreenState extends State { if (url != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Copied to clipboard'), + content: Text(AppStrings.copied), duration: const Duration(seconds: 3), action: SnackBarAction( - label: 'Open', + label: '打开', onPressed: () { final uri = Uri.tryParse(url); if (uri != null) { @@ -210,9 +211,9 @@ class _TerminalScreenState extends State { ); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Copied to clipboard'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(AppStrings.copied), + duration: const Duration(seconds: 1), ), ); } @@ -231,9 +232,9 @@ class _TerminalScreenState extends State { } } ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('No URL found in selection'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(AppStrings.noUrlFound), + duration: const Duration(seconds: 1), ), ); } @@ -251,8 +252,8 @@ class _TerminalScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(path != null - ? 'Screenshot saved: ${path.split('/').last}' - : 'Failed to capture screenshot'), + ? '截图已保�? ${path.split('/').last}' + : '截图失败'), ), ); } @@ -302,24 +303,24 @@ class _TerminalScreenState extends State { actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), + child: Text(AppStrings.cancel), ), TextButton( onPressed: () { Clipboard.setData(ClipboardData(text: url)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Link copied'), - duration: Duration(seconds: 1), + SnackBar( + content: Text(AppStrings.linkCopied), + duration: const Duration(seconds: 1), ), ); Navigator.pop(ctx, false); }, - child: const Text('Copy'), + child: Text(AppStrings.copy), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), - child: const Text('Open'), + child: const Text('打开'), ), ], ), @@ -334,31 +335,31 @@ class _TerminalScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Terminal'), + title: Text(AppStrings.terminal), actions: [ IconButton( icon: const Icon(Icons.camera_alt_outlined), - tooltip: 'Screenshot', + tooltip: AppStrings.screenshot, onPressed: _takeScreenshot, ), IconButton( icon: const Icon(Icons.copy), - tooltip: 'Copy', + tooltip: AppStrings.copy, onPressed: _copySelection, ), IconButton( icon: const Icon(Icons.open_in_browser), - tooltip: 'Open URL', + tooltip: AppStrings.openUrl, onPressed: _openSelection, ), IconButton( icon: const Icon(Icons.paste), - tooltip: 'Paste', + tooltip: AppStrings.paste, onPressed: _paste, ), IconButton( icon: const Icon(Icons.refresh), - tooltip: 'Restart', + tooltip: AppStrings.restart, onPressed: () { _pty?.kill(); setState(() { @@ -376,13 +377,13 @@ class _TerminalScreenState extends State { Widget _buildBody() { if (_loading) { - return const Center( + return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), - Text('Starting terminal...'), + Text(AppStrings.startingTerminal), ], ), ); @@ -416,7 +417,7 @@ class _TerminalScreenState extends State { _startPty(); }, icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(AppStrings.retry), ), ], ), diff --git a/flutter_app/lib/services/bootstrap_service.dart b/flutter_app/lib/services/bootstrap_service.dart index 9399cbb..b934bfe 100644 --- a/flutter_app/lib/services/bootstrap_service.dart +++ b/flutter_app/lib/services/bootstrap_service.dart @@ -139,7 +139,7 @@ class BootstrapService { )); // Blanket recursive chmod on all bin/lib directories. // Java tar extraction loses execute bits; dpkg needs tar, xz, - // gzip, rm, mv, etc. — easier to fix everything than enumerate. + // gzip, rm, mv, etc. ?easier to fix everything than enumerate. await NativeBridge.runInProot( 'chmod -R 755 /usr/bin /usr/sbin /bin /sbin ' '/usr/local/bin /usr/local/sbin 2>/dev/null; ' @@ -153,7 +153,7 @@ class BootstrapService { // --- Install base packages via apt-get (like Termux proot-distro) --- // Now that our proot matches Termux exactly (env -i, clean host env, // proper flags), dpkg works normally. No need for Java-side deb - // extraction — let dpkg+tar handle it inside proot like Termux does. + // extraction ?let dpkg+tar handle it inside proot like Termux does. _updateSetupNotification('Updating package lists...', progress: 48); onProgress(const SetupState( step: SetupStep.installingNode, @@ -173,7 +173,7 @@ class BootstrapService { // python3, make, g++: node-gyp needs these to compile native addons // (npm's bundled node-gyp runs as a JS module, not a spawned process, // so proot-compat.js spawn mock can't intercept it) - // dpkg extracts via tar inside proot — permissions are correct. + // dpkg extracts via tar inside proot ?permissions are correct. // Post-install scripts (update-ca-certificates) run automatically. // Pre-configure tzdata to avoid interactive continent/timezone prompt // (tzdata is a dependency of python3 and ignores DEBIAN_FRONTEND on @@ -188,7 +188,7 @@ class BootstrapService { ); // Git config (.gitconfig) is written by installBionicBypass() on the - // Java side — directly to $rootfsDir/root/.gitconfig — rewrites + // Java side ?directly to $rootfsDir/root/.gitconfig ?rewrites // SSH→HTTPS for npm git deps (no SSH keys in proot). // --- Install Node.js via binary tarball --- @@ -259,7 +259,7 @@ class BootstrapService { progress: 0.0, message: 'Installing OpenClaw (this may take a few minutes)...', )); - // Install openclaw — fork/exec works now with our Termux-matching proot. + // Install openclaw ?fork/exec works now with our Termux-matching proot. await NativeBridge.runInProot( '$nodeRun $npmCli install -g openclaw', timeout: 1800, @@ -273,7 +273,7 @@ class BootstrapService { )); // npm global install creates symlinks for bin entries, but symlinks // can fail silently in proot. Create shell wrappers from Java side - // (reads package.json directly from rootfs filesystem — no escaping). + // (reads package.json directly from rootfs filesystem ?no escaping). await NativeBridge.createBinWrappers('openclaw'); _updateSetupNotification('Verifying OpenClaw...', progress: 96); diff --git a/flutter_app/lib/services/capabilities/flash_capability.dart b/flutter_app/lib/services/capabilities/flash_capability.dart index 217f0b4..6f66dc6 100644 --- a/flutter_app/lib/services/capabilities/flash_capability.dart +++ b/flutter_app/lib/services/capabilities/flash_capability.dart @@ -34,7 +34,7 @@ class FlashCapability extends CapabilityHandler { if (_controller!.value.isInitialized && !_controller!.value.hasError) { return _controller!; } - // Controller is stale/errored — dispose and recreate + // Controller is stale/errored ?dispose and recreate try { _controller!.dispose(); } catch (_) {} _controller = null; } diff --git a/flutter_app/lib/services/gateway_service.dart b/flutter_app/lib/services/gateway_service.dart index a51097f..d97c1fe 100644 --- a/flutter_app/lib/services/gateway_service.dart +++ b/flutter_app/lib/services/gateway_service.dart @@ -196,7 +196,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } } - /// Repair openclaw.json on disk — fixes corrupted model entries and ensures + /// Repair openclaw.json on disk ?fixes corrupted model entries and ensures /// gateway.mode=local is set. Called on init() before any gateway start (#88). Future _repairConfigFile() async { try { @@ -210,7 +210,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); try { config = Map.from(jsonDecode(content) as Map); } catch (_) { - return; // Unparseable — _writeNodeAllowConfig will recreate it + return; // Unparseable ?_writeNodeAllowConfig will recreate it } bool modified = false; @@ -223,7 +223,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); modified = true; } - // Fix model entries: strings → objects (#83, #88) + // Fix model entries: strings ?objects (#83, #88) final models = config['models'] as Map?; if (models != null) { final providers = models['providers'] as Map?; @@ -272,7 +272,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); } /// Read the actual gateway auth token from openclaw.json config file (#74, #82). - /// This is the source of truth — more reliable than regex-scraping stdout. + /// This is the source of truth ?more reliable than regex-scraping stdout. Future _readTokenFromConfig() async { try { final raw = await NativeBridge.readRootfsFile('root/.openclaw/openclaw.json'); @@ -308,7 +308,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); )); try { - // Ensure directories exist — Android may have cleared them (#40). + // Ensure directories exist ?Android may have cleared them (#40). // Non-fatal: the GatewayService foreground service also creates them. try { await NativeBridge.setupDirs(); } catch (_) {} try { await NativeBridge.writeResolv(); } catch (_) {} @@ -373,7 +373,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); void _startHealthCheck() { _cancelAllTimers(); - // Delay the first health check by 30s — Node.js inside proot needs time to start. + // Delay the first health check by 30s ?Node.js inside proot needs time to start. // Use a Timer (not Future.delayed) so it can be cancelled on stop(). _initialDelayTimer = Timer(const Duration(seconds: 30), () { _initialDelayTimer = null; @@ -393,7 +393,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); .timeout(const Duration(seconds: 3)); if (response.statusCode < 500 && _state.status != GatewayStatus.running) { - // Read the actual token from openclaw.json — source of truth (#74, #82). + // Read the actual token from openclaw.json ?source of truth (#74, #82). // This ensures the displayed token always matches the gateway's config, // even if the stdout regex didn't capture it. String? configUrl = _state.dashboardUrl; diff --git a/flutter_app/lib/services/native_bridge.dart b/flutter_app/lib/services/native_bridge.dart index 70b0cbe..e9cf76c 100644 --- a/flutter_app/lib/services/native_bridge.dart +++ b/flutter_app/lib/services/native_bridge.dart @@ -35,7 +35,8 @@ class NativeBridge { } static Future runInProot(String command, {int timeout = 900}) async { - return await _channel.invokeMethod('runInProot', {'command': command, 'timeout': timeout}); + return await _channel + .invokeMethod('runInProot', {'command': command, 'timeout': timeout}); } static Future startGateway() async { @@ -67,11 +68,13 @@ class NativeBridge { } static Future extractNodeTarball(String tarPath) async { - return await _channel.invokeMethod('extractNodeTarball', {'tarPath': tarPath}); + return await _channel + .invokeMethod('extractNodeTarball', {'tarPath': tarPath}); } static Future createBinWrappers(String packageName) async { - return await _channel.invokeMethod('createBinWrappers', {'packageName': packageName}); + return await _channel + .invokeMethod('createBinWrappers', {'packageName': packageName}); } static Future startTerminalService() async { @@ -86,6 +89,18 @@ class NativeBridge { return await _channel.invokeMethod('isTerminalServiceRunning'); } + static Future startNineRouterService() async { + return await _channel.invokeMethod('startNineRouterService'); + } + + static Future stopNineRouterService() async { + return await _channel.invokeMethod('stopNineRouterService'); + } + + static Future isNineRouterServiceRunning() async { + return await _channel.invokeMethod('isNineRouterServiceRunning'); + } + static Future startNodeService() async { return await _channel.invokeMethod('startNodeService'); } @@ -99,7 +114,8 @@ class NativeBridge { } static Future updateNodeNotification(String text) async { - return await _channel.invokeMethod('updateNodeNotification', {'text': text}); + return await _channel + .invokeMethod('updateNodeNotification', {'text': text}); } static Future requestBatteryOptimization() async { @@ -114,24 +130,31 @@ class NativeBridge { return await _channel.invokeMethod('startSetupService'); } - static Future updateSetupNotification(String text, {int progress = -1}) async { - return await _channel.invokeMethod('updateSetupNotification', {'text': text, 'progress': progress}); + static Future updateSetupNotification(String text, + {int progress = -1}) async { + return await _channel.invokeMethod( + 'updateSetupNotification', {'text': text, 'progress': progress}); } static Future stopSetupService() async { return await _channel.invokeMethod('stopSetupService'); } - static Future showUrlNotification(String url, {String title = 'URL Detected'}) async { - return await _channel.invokeMethod('showUrlNotification', {'url': url, 'title': title}); + static Future showUrlNotification(String url, + {String title = 'URL Detected'}) async { + return await _channel + .invokeMethod('showUrlNotification', {'url': url, 'title': title}); } static Stream get gatewayLogStream { - return _eventChannel.receiveBroadcastStream().map((event) => event.toString()); + return _eventChannel + .receiveBroadcastStream() + .map((event) => event.toString()); } static Future requestScreenCapture(int durationMs) async { - return await _channel.invokeMethod('requestScreenCapture', {'durationMs': durationMs}); + return await _channel + .invokeMethod('requestScreenCapture', {'durationMs': durationMs}); } static Future stopScreenCapture() async { @@ -155,7 +178,8 @@ class NativeBridge { } static Future writeRootfsFile(String path, String content) async { - return await _channel.invokeMethod('writeRootfsFile', {'path': path, 'content': content}); + return await _channel + .invokeMethod('writeRootfsFile', {'path': path, 'content': content}); } // SSH Service @@ -185,6 +209,7 @@ class NativeBridge { } static Future setRootPassword(String password) async { - return await _channel.invokeMethod('setRootPassword', {'password': password}); + return await _channel + .invokeMethod('setRootPassword', {'password': password}); } } diff --git a/flutter_app/lib/services/node_service.dart b/flutter_app/lib/services/node_service.dart index 95c9256..465784c 100644 --- a/flutter_app/lib/services/node_service.dart +++ b/flutter_app/lib/services/node_service.dart @@ -263,7 +263,7 @@ class NodeService { )); _log('[NODE] Paired and connected'); - // Send capabilities advertisement — include both 'capabilities' (legacy) + // Send capabilities advertisement ?include both 'capabilities' (legacy) // and 'commands' (matching the connect frame format) so the gateway can // discover node commands regardless of which field it checks (#56). final capabilities = _capabilityHandlers.keys.toList(); diff --git a/flutter_app/lib/services/package_service.dart b/flutter_app/lib/services/package_service.dart index 50bd5e5..906e023 100644 --- a/flutter_app/lib/services/package_service.dart +++ b/flutter_app/lib/services/package_service.dart @@ -21,7 +21,7 @@ class PackageService { } /// Check installation status for all optional packages. - /// Returns a map of package id → installed boolean. + /// Returns a map of package id ?installed boolean. static Future> checkAllStatuses() async { final rootfs = await _getRootfsDir(); final statuses = {}; diff --git a/flutter_app/lib/services/provider_config_service.dart b/flutter_app/lib/services/provider_config_service.dart index 41f1cfc..8f090cd 100644 --- a/flutter_app/lib/services/provider_config_service.dart +++ b/flutter_app/lib/services/provider_config_service.dart @@ -39,7 +39,8 @@ class ProviderConfigService { final providers = {}; final modelsSection = config['models'] as Map?; if (modelsSection != null) { - final providerEntries = modelsSection['providers'] as Map?; + final providerEntries = + modelsSection['providers'] as Map?; if (providerEntries != null) { for (final entry in providerEntries.entries) { providers[entry.key] = entry.value; @@ -60,10 +61,12 @@ class ProviderConfigService { required AiProvider provider, required String apiKey, required String model, + String? baseUrl, }) async { + final effectiveBaseUrl = baseUrl ?? provider.baseUrl; final providerIdJson = jsonEncode(provider.id); final apiKeyJson = jsonEncode(apiKey); - final baseUrlJson = jsonEncode(provider.baseUrl); + final baseUrlJson = jsonEncode(effectiveBaseUrl); final modelJson = jsonEncode(model); // Build the provider object with the model as an object containing `id`, @@ -100,7 +103,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); await _saveConfigDirect( providerId: provider.id, apiKey: apiKey, - baseUrl: provider.baseUrl, + baseUrl: effectiveBaseUrl, model: model, ); } @@ -123,20 +126,28 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); // Start fresh } - // Merge provider entry — models must be objects with `id`, not bare strings (#83, #88). + // Merge provider entry �?models must be objects with `id`, not bare strings (#83, #88). config['models'] ??= {}; - (config['models'] as Map)['providers'] ??= {}; - ((config['models'] as Map)['providers'] as Map)[providerId] = { + (config['models'] as Map)['providers'] ??= + {}; + ((config['models'] as Map)['providers'] + as Map)[providerId] = { 'apiKey': apiKey, 'baseUrl': baseUrl, - 'models': [{'id': model}], + 'models': [ + {'id': model} + ], }; // Set active model config['agents'] ??= {}; - (config['agents'] as Map)['defaults'] ??= {}; - ((config['agents'] as Map)['defaults'] as Map)['model'] ??= {}; - (((config['agents'] as Map)['defaults'] as Map)['model'] as Map)['primary'] = model; + (config['agents'] as Map)['defaults'] ??= + {}; + ((config['agents'] as Map)['defaults'] + as Map)['model'] ??= {}; + (((config['agents'] as Map)['defaults'] + as Map)['model'] + as Map)['primary'] = model; // Ensure gateway.mode is set (#93, #90) config['gateway'] ??= {}; diff --git a/flutter_app/lib/services/terminal_service.dart b/flutter_app/lib/services/terminal_service.dart index a757957..b4f29b8 100644 --- a/flutter_app/lib/services/terminal_service.dart +++ b/flutter_app/lib/services/terminal_service.dart @@ -13,10 +13,10 @@ class TerminalService { '#1 SMP PREEMPT_DYNAMIC Fri, 10 Oct 2025 00:00:00 +0000'; /// Get paths and host-side proot environment variables. - /// Host env should ONLY contain proot-specific vars — guest env is + /// Host env should ONLY contain proot-specific vars ?guest env is /// set via `env -i` inside the command, matching proot-distro. /// - /// Also ensures directories and resolv.conf exist — Android may clear + /// Also ensures directories and resolv.conf exist ?Android may clear /// them during an app update (#40). Every screen that uses proot calls /// this method, so it's the single place to guarantee the files exist. static Future> getProotShellConfig() async { @@ -64,7 +64,7 @@ class TerminalService { 'libDir': libDir, 'nativeLibDir': nativeLibDir, 'storageGranted': storageGranted.toString(), - // Host-side proot env — ONLY proot-specific vars. + // Host-side proot env ?ONLY proot-specific vars. // Do NOT set PROOT_NO_SECCOMP (proot-distro doesn't set it). // Do NOT set HOME/TERM/LANG here (those go in guest env via env -i). 'PROOT_TMP_DIR': tmpDir, @@ -76,7 +76,7 @@ class TerminalService { /// Build proot arguments matching ProcessManager.kt's gateway mode /// (proot-distro command_login). Uses `env -i` for a clean guest - /// environment — prevents Android JVM vars from leaking into proot. + /// environment ?prevents Android JVM vars from leaking into proot. static List buildProotArgs(Map config, {int columns = 80, int rows = 24}) { final procFakes = '${config['configDir']}/proc_fakes'; @@ -161,7 +161,7 @@ class TerminalService { } /// Host-side environment map for Pty.start(). - /// Only proot-specific vars — no guest vars (those are in env -i). + /// Only proot-specific vars ?no guest vars (those are in env -i). static Map buildHostEnv(Map config) { return { 'PROOT_TMP_DIR': config['PROOT_TMP_DIR']!, diff --git a/flutter_app/lib/utils/log_parser.dart b/flutter_app/lib/utils/log_parser.dart new file mode 100644 index 0000000..71fd5b8 --- /dev/null +++ b/flutter_app/lib/utils/log_parser.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import '../app.dart'; +import '../l10n/app_strings.dart'; + +enum LogLevel { info, warn, error, success, debug, system } + +class ParsedLog { + final LogLevel level; + final String time; + final String rawMessage; + final String friendlyMessage; // 中文友好说明 + final String? detail; // 可选的技术细节 + final IconData icon; + final Color color; + + const ParsedLog({ + required this.level, + required this.time, + required this.rawMessage, + required this.friendlyMessage, + this.detail, + required this.icon, + required this.color, + }); +} + +class LogParser { + static ParsedLog parse(String raw, ThemeData theme) { + final time = _extractTime(raw); + final msg = _stripTime(raw).trim(); + + // 错误级别 + if (_isError(msg)) { + return ParsedLog( + level: LogLevel.error, + time: time, + rawMessage: raw, + friendlyMessage: _translateError(msg), + detail: msg, + icon: Icons.error_outline, + color: theme.colorScheme.error, + ); + } + + // 警告级别 + if (_isWarn(msg)) { + return ParsedLog( + level: LogLevel.warn, + time: time, + rawMessage: raw, + friendlyMessage: _translateWarn(msg), + detail: msg, + icon: Icons.warning_amber_outlined, + color: AppColors.statusAmber, + ); + } + + // 成功/健康 + if (_isSuccess(msg)) { + return ParsedLog( + level: LogLevel.success, + time: time, + rawMessage: raw, + friendlyMessage: _translateSuccess(msg), + icon: Icons.check_circle_outline, + color: AppColors.statusGreen, + ); + } + + // 系统/启动 + if (_isSystem(msg)) { + return ParsedLog( + level: LogLevel.system, + time: time, + rawMessage: raw, + friendlyMessage: _translateSystem(msg), + icon: Icons.settings_outlined, + color: AppColors.mutedText, + ); + } + + // 普通信息 + return ParsedLog( + level: LogLevel.info, + time: time, + rawMessage: raw, + friendlyMessage: _translateInfo(msg), + icon: Icons.info_outline, + color: AppColors.mutedText, + ); + } + + static String _extractTime(String raw) { + // ISO 时间格式: 2024-01-01T12:00:00.000Z + final iso = RegExp(r'\d{4}-\d{2}-\d{2}T(\d{2}:\d{2}:\d{2})'); + final m = iso.firstMatch(raw); + if (m != null) return m.group(1)!; + // HH:MM:SS 格式 + final hms = RegExp(r'(\d{2}:\d{2}:\d{2})'); + final m2 = hms.firstMatch(raw); + if (m2 != null) return m2.group(1)!; + return ''; + } + + static String _stripTime(String raw) { + return raw + .replaceAll( + RegExp(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z?\s*'), '') + .replaceAll(RegExp(r'\[\w+\]\s*'), '') + .trim(); + } + + static bool _isError(String msg) => + msg.contains('[ERR]') || + msg.contains('ERROR') || + msg.contains('error') || + msg.contains('failed') || + msg.contains('Failed') || + msg.contains('exception') || + msg.contains('ECONNREFUSED') || + msg.contains('ENOENT'); + + static bool _isWarn(String msg) => + msg.contains('[WARN]') || + msg.contains('WARNING') || + msg.contains('warn') || + msg.contains('deprecated'); + + static bool _isSuccess(String msg) => + msg.contains('healthy') || + msg.contains('ready') || + msg.contains('started') || + msg.contains('connected') || + msg.contains('complete') || + msg.contains('success') || + msg.contains('Gateway is healthy') || + msg.contains('Running'); + + static bool _isSystem(String msg) => + msg.contains('Starting') || + msg.contains('Stopping') || + msg.contains('Auto-start') || + msg.contains('reconnect') || + msg.contains('detected') || + msg.contains('process'); + + static String _translateError(String msg) { + if (!AppStrings.isChinese) return msg; + if (msg.contains('ECONNREFUSED') || msg.contains('Connection refused')) { + return '连接被拒绝:网关服务未响应,请检查是否已启动'; + } + if (msg.contains('ENOENT') || msg.contains('No such file')) { + return '文件不存在:安装可能不完整,建议重新安装'; + } + if (msg.contains('timeout') || msg.contains('Timeout')) { + return '连接超时:网络或服务响应过慢'; + } + if (msg.contains('Failed to start')) { + return '启动失败:请检查环境是否正确安装'; + } + if (msg.contains('permission') || msg.contains('Permission')) { + return '权限不足:请检查应用权限设置'; + } + if (msg.contains('port') || msg.contains('Port')) { + return '端口错误:18789 端口可能被占用'; + } + if (msg.contains('memory') || msg.contains('Memory')) { + return '内存不足:请关闭其他应用后重试'; + } + return '发生错误:$msg'; + } + + static String _translateWarn(String msg) { + if (!AppStrings.isChinese) return msg; + if (msg.contains('deprecated')) return '功能已过时,建议更新版本'; + if (msg.contains('retry') || msg.contains('Retry')) return '正在重试连接...'; + if (msg.contains('slow') || msg.contains('Slow')) return '响应较慢,请耐心等待'; + if (msg.contains('not running')) return '网关进程未运行'; + return '警告:$msg'; + } + + static String _translateSuccess(String msg) { + if (!AppStrings.isChinese) return msg; + if (msg.contains('healthy') || msg.contains('Gateway is healthy')) { + return '✓ 网关运行正常,服务健康'; + } + if (msg.contains('started') || msg.contains('Starting gateway')) { + return '网关正在启动中...'; + } + if (msg.contains('connected') || msg.contains('reconnecting')) { + return '已连接到网关服务'; + } + if (msg.contains('complete') || msg.contains('success')) { + return '操作成功完成'; + } + if (msg.contains('Running') || msg.contains('ready')) { + return '服务已就绪,可以开始使用'; + } + return msg; + } + + static String _translateSystem(String msg) { + if (!AppStrings.isChinese) return msg; + if (msg.contains('Auto-starting')) return '自动启动网关...'; + if (msg.contains('Starting gateway')) return '正在启动网关服务...'; + if (msg.contains('Stopping') || msg.contains('stopped')) return '网关已停止'; + if (msg.contains('detected') || msg.contains('reconnecting')) { + return '检测到网关进程,正在重新连接...'; + } + if (msg.contains('waiting')) return '等待网关启动,请稍候...'; + if (msg.contains('process not running')) return '网关进程已退出'; + return msg; + } + + static String _translateInfo(String msg) { + if (!AppStrings.isChinese) return msg; + if (msg.contains('token') || msg.contains('Token')) { + return '获取到访问令牌,控制台已就绪'; + } + if (msg.contains('Dashboard')) return '控制台地址已更新'; + if (msg.contains('health') || msg.contains('Health')) + return '正在检查服务健康状态...'; + if (msg.contains('log') || msg.contains('Log')) return '日志记录中'; + if (msg.isEmpty) return '系统消息'; + return msg; + } +} diff --git a/flutter_app/lib/utils/responsive.dart b/flutter_app/lib/utils/responsive.dart new file mode 100644 index 0000000..ca6508f --- /dev/null +++ b/flutter_app/lib/utils/responsive.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class Responsive { + static const double tabletBreakpoint = 600.0; + static const double maxContentWidth = 720.0; + + static bool isTablet(BuildContext context) => + MediaQuery.of(context).size.width >= tabletBreakpoint; + + /// 平板上限制内容最大宽度并居中,手机上全宽 + static Widget constrain(Widget child) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxContentWidth), + child: child, + ), + ); + } +} diff --git a/flutter_app/lib/widgets/gateway_controls.dart b/flutter_app/lib/widgets/gateway_controls.dart index 52c524b..c260cba 100644 --- a/flutter_app/lib/widgets/gateway_controls.dart +++ b/flutter_app/lib/widgets/gateway_controls.dart @@ -1,8 +1,9 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../app.dart'; import '../constants.dart'; +import '../l10n/app_strings.dart'; import '../models/gateway_state.dart'; import '../providers/gateway_provider.dart'; import '../screens/logs_screen.dart'; @@ -29,7 +30,7 @@ class GatewayControls extends StatelessWidget { children: [ Expanded( child: Text( - 'Gateway', + AppStrings.gateway, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), @@ -66,21 +67,22 @@ class GatewayControls extends StatelessWidget { ), IconButton( icon: const Icon(Icons.copy, size: 18), - tooltip: 'Copy URL', + tooltip: AppStrings.copyUrl, onPressed: () { - final url = state.dashboardUrl ?? AppConstants.gatewayUrl; + final url = + state.dashboardUrl ?? AppConstants.gatewayUrl; Clipboard.setData(ClipboardData(text: url)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('URL copied to clipboard'), - duration: Duration(seconds: 2), + SnackBar( + content: Text(AppStrings.urlCopied), + duration: const Duration(seconds: 2), ), ); }, ), IconButton( icon: const Icon(Icons.open_in_new, size: 18), - tooltip: 'Open dashboard', + tooltip: AppStrings.openDashboard, onPressed: () { Navigator.of(context).push( MaterialPageRoute( @@ -108,20 +110,21 @@ class GatewayControls extends StatelessWidget { FilledButton.icon( onPressed: () => provider.start(), icon: const Icon(Icons.play_arrow), - label: const Text('Start Gateway'), + label: Text(AppStrings.startGateway), ), - if (state.isRunning || state.status == GatewayStatus.starting) + if (state.isRunning || + state.status == GatewayStatus.starting) OutlinedButton.icon( onPressed: () => provider.stop(), icon: const Icon(Icons.stop), - label: const Text('Stop Gateway'), + label: Text(AppStrings.stopGateway), ), OutlinedButton.icon( onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const LogsScreen()), ), icon: const Icon(Icons.article_outlined), - label: const Text('View Logs'), + label: Text(AppStrings.viewLogs), ), ], ), @@ -141,19 +144,19 @@ class GatewayControls extends StatelessWidget { switch (status) { case GatewayStatus.running: color = AppColors.statusGreen; - label = 'Running'; + label = AppStrings.gatewayRunning; icon = Icons.check_circle_outline; case GatewayStatus.starting: color = AppColors.statusAmber; - label = 'Starting'; + label = AppStrings.gatewayStarting; icon = Icons.hourglass_top; case GatewayStatus.error: color = AppColors.statusRed; - label = 'Error'; + label = AppStrings.gatewayError; icon = Icons.error_outline; case GatewayStatus.stopped: color = AppColors.statusGrey; - label = 'Stopped'; + label = AppStrings.gatewayStopped; icon = Icons.circle_outlined; } diff --git a/flutter_app/lib/widgets/node_controls.dart b/flutter_app/lib/widgets/node_controls.dart index 4a2b719..ea6d6f2 100644 --- a/flutter_app/lib/widgets/node_controls.dart +++ b/flutter_app/lib/widgets/node_controls.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../l10n/app_strings.dart'; import '../models/node_state.dart'; import '../providers/node_provider.dart'; import '../screens/node_screen.dart'; @@ -26,7 +27,7 @@ class NodeControls extends StatelessWidget { children: [ Expanded( child: Text( - 'Node', + AppStrings.node, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), @@ -78,20 +79,20 @@ class NodeControls extends StatelessWidget { FilledButton.icon( onPressed: () => provider.enable(), icon: const Icon(Icons.power_settings_new), - label: const Text('Enable Node'), + label: Text(AppStrings.enableNode), ), if (!state.isDisabled) ...[ OutlinedButton.icon( onPressed: () => provider.disable(), icon: const Icon(Icons.stop), - label: const Text('Disable Node'), + label: Text(AppStrings.disableNode), ), if (state.status == NodeStatus.error || state.status == NodeStatus.disconnected) OutlinedButton.icon( onPressed: () => provider.reconnect(), icon: const Icon(Icons.refresh), - label: const Text('Reconnect'), + label: Text(AppStrings.reconnect), ), ], OutlinedButton.icon( @@ -99,7 +100,7 @@ class NodeControls extends StatelessWidget { MaterialPageRoute(builder: (_) => const NodeScreen()), ), icon: const Icon(Icons.settings), - label: const Text('Configure'), + label: Text(AppStrings.nodeConfigure), ), ], ), @@ -119,25 +120,25 @@ class NodeControls extends StatelessWidget { switch (status) { case NodeStatus.paired: color = AppColors.statusGreen; - label = 'Paired'; + label = AppStrings.nodePaired; icon = Icons.check_circle_outline; case NodeStatus.connecting: case NodeStatus.challenging: case NodeStatus.pairing: color = AppColors.statusAmber; - label = 'Connecting'; + label = AppStrings.nodeConnecting; icon = Icons.hourglass_top; case NodeStatus.error: color = AppColors.statusRed; - label = 'Error'; + label = AppStrings.gatewayError; icon = Icons.error_outline; case NodeStatus.disabled: color = AppColors.statusGrey; - label = 'Disabled'; + label = AppStrings.nodeDisabled; icon = Icons.circle_outlined; case NodeStatus.disconnected: color = AppColors.statusGrey; - label = 'Disconnected'; + label = AppStrings.nodeDisconnected; icon = Icons.link_off; } diff --git a/flutter_app/lib/widgets/progress_step.dart b/flutter_app/lib/widgets/progress_step.dart index adc6593..5962742 100644 --- a/flutter_app/lib/widgets/progress_step.dart +++ b/flutter_app/lib/widgets/progress_step.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import '../app.dart'; class ProgressStep extends StatelessWidget { @@ -9,7 +9,7 @@ class ProgressStep extends StatelessWidget { final bool hasError; final double? progress; - const ProgressStep({ + ProgressStep({ super.key, required this.stepNumber, required this.label, diff --git a/flutter_app/lib/widgets/terminal_toolbar.dart b/flutter_app/lib/widgets/terminal_toolbar.dart index 8675cef..e56714d 100644 --- a/flutter_app/lib/widgets/terminal_toolbar.dart +++ b/flutter_app/lib/widgets/terminal_toolbar.dart @@ -54,7 +54,7 @@ class _TerminalToolbarState extends State { if (_ctrlActive) { widget.ctrlNotifier.value = false; - // Ctrl+a-z → bytes 1-26 + // Ctrl+a-z ?bytes 1-26 if (data.length == 1) { final code = data.toLowerCase().codeUnitAt(0); if (code >= 97 && code <= 122) { diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock new file mode 100644 index 0000000..e5561f9 --- /dev/null +++ b/flutter_app/pubspec.lock @@ -0,0 +1,954 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + bluez: + dependency: transitive + description: + name: bluez + sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545" + url: "https://pub.dev" + source: hosted + version: "0.8.3" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.dev" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.dev" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.dev" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63" + url: "https://pub.dev" + source: hosted + version: "2.12.0" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7" + url: "https://pub.dev" + source: hosted + version: "0.3.5+3" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_blue_plus: + dependency: "direct main" + description: + name: flutter_blue_plus + sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed" + url: "https://pub.dev" + source: hosted + version: "1.36.8" + flutter_blue_plus_android: + dependency: transitive + description: + name: flutter_blue_plus_android + sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d" + url: "https://pub.dev" + source: hosted + version: "7.0.4" + flutter_blue_plus_darwin: + dependency: transitive + description: + name: flutter_blue_plus_darwin + sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8" + url: "https://pub.dev" + source: hosted + version: "7.0.3" + flutter_blue_plus_linux: + dependency: transitive + description: + name: flutter_blue_plus_linux + sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347" + url: "https://pub.dev" + source: hosted + version: "7.0.3" + flutter_blue_plus_platform_interface: + dependency: transitive + description: + name: flutter_blue_plus_platform_interface + sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + flutter_blue_plus_web: + dependency: transitive + description: + name: flutter_blue_plus_web + sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99 + url: "https://pub.dev" + source: hosted + version: "7.0.2" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" + flutter_pty: + dependency: "direct main" + description: + name: flutter_pty + sha256: c2f3b3160b519ac820fa3f6ef175361f2dfc52c557465643589542e9f229ad66 + url: "https://pub.dev" + source: hosted + version: "0.4.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "149876cc5207a0f5daf4fdd3bfcf0a0f27258b3fe95108fa084f527ad0568f1b" + url: "https://pub.dev" + source: hosted + version: "12.0.0" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + url: "https://pub.dev" + source: hosted + version: "2.2.23" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.dev" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.dev" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" + url: "https://pub.dev" + source: hosted + version: "6.3.29" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + usb_serial: + dependency: "direct main" + description: + name: usb_serial + sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76" + url: "https://pub.dev" + source: hosted + version: "4.10.15" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83 + url: "https://pub.dev" + source: hosted + version: "3.24.2" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + xterm: + dependency: "direct main" + description: + name: xterm + sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + zmodem: + dependency: transitive + description: + name: zmodem + sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" + url: "https://pub.dev" + source: hosted + version: "0.0.6" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/scripts/fetch-proot-windows.ps1 b/scripts/fetch-proot-windows.ps1 new file mode 100644 index 0000000..dcb28d9 --- /dev/null +++ b/scripts/fetch-proot-windows.ps1 @@ -0,0 +1,98 @@ +# 从 Termux 仓库下载 proot 和 libtalloc,解压放到 jniLibs +# 在 Windows PowerShell 中运行 + +$TERMUX_REPO = "https://packages.termux.dev/apt/termux-main" +$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path +$JNILIBS_DIR = "$SCRIPT_DIR\..\flutter_app\android\app\jniLibs" +$TMP_DIR = "$env:TEMP\proot-fetch-$(Get-Random)" +New-Item -ItemType Directory -Force -Path $TMP_DIR | Out-Null + +$env:HTTPS_PROXY = "http://127.0.0.1:7897" +$env:HTTP_PROXY = "http://127.0.0.1:7897" + +function Get-TermuxPkgUrl($arch, $pkgName) { + $indexUrl = "$TERMUX_REPO/dists/stable/main/binary-$arch/Packages" + $index = (Invoke-WebRequest -Uri $indexUrl -UseBasicParsing -Proxy "http://127.0.0.1:7897").Content + $lines = $index -split "`n" + $inPkg = $false + foreach ($line in $lines) { + if ($line -match "^Package: $pkgName$") { $inPkg = $true } + if ($inPkg -and $line -match "^Filename: (.+)") { + return "$TERMUX_REPO/$($Matches[1].Trim())" + } + if ($inPkg -and $line -eq "") { $inPkg = $false } + } + return $null +} + +function Extract-Deb($debPath, $outDir) { + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + # .deb 是 ar 格式,用 7-Zip 或手动解析 + # 尝试用 7-Zip + $7z = "C:\Program Files\7-Zip\7z.exe" + if (Test-Path $7z) { + & $7z e $debPath -o"$outDir" "data.tar*" -y | Out-Null + $dataTar = Get-ChildItem $outDir -Filter "data.tar*" | Select-Object -First 1 + if ($dataTar) { + & $7z x $dataTar.FullName -o"$outDir\data" -y | Out-Null + } + } else { + Write-Host " Please install 7-Zip: https://www.7-zip.org/" -ForegroundColor Red + exit 1 + } +} + +$abis = @( + @{ jni = "arm64-v8a"; deb = "aarch64"; find = "aarch64" }, + @{ jni = "armeabi-v7a"; deb = "arm"; find = "arm" }, + @{ jni = "x86_64"; deb = "x86_64"; find = "x86_64" } +) + +foreach ($abi in $abis) { + $jniAbi = $abi.jni + $debArch = $abi.deb + $outDir = "$JNILIBS_DIR\$jniAbi" + New-Item -ItemType Directory -Force -Path $outDir | Out-Null + + Write-Host "[$jniAbi] Fetching proot..." -ForegroundColor Cyan + $prootUrl = Get-TermuxPkgUrl $debArch "proot" + if (-not $prootUrl) { Write-Host " proot package not found" -ForegroundColor Red; continue } + + $prootDeb = "$TMP_DIR\proot-$debArch.deb" + Invoke-WebRequest -Uri $prootUrl -OutFile $prootDeb -UseBasicParsing -Proxy "http://127.0.0.1:7897" + Extract-Deb $prootDeb "$TMP_DIR\proot-$debArch" + + Write-Host "[$jniAbi] Fetching libtalloc..." -ForegroundColor Cyan + $tallocUrl = Get-TermuxPkgUrl $debArch "libtalloc" + if (-not $tallocUrl) { Write-Host " libtalloc package not found" -ForegroundColor Red; continue } + + $tallocDeb = "$TMP_DIR\talloc-$debArch.deb" + Invoke-WebRequest -Uri $tallocUrl -OutFile $tallocDeb -UseBasicParsing -Proxy "http://127.0.0.1:7897" + Extract-Deb $tallocDeb "$TMP_DIR\talloc-$debArch" + + # 复制 proot 二进制 + $prootBin = Get-ChildItem "$TMP_DIR\proot-$debArch\data" -Recurse -Filter "proot" | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1 + if ($prootBin) { + Copy-Item $prootBin.FullName "$outDir\libproot.so" -Force + Write-Host " libproot.so OK ($([math]::Round($prootBin.Length/1KB))KB)" + } + + # 复制 loader + $loader = Get-ChildItem "$TMP_DIR\proot-$debArch\data" -Recurse -Filter "loader" | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1 + if ($loader) { + Copy-Item $loader.FullName "$outDir\libprootloader.so" -Force + Write-Host " libprootloader.so OK" + } + + # 复制 libtalloc + $talloc = Get-ChildItem "$TMP_DIR\talloc-$debArch\data" -Recurse | Where-Object { $_.Name -like "libtalloc.so*" -and -not $_.PSIsContainer } | Select-Object -First 1 + if ($talloc) { + Copy-Item $talloc.FullName "$outDir\libtalloc.so" -Force + Write-Host " libtalloc.so OK" + } + + Write-Host "[$jniAbi] Done" -ForegroundColor Green +} + +Remove-Item -Recurse -Force $TMP_DIR -ErrorAction SilentlyContinue +Write-Host "`nAll done! Now rebuild the APK." -ForegroundColor Green diff --git "a/\346\225\264\345\220\210\346\226\271\346\241\210.md" "b/\346\225\264\345\220\210\346\226\271\346\241\210.md" new file mode 100644 index 0000000..9db0482 --- /dev/null +++ "b/\346\225\264\345\220\210\346\226\271\346\241\210.md" @@ -0,0 +1,32 @@ +现在全局都清楚了,整合方案非常明确。 + +这个 UI 是 React + TypeScript 的 Web 应用,构建后是一个单文件 HTML。 + +关键信息: + +CLIProxyAPI 从 v6.0.19 起已经内置这个 UI,访问 http://localhost:端口/management.html 就能用 +UI 已经支持中文、响应式移动端布局 +支持 OAuth 登录、多账号管理、配额监控、日志查看等所有功能 +所以整合到 openclaw-termux 的方案非常简单,不需要重写任何东西: + + +proot Ubuntu 里同时运行: + ├── CLIProxyAPI(Go 二进制,监听 18790) + │ └── 内置 /management.html(Web UI) + └── OpenClaw(Node.js,监听 18789) + └── 配置 API base URL → 127.0.0.1:18790 + +Flutter App 加两个入口: + ├── 现有的 Web Dashboard(OpenClaw,18789) + └── 新增 CLIProxy 管理(WebView → 18790/management.html) + + + +分步骤完成: + +添加 flutter_localizations + intl 依赖 +创建中文翻译文件 +配置 app.dart 支持中文【方案改成了用 PlatformDispatcher.instance.locale.languageCode 在运行时读取系统语言,AppStrings 里每个字符串都是 getter,手机系统设置中文就返回中文,设置英文(或其他语言)就返回英文。不需要 ARB 代码生成,也不需要 flutter gen-l10n 命令,flutter build apk 直接就能跑。】 +平板响应式布局(dashboard_screen.dart + 全局 ConstrainedBox) +新增 CLIProxyAPI 管理页面(WebView) +Dashboard 加入口 \ No newline at end of file diff --git "a/\347\273\210\347\253\257\346\227\245\345\277\227.md" "b/\347\273\210\347\253\257\346\227\245\345\277\227.md" new file mode 100644 index 0000000..e69de29