Explorar o código

fix: 更新首页连接按钮效果、增加metrics日志上传、适配浅色模式

lilu hai 3 meses
pai
achega
63e0a0182a
Modificáronse 56 ficheiros con 2065 adicións e 323 borrados
  1. 141 165
      android/app/src/main/kotlin/app/xixi/nomo/XRayService.kt
  2. BIN=BIN
      assets/images/round/connected_round.png
  3. BIN=BIN
      assets/images/round/connecting_round.png
  4. BIN=BIN
      assets/images/round/dark_connected.png
  5. BIN=BIN
      assets/images/round/dark_connecting.png
  6. BIN=BIN
      assets/images/round/dark_disconnected.png
  7. BIN=BIN
      assets/images/round/disconnected_round.png
  8. BIN=BIN
      assets/images/round/light_connected.png
  9. BIN=BIN
      assets/images/round/light_connecting.png
  10. BIN=BIN
      assets/images/round/light_disconnected.png
  11. 6 0
      assets/vectors/settings_theme.svg
  12. 23 34
      lib/app/app.dart
  13. 15 6
      lib/app/constants/assets.dart
  14. 3 1
      lib/app/constants/enums.dart
  15. 3 0
      lib/app/constants/sp_keys.dart
  16. 327 2
      lib/app/controllers/api_controller.dart
  17. 165 14
      lib/app/controllers/core_controller.dart
  18. 6 0
      lib/app/controllers/windows_core_api.dart
  19. 0 55
      lib/app/data/models/disconnect_domain.dart
  20. 10 2
      lib/app/data/sp/ix_sp.dart
  21. 11 5
      lib/app/modules/home/controllers/home_controller.dart
  22. 18 2
      lib/app/modules/home/views/home_view.dart
  23. 18 3
      lib/app/modules/home/widgets/connection_button.dart
  24. 59 20
      lib/app/modules/home/widgets/connection_round_button.dart
  25. 592 0
      lib/app/modules/home/widgets/connection_theme_button.dart
  26. 5 0
      lib/app/modules/home/widgets/menu_list.dart
  27. 36 0
      lib/app/modules/setting/views/setting_view.dart
  28. 10 0
      lib/app/modules/theme/bindings/theme_binding.dart
  29. 75 0
      lib/app/modules/theme/controllers/theme_controller.dart
  30. 112 0
      lib/app/modules/theme/views/theme_view.dart
  31. 9 0
      lib/app/routes/app_pages.dart
  32. 2 0
      lib/app/routes/app_routes.dart
  33. 2 2
      lib/config/theme/dark_theme_colors.dart
  34. 9 9
      lib/config/theme/light_theme_colors.dart
  35. 1 0
      lib/config/translations/ar_AR/ar_ar_translation.dart
  36. 1 0
      lib/config/translations/de_DE/de_de_translation.dart
  37. 7 0
      lib/config/translations/en_US/en_us_translation.dart
  38. 1 0
      lib/config/translations/es_ES/es_es_translation.dart
  39. 1 0
      lib/config/translations/fa_IR/fa_ir_translation.dart
  40. 1 0
      lib/config/translations/fr_FR/fr_fr_translation.dart
  41. 1 0
      lib/config/translations/hi_IN/hi_in_translation.dart
  42. 1 0
      lib/config/translations/id_ID/id_id_translation.dart
  43. 1 0
      lib/config/translations/ja_JP/ja_jp_translation.dart
  44. 1 0
      lib/config/translations/ko_KR/ko_kr_translation.dart
  45. 1 0
      lib/config/translations/my_MM/my_mm_translation.dart
  46. 1 0
      lib/config/translations/pt_BR/pt_br_translation.dart
  47. 1 0
      lib/config/translations/ru_RU/ru_ru_translation.dart
  48. 6 0
      lib/config/translations/strings_enum.dart
  49. 1 0
      lib/config/translations/th_TH/th_th_translation.dart
  50. 1 0
      lib/config/translations/tk_TM/tk_tm_translation.dart
  51. 1 0
      lib/config/translations/tl_PH/tl_ph_translation.dart
  52. 1 0
      lib/config/translations/tr_TR/tr_tr_translation.dart
  53. 1 0
      lib/config/translations/vi_VN/vi_vn_translation.dart
  54. 7 0
      lib/config/translations/zh_TW/zh_tw_translation.dart
  55. 2 3
      lib/utils/awesome_notifications_helper.dart
  56. 369 0
      lib/utils/gzip_manager.dart

+ 141 - 165
android/app/src/main/kotlin/app/xixi/nomo/XRayService.kt

@@ -1,7 +1,6 @@
 package app.xixi.nomo
 
 import android.app.Notification
-import android.app.NotificationManager
 import android.content.BroadcastReceiver
 import android.content.Context
 import android.content.Intent
@@ -19,16 +18,18 @@ import app.xixi.nomo.XRayApi.Companion.VPN_STATE_CONNECTED
 import app.xixi.nomo.XRayApi.Companion.VPN_STATE_ERROR
 import app.xixi.nomo.XRayApi.Companion.VPN_STATE_IDLE
 import com.google.gson.Gson
-import com.tekartik.sqflite.Constant
 import go.Seq
 import ixvpn_mobile.Ixvpn_mobile
 import ixvpn_mobile.ProxyConnectorHandler
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
 import org.json.JSONObject
 import win.fkey.netboost.service.NetworkReporter
 
@@ -38,27 +39,32 @@ class XRayService : LifecycleVpnService() {
     private var tunFd: ParcelFileDescriptor? = null
     private val mutex = Mutex()
 
+    @Volatile
     private var vpnStatus = VPN_STATE_IDLE
 
     // 计时功能相关变量
     private var timerJob: Job? = null
-    private var timerMode = TIMER_MODE_NORMAL // 0: 普通计时, 1: 倒计时
-    private var timerBaseRealtime = 0L // 计时开始的真实时间(elapsedRealtime)
-    private var timerInitialTime = 0L // 初始时间(对于倒计时是总时长,对于正常计时是0或已用时间)
-    private var peekElapsedSeconds = 0L // Peek 上报计数器(秒)
+    private var timerMode = TIMER_MODE_NORMAL
+    private var timerBaseRealtime = 0L
+    private var timerInitialTime = 0L
+    private var peekElapsedSeconds = 0L
 
     private var xrayConfig: XrayConfig? = null
-
     private var remainTime = 0L
 
     // 屏幕状态跟踪
+    @Volatile
     private var isScreenOn = true
     private var screenReceiver: BroadcastReceiver? = null
 
     // 应用前后台状态跟踪
+    @Volatile
     private var isAppForeground = true
     private var foregroundReceiver: BroadcastReceiver? = null
 
+    // 服务状态(实例级别)
+    private var serviceStatus: ServiceStatus = ServiceStatus.Disconnected
+
     companion object {
         private val TAG: String = XRayService::class.java.simpleName
 
@@ -69,7 +75,15 @@ class XRayService : LifecycleVpnService() {
         const val TIMER_MODE_NORMAL = 0  // 普通计时(正计时)
         const val TIMER_MODE_COUNTDOWN = 1  // 倒计时
 
-        private var serviceStatus: ServiceStatus = ServiceStatus.Disconnected
+        // VPN 网络配置常量
+        private const val TUN_IP_ADDRESS = "192.168.34.2"
+        private const val TUN_PREFIX_LENGTH = 24
+        private const val TUN_MTU = 1500
+        private const val DNS_SERVER = "8.8.8.8"
+        private const val VPN_SESSION_NAME = "ix_vpn"
+
+        // 超时常量
+        private const val STOP_TUNNEL_TIMEOUT_MS = 3000L
     }
 
     override fun onCreate() {
@@ -234,7 +248,6 @@ class XRayService : LifecycleVpnService() {
 
         if (serviceStatus == ServiceStatus.Connected) {
             VLog.i(TAG, "VPN already connected")
-            stop()
             return
         }
 
@@ -266,54 +279,50 @@ class XRayService : LifecycleVpnService() {
     }
 
     private fun startTun2Socks() {
-        VLog.i(TAG, "socksPort: ${xrayConfig?.socksPort}, sessionId: ${xrayConfig?.sessionId}")
-        VLog.i(
-            TAG,
-            "allowVpnApps count: ${xrayConfig?.allowVpnApps?.size}, disallowVpnApps count: ${xrayConfig?.disallowVpnApps?.size}"
-        )
-        val builder = Builder()
-        val ipAddress = "192.168.34.2"
-        builder.setSession("ix_vpn")
-            .setMtu(1500)
-            .addAddress(ipAddress, 24)
-            .addRoute("0.0.0.0", 0)
-            .addDnsServer("8.8.8.8")
-
-        if (xrayConfig?.allowVpnApps!!.isNotEmpty()) {
-            xrayConfig?.allowVpnApps!!.forEach { app ->
+        val config = xrayConfig ?: run {
+            VLog.e(TAG, "xrayConfig is null")
+            sendStatusMessage(VPN_STATE_ERROR, ERROR_INIT, "Config is null")
+            stopSelfService()
+            return
+        }
+
+        VLog.i(TAG, "socksPort: ${config.socksPort}, sessionId: ${config.sessionId}")
+        VLog.i(TAG, "allowVpnApps count: ${config.allowVpnApps.size}, disallowVpnApps count: ${config.disallowVpnApps.size}")
+
+        val builder = Builder().apply {
+            setSession(VPN_SESSION_NAME)
+            setMtu(TUN_MTU)
+            addAddress(TUN_IP_ADDRESS, TUN_PREFIX_LENGTH)
+            addRoute("0.0.0.0", 0)
+            addDnsServer(DNS_SERVER)
+        }
+
+        // 配置应用白名单/黑名单
+        if (config.allowVpnApps.isNotEmpty()) {
+            config.allowVpnApps.forEach { app ->
                 builder.addAllowedApplication(app)
             }
-            VLog.i(TAG, "允许的应用: ${xrayConfig?.allowVpnApps?.joinToString(", ")}")
+            VLog.i(TAG, "允许的应用: ${config.allowVpnApps.joinToString(", ")}")
         } else {
-            xrayConfig?.disallowVpnApps!!.forEach { app ->
+            config.disallowVpnApps.forEach { app ->
                 builder.addDisallowedApplication(app)
             }
             builder.addDisallowedApplication(packageName)
-            VLog.i(TAG, "禁止的应用: ${xrayConfig?.disallowVpnApps?.joinToString(", ")}")
+            VLog.i(TAG, "禁止的应用: ${config.disallowVpnApps.joinToString(", ")}")
         }
 
         // 初始化网络请求参数
-        NetworkReporter.getInstance().initialize(xrayConfig!!)
+        NetworkReporter.getInstance().initialize(config)
 
         tunFd = builder.establish()
         tunFd?.let { tfd ->
             VLog.i(TAG, "VPN tunnel established, fd: ${tfd.fd}")
-            tunnelService.startTunnel(
-                this,
-                xrayConfig!!.socksPort,
-                xrayConfig!!.tunnelConfig,
-                tfd.fd
-            )
-            Ixvpn_mobile.proxyConnectorStart(
-                xrayConfig!!.sessionId,
-                xrayConfig!!.startOptions,
-                vpnHandler
-            )
+            tunnelService.startTunnel(this, config.socksPort, config.tunnelConfig, tfd.fd)
+            Ixvpn_mobile.proxyConnectorStart(config.sessionId, config.startOptions, vpnHandler)
             VLog.i(TAG, "XRay proxy started successfully")
         } ?: run {
             VLog.e(TAG, "Failed to establish VPN tunnel")
             updateStatus(ServiceStatus.Failed)
-            // 通知 Flutter 层启动失败
             sendStatusMessage(VPN_STATE_ERROR, ERROR_INIT, "Failed to establish VPN tunnel")
             stopSelfService()
         }
@@ -326,64 +335,78 @@ class XRayService : LifecycleVpnService() {
         }
         vpnStatus = status
 
-        var code = 0L
-        var message = ""
-        try {
-            val json = JSONObject(params)
-            code = json.optLong("code", 0)
-            message = json.optString("message", "")
-        } catch (e: Exception) {
-            VLog.e(TAG, "解析 params JSON 失败", e)
-        }
+        val (code, message) = parseStatusParams(params)
 
-        // VPN 连接成功后启动计时
-        if (status == VPN_STATE_CONNECTED) {
-            val mode = if (xrayConfig!!.isCountdown) TIMER_MODE_COUNTDOWN else TIMER_MODE_NORMAL
-            val time = if (xrayConfig!!.isCountdown) remainTime else 0L
-            startTimer(mode, time)
-            uploadBoostResult(true, code)
-            AppLogger.getInstance(this).updateAppJsonStatusInfo(true, code)
-        } else if (status == VPN_STATE_ERROR) {
-            AppLogger.getInstance(this).updateAppJsonStatusInfo(false, code)
-            uploadBoostResult(false, code)
-            VLog.i(TAG, "vpnHandler")
-            lifecycleScope.launch { stop() }
+        when (status) {
+            VPN_STATE_CONNECTED -> {
+                val config = xrayConfig ?: return@ProxyConnectorHandler
+                val mode = if (config.isCountdown) TIMER_MODE_COUNTDOWN else TIMER_MODE_NORMAL
+                val time = if (config.isCountdown) remainTime else 0L
+                startTimer(mode, time)
+                uploadBoostResult(true, code)
+                AppLogger.getInstance(this).updateAppJsonStatusInfo(true, code)
+            }
+            VPN_STATE_ERROR -> {
+                AppLogger.getInstance(this).updateAppJsonStatusInfo(false, code)
+                uploadBoostResult(false, code)
+                VLog.i(TAG, "vpnHandler error")
+                lifecycleScope.launch { stop() }
+            }
         }
-        // 发送状态消息到XRayApi
 
         sendStatusMessage(status, code, message)
     }
 
+    private fun parseStatusParams(params: String): Pair<Long, String> {
+        return try {
+            val json = JSONObject(params)
+            Pair(json.optLong("code", 0), json.optString("message", ""))
+        } catch (e: Exception) {
+            VLog.e(TAG, "解析 params JSON 失败", e)
+            Pair(0L, "")
+        }
+    }
+
     private fun uploadBoostResult(success: Boolean, code: Long) {
+        val config = xrayConfig ?: return
         try {
-            // 更新 实时流量
-            val queryTypes = byteArrayOf(1)
-            val stats: String? = Ixvpn_mobile.proxyConnectorQueryStats(queryTypes)
-            val session: String = xrayConfig!!.sessionId
-            NetworkReporter.getInstance().addBoostResultParams(
+            val stats = queryStats(1) // QueryStatsConnectionHistory
+            val reporter = NetworkReporter.getInstance()
+
+            reporter.addBoostResultParams(
                 "NM_BoostResult",
                 stats,
-                session,
+                config.sessionId,
                 success,
                 code
             )
-            val param = NetworkReporter.getInstance()
-                .buildRequestParams("NM_BoostResult")
-            val intent = Intent(
-                BOOST_RESULT_BROADCAST
-            ).apply {
+
+            val intent = Intent(BOOST_RESULT_BROADCAST).apply {
                 setPackage(packageName)
-                putExtra("param", param)
+                putExtra("param", reporter.buildRequestParams("NM_BoostResult"))
                 putExtra("success", success)
-                putExtra("locationCode", NetworkReporter.getInstance().getLocationCode())
-                putExtra("nodeId", NetworkReporter.getInstance().getConnectedNodeId())
+                putExtra("locationCode", reporter.getLocationCode())
+                putExtra("nodeId", reporter.getConnectedNodeId())
             }
             sendBroadcast(intent)
-        } catch (e: java.lang.Exception) {
+        } catch (e: Exception) {
             VLog.e(TAG, "uploadBoostResult error: ${e.message}")
         }
     }
 
+    /**
+     * 查询代理连接统计数据
+     * @param types 查询类型: 1=连接历史, 2=带宽, 3=最大速度
+     */
+    private fun queryStats(vararg types: Byte): String? {
+        return try {
+            Ixvpn_mobile.proxyConnectorQueryStats(types)
+        } catch (e: Exception) {
+            VLog.e(TAG, "queryStats error: ${e.message}")
+            null
+        }
+    }
+
     private fun sendStatusMessage(status: Long, code: Long, message: String) {
         VLog.i(TAG, "sendStatusMessage status:$status code:$code message:$message")
         val intent = Intent(STATUS_BROADCAST).apply {
@@ -423,44 +446,56 @@ class XRayService : LifecycleVpnService() {
         stopSelf()
     }
 
-    private fun stopTun2Socks() {
+    private suspend fun stopTun2Socks() {
+        VLog.i(TAG, "stopTun2Socks")
         // 停止计时
         stopTimer()
 
         dealStat()
 
-        // 注意:必须先停止 tunnel,再关闭 fd
-        // 否则 hev-socks5-tunnel 可能在读写 fd 时卡住
-        try {
-            // 使用单独线程执行 stopTunnel,避免阻塞,并添加超时
-            val stopThread = Thread {
+        // 停止顺序优化:
+        // 1. 先停止 proxy connector,切断数据源
+        // 2. 再停止 tunnel(此时不再有新数据进入)
+        // 3. 最后关闭 tun fd
+        stopProxyConnector()
+        stopTunnelWithTimeout()
+        closeTunFd()
+
+        VLog.i(TAG, "xray stopped")
+    }
+
+    private suspend fun stopTunnelWithTimeout() {
+        val startTime = System.currentTimeMillis()
+        val result = withTimeoutOrNull(STOP_TUNNEL_TIMEOUT_MS) {
+            withContext(Dispatchers.IO) {
                 try {
                     tunnelService.stopTunnel()
-                    VLog.i(TAG, "Tunnel stopped")
+                    val elapsed = System.currentTimeMillis() - startTime
+                    VLog.i(TAG, "Tunnel stopped in ${elapsed}ms")
+                    true
                 } catch (e: Exception) {
                     VLog.e(TAG, "停止 tunnel 失败", e)
+                    false
                 }
             }
-            stopThread.start()
-            // 等待最多 3 秒
-            stopThread.join(3000)
-            if (stopThread.isAlive) {
-                VLog.w(TAG, "stopTunnel 超时,强制继续")
-                stopThread.interrupt()
-            }
-        } catch (e: Exception) {
-            VLog.e(TAG, "停止 tunnel 异常", e)
         }
+        if (result == null) {
+            val elapsed = System.currentTimeMillis() - startTime
+            VLog.w(TAG, "stopTunnel 超时 (${elapsed}ms),强制继续")
+        }
+    }
 
+    private fun stopProxyConnector() {
         try {
             Ixvpn_mobile.proxyConnectorStop()
             VLog.i(TAG, "Proxy connector stopped")
         } catch (e: Exception) {
             VLog.e(TAG, "停止 proxy connector 失败", e)
         }
+    }
 
-         // 关闭 TUN fd
-         tunFd?.let { tfd ->
+    private fun closeTunFd() {
+        tunFd?.let { tfd ->
             try {
                 tfd.close()
                 VLog.i(TAG, "TUN file descriptor closed")
@@ -469,7 +504,6 @@ class XRayService : LifecycleVpnService() {
             }
             tunFd = null
         }
-        VLog.i(TAG, "xray stopped")
     }
 
 
@@ -524,25 +558,17 @@ class XRayService : LifecycleVpnService() {
                 // 检查倒计时是否结束
                 if (timerMode == TIMER_MODE_COUNTDOWN && currentTime <= 0) {
                     VLog.i(TAG, "倒计时结束 - 关闭VPN (elapsed: ${elapsedTime}ms)")
-//                    sendTimerUpdate(0L)
                     stop()
                     sendStatusMessage(VPN_STATE_ERROR, ERROR_REMAIN_TIME, "No available time")
                     return@launch
                 }
                 if (timerMode == TIMER_MODE_NORMAL && currentTime >= remainTime) {
                     VLog.i(TAG, "可用时间已结束 - 关闭VPN (elapsed: ${elapsedTime}ms)")
-//                    sendTimerUpdate(0L)
                     stop()
                     sendStatusMessage(VPN_STATE_ERROR, ERROR_REMAIN_TIME, "No available time")
                     return@launch
                 }
 
-                // 更新通知
-//                updateTimerNotification(currentTime)
-
-                // 发送计时更新广播
-//                sendTimerUpdate(currentTime)
-
                 // Peek 定时上报(屏幕关闭时不上报,节省资源)
                 peekElapsedSeconds++
                 if (peekInterval > 0 && peekElapsedSeconds >= peekInterval) {
@@ -571,51 +597,14 @@ class XRayService : LifecycleVpnService() {
     }
 
     /**
-     * 发送计时更新广播
-     * 屏幕关闭时不发送,节省资源
-     */
-//    private fun sendTimerUpdate(currentTime: Long) {
-//        // 屏幕关闭或应用在后台时不发送广播,节省资源
-//        if (!isScreenOn || !isAppForeground) {
-//            return
-//        }
-//        val intent = Intent(TIMER_BROADCAST).apply {
-//            setPackage(packageName)  // 必须设置包名,否则 RECEIVER_NOT_EXPORTED 接收不到
-//            putExtra("currentTime", currentTime)
-//            putExtra("mode", timerMode)
-//        }
-//        sendBroadcast(intent)
-//    }
-
-    /**
-     * 更新通知显示计时
-     */
-    private fun updateTimerNotification(currentTime: Long) {
-        val timeText = formatTime(currentTime)
-        val notification = createConnectionNotification(
-            this,
-            NOTIFICATION_CHANNEL_ID,
-            "NOMO VPN",
-            "Running - $timeText",
-        )
-
-        val notificationManager = getSystemService(NotificationManager::class.java)
-        notificationManager?.notify(FOREGROUND_SERVICE_ID, notification)
-    }
-
-    /**
-     * 查询日志状态
-     * QueryStatsConnectionHistory = 1 查询连接历史
-     * QueryStatsBandwidth         = 2 带宽
-     * QueryStatsMaxSpeed          = 3 最大速度
+     * 查询并记录统计日志
      */
     private fun dealStat() {
-        val queryTypes = byteArrayOf(1, 2, 3)
-        val stats: String? = Ixvpn_mobile.proxyConnectorQueryStats(queryTypes)
+        val stats = queryStats(1, 2, 3) // 连接历史、带宽、最大速度
         val version = Ixvpn_mobile.proxyConnectorVersion()
-        VLog.i(Constant.TAG, "stats = $stats")
-        
-        // 将stats和version写入core.json日志文件
+        VLog.i(TAG, "stats = $stats")
+
+        // 将 stats 和 version 写入 core.json 日志文件
         CoreLogger.getInstance(this).updateStatsAndVersion(stats, version)
         // 更新停止时间
         AppLogger.getInstance(this).updateAppJsonStopTime()
@@ -625,27 +614,14 @@ class XRayService : LifecycleVpnService() {
      * 调用 Peek 接口上报数据
      */
     private fun callPeekInterface() {
+        val config = xrayConfig ?: return
         try {
-            // 获取实时流量数据
-            // 更新 实时流量
-            val queryTypes = byteArrayOf(3)
-            val stats: String? = Ixvpn_mobile.proxyConnectorQueryStats(queryTypes)
-            VLog.i(TAG, "peek stats : $stats")
-
-            // 设置 Peek Log 参数
-            NetworkReporter.getInstance().addPeekLogParams(
-                "NM_PeekLog",
-                stats,
-                xrayConfig!!.sessionId
-            )
+            val stats = queryStats(3) // QueryStatsMaxSpeed
+            VLog.i(TAG, "peek stats: $stats")
 
-            // 上报数据
-            NetworkReporter.getInstance().report(
-                "/api/v1/event",
-                "NM_PeekLog",
-                null,
-                null
-            )
+            val reporter = NetworkReporter.getInstance()
+            reporter.addPeekLogParams("NM_PeekLog", stats, config.sessionId)
+            reporter.report("/api/v1/event", "NM_PeekLog", null, null)
 
             VLog.i(TAG, "Peek log reported successfully")
         } catch (e: Exception) {

BIN=BIN
assets/images/round/connected_round.png


BIN=BIN
assets/images/round/connecting_round.png


BIN=BIN
assets/images/round/dark_connected.png


BIN=BIN
assets/images/round/dark_connecting.png


BIN=BIN
assets/images/round/dark_disconnected.png


BIN=BIN
assets/images/round/disconnected_round.png


BIN=BIN
assets/images/round/light_connected.png


BIN=BIN
assets/images/round/light_connecting.png


BIN=BIN
assets/images/round/light_disconnected.png


+ 6 - 0
assets/vectors/settings_theme.svg

@@ -0,0 +1,6 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.0003 18.3332C12.4837 18.3332 10.9736 14.6398 12.5003 12.9165C13.803 11.446 18.3337 12.1188 18.3337 9.99984C18.3337 5.39746 14.6027 1.6665 10.0003 1.6665C5.39795 1.6665 1.66699 5.39746 1.66699 9.99984C1.66699 14.6022 5.39795 18.3332 10.0003 18.3332Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M11.667 7.0835C12.3574 7.0835 12.917 6.52387 12.917 5.8335C12.917 5.14312 12.3574 4.5835 11.667 4.5835C10.9766 4.5835 10.417 5.14312 10.417 5.8335C10.417 6.52387 10.9766 7.0835 11.667 7.0835Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M6.66699 8.75C7.35737 8.75 7.91699 8.19037 7.91699 7.5C7.91699 6.80962 7.35737 6.25 6.66699 6.25C5.97662 6.25 5.41699 6.80962 5.41699 7.5C5.41699 8.19037 5.97662 8.75 6.66699 8.75Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M7.08301 14.1665C7.77338 14.1665 8.33301 13.6069 8.33301 12.9165C8.33301 12.2261 7.77338 11.6665 7.08301 11.6665C6.39263 11.6665 5.83301 12.2261 5.83301 12.9165C5.83301 13.6069 6.39263 14.1665 7.08301 14.1665Z" stroke="white" style="stroke:white;stroke-opacity:1;" stroke-width="1.25" stroke-linejoin="round"/>
+</svg>

+ 23 - 34
lib/app/app.dart

@@ -100,22 +100,9 @@ class App extends StatelessWidget {
               child: const SizedBox.shrink(), // 不显示任何东西
             ),
           ),
-          Positioned(
-            top: 0,
-            left: 0,
-            width: 100,
-            height: 100,
-            child: TripleTapDetector(
-              requiredTaps: 10,
-              onTripleTap: () async {
-                IXDeveloperTools.show();
-              },
-              child: const SizedBox.shrink(), // 不显示任何东西
-            ),
-          ),
           if (Configs.debug)
             Positioned(
-              bottom: 200,
+              bottom: 100,
               right: 10,
               child: ClickOpacity(
                 child: Container(
@@ -135,29 +122,31 @@ class App extends StatelessWidget {
                 },
               ),
             ),
-
-          Positioned(
-            bottom: 300,
-            right: 10,
-            child: ClickOpacity(
-              child: Container(
-                width: 40,
-                height: 40,
-                decoration: BoxDecoration(
-                  color: Get.reactiveTheme.primaryColor.withValues(alpha: 0.5),
-                  borderRadius: BorderRadius.circular(40),
-                ),
-                child: Icon(
-                  ReactiveTheme.isLightTheme ? Icons.sunny : Icons.nightlight,
-                  color: Get.reactiveTheme.primaryColor,
+          if (Configs.debug)
+            Positioned(
+              bottom: 100,
+              right: 10,
+              child: ClickOpacity(
+                child: Container(
+                  width: 40,
+                  height: 40,
+                  decoration: BoxDecoration(
+                    color: Get.reactiveTheme.primaryColor.withValues(
+                      alpha: 0.5,
+                    ),
+                    borderRadius: BorderRadius.circular(40),
+                  ),
+                  child: Icon(
+                    ReactiveTheme.isLightTheme ? Icons.sunny : Icons.nightlight,
+                    color: Get.reactiveTheme.primaryColor,
+                  ),
                 ),
+                onTap: () {
+                  // 切换主题
+                  IXTheme.changeTheme();
+                },
               ),
-              onTap: () {
-                // 切换主题
-                IXTheme.changeTheme();
-              },
             ),
-          ),
         ],
       ),
     );

+ 15 - 6
lib/app/constants/assets.dart

@@ -110,17 +110,26 @@ class Assets {
   static const String update = 'assets/vectors/update.svg';
 
   // 圆形连接按钮资源
-  static const String connectedRound =
-      'assets/images/round/connected_round.png';
-  static const String connectingRound =
-      'assets/images/round/connecting_round.png';
-  static const String disconnectedRound =
-      'assets/images/round/disconnected_round.png';
   static const String connectedSwitch =
       'assets/images/round/connected_switch.png';
   static const String disconnectedSwitch =
       'assets/images/round/disconnected_switch.png';
 
+  // 连接按钮中间的图片
+  static const String darkDisconnected =
+      'assets/images/round/dark_disconnected.png';
+  static const String lightDisconnected =
+      'assets/images/round/light_disconnected.png';
+  static const String darkConnected = 'assets/images/round/dark_connected.png';
+  static const String lightConnected =
+      'assets/images/round/light_connected.png';
+  static const String darkConnecting =
+      'assets/images/round/dark_connecting.png';
+  static const String lightConnecting =
+      'assets/images/round/light_connecting.png';
+
+  static const String settingsTheme = 'assets/vectors/settings_theme.svg';
+
   // home页的会员
   static const String homePremium = 'assets/images/identity/premium.png';
   static const String homeTest = 'assets/images/identity/test.png';

+ 3 - 1
lib/app/constants/enums.dart

@@ -75,8 +75,10 @@ enum LogModule {
 
 enum ConnectionState {
   disconnected, // 默认断开状态
-  connecting, // 连接中状态
+  connectingVirtual, // 虚拟连接中(点击按钮会触发虚拟连接)
+  connecting, // 真实连接中
   connected, // 连接成功状态
+  disconnecting, // 断开中状态
   error, // 连接错误状态
 }
 

+ 3 - 0
lib/app/constants/sp_keys.dart

@@ -14,6 +14,9 @@ class SPKeys {
   /// 主题是否为浅色
   static const String lightTheme = 'is_theme_light';
 
+  /// 主题模式: 'system', 'dark', 'light'
+  static const String themeMode = 'theme_mode';
+
   /// 是否启动游戏
   static const String launchGame = 'is_launch_game';
 

+ 327 - 2
lib/app/controllers/api_controller.dart

@@ -14,9 +14,13 @@ import 'package:package_info_plus/package_info_plus.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:play_install_referrer/play_install_referrer.dart';
 import 'package:system_clock/system_clock.dart';
+import 'package:uuid/uuid.dart';
 
 import '../../config/translations/localization_service.dart';
 import '../../config/translations/strings_enum.dart';
+import '../../utils/boost_report_manager.dart';
+import '../../utils/gzip_manager.dart';
+import '../api/file/api_file.dart';
 import '../data/models/banner/banner_list.dart';
 import '../data/models/launch/upgrade.dart';
 import 'base_core_api.dart';
@@ -50,6 +54,9 @@ class ApiController extends GetxService with WidgetsBindingObserver {
   // 记录是否已经显示禁用弹窗
   bool isShowDisabled = false;
 
+  // 上次调用 uploadBoostLog 的时间(用于节流)
+  DateTime? _lastUploadBoostLogTime;
+
   //是否是游客
   final _isGuest = false.obs;
   bool get isGuest => _isGuest.value;
@@ -342,6 +349,12 @@ class ApiController extends GetxService with WidgetsBindingObserver {
         // 初始化Launch
         await initData(launchData);
 
+        // 缓存日志上传
+        processCachedLogs();
+
+        // 缓存文件日志上传
+        processCachedFileLogs();
+
         return launchData;
       } on ApiException catch (e) {
         final url = await ApiDomains.instance.getNextApiUrl();
@@ -411,6 +424,12 @@ class ApiController extends GetxService with WidgetsBindingObserver {
         // 初始化Launch
         await initData(launchData);
 
+        // 缓存日志上传
+        processCachedLogs();
+
+        // 缓存文件日志上传
+        processCachedFileLogs();
+
         return launchData;
       } on ApiException catch (e) {
         final url = await ApiDomains.instance.getNextApiUrl();
@@ -1026,6 +1045,119 @@ class ApiController extends GetxService with WidgetsBindingObserver {
     }
   }
 
+  /// 上传 Boost 日志(读取 app.json 和 core.json 并组合上传)
+  /// 1秒内只允许调用一次
+  Future<void> uploadBoostLog() async {
+    // 节流检查:1秒内只允许调用一次
+    final now = DateTime.now();
+    if (_lastUploadBoostLogTime != null &&
+        now.difference(_lastUploadBoostLogTime!).inMilliseconds < 1000) {
+      log(TAG, 'uploadBoostLog throttled, skip');
+      return;
+    }
+    _lastUploadBoostLogTime = now;
+
+    try {
+      // 读取 app.json 内容
+      Map<String, dynamic> appLog = {};
+      final appLogPath = await BoostReportManager().getAppLogFilePath();
+      if (appLogPath != null) {
+        final appFile = File(appLogPath);
+        if (await appFile.exists()) {
+          final content = await appFile.readAsString();
+          if (content.isNotEmpty) {
+            appLog = jsonDecode(content) as Map<String, dynamic>;
+          }
+        }
+      }
+
+      // 读取 core.json 内容
+      Map<String, dynamic> coreLog = {};
+      final coreLogPath = await BoostReportManager().getCoreLogFilePath();
+      if (coreLogPath != null) {
+        final coreFile = File(coreLogPath);
+        if (await coreFile.exists()) {
+          final content = await coreFile.readAsString();
+          if (content.isNotEmpty) {
+            coreLog = jsonDecode(content) as Map<String, dynamic>;
+          }
+        }
+      }
+
+      // 如果两个日志都为空,则不上传
+      if (appLog.isEmpty && coreLog.isEmpty) {
+        log(TAG, 'Both app log and core log are empty, skip upload');
+        return;
+      }
+
+      // 组装日志数据
+      final logItem = {
+        'id': const Uuid().v4(),
+        'time': DateTime.now().millisecondsSinceEpoch,
+        'level': LogLevel.info.name,
+        'module': LogModule.NM_Metrics.name,
+        'category': Configs.productCode,
+        'fields': {'appLog': appLog, 'coreLog': coreLog},
+      };
+
+      // 上传日志
+      await uploadMetricsLog([logItem]);
+      await uploadLocalLog(appLog['sessionInfo']?['boostSessionId'] ?? '');
+      log(TAG, 'Boost log uploaded successfully');
+    } catch (e) {
+      log(TAG, 'uploadBoostLog error: $e');
+    }
+  }
+
+  Future<void> uploadMetricsLog(List<Map<String, dynamic>> logs) async {
+    try {
+      await uploadLogs(logs, isCache: true);
+    } catch (e) {
+      log(TAG, 'uploadMetricsLog error: $e');
+    }
+  }
+
+  Future<void> uploadLocalLog(String boostSessionId) async {
+    try {
+      final appDir = await getApplicationSupportDirectory();
+      final logDir = Directory('${appDir.path}/logs');
+      if (await logDir.exists()) {
+        final filePaths = <String>[];
+
+        // 遍历 logDir 目录,查找 client_ 和 service_ 开头的文件
+        await for (final entity in logDir.list()) {
+          if (entity is File) {
+            final fileName = entity.path.split('/').last;
+            if (fileName.startsWith('client_') ||
+                fileName.startsWith('service_')) {
+              filePaths.add(entity.path);
+            }
+          }
+        }
+
+        if (filePaths.isEmpty) {
+          log(TAG, 'No client_ or service_ log files found');
+          return;
+        }
+
+        final gzipFilePath = await GzipManager().generateAndShareGzip(
+          filePaths: filePaths,
+          gzipFileName: '$boostSessionId.tar.gz',
+        );
+        if (gzipFilePath == null) {
+          return;
+        }
+        await uploadLogFile(
+          gzipFilePath,
+          boostSessionId,
+          LogModule.NM_Log.name,
+        );
+      }
+    } catch (e) {
+      rethrow;
+    }
+  }
+
   Future<String> uploadLogs(List<dynamic> items, {bool isCache = false}) async {
     await updateFingerprintData();
     Map<String, dynamic> request = fp.toJson();
@@ -1088,10 +1220,162 @@ class ApiController extends GetxService with WidgetsBindingObserver {
     }
   }
 
+  // 上传文件
+  Future<String> uploadLogFile(
+    String filePath,
+    String fileName,
+    String logType, {
+    bool isCache = false,
+  }) async {
+    try {
+      Map<String, dynamic> request = fp.toJson();
+      final data = jsonEncode(request);
+      while (true) {
+        try {
+          ApiFile().setbaseUrl(ApiDomains.instance.getFileUrl());
+          final result = await ApiFile().uploadLogFile(
+            filePath,
+            fileName,
+            logType,
+            data,
+          );
+          if (!result.success) {
+            throw Failure(
+              code: result.errorCode ?? '',
+              message: result.errorMessage ?? '',
+            );
+          }
+          return result.errorMessage ?? '';
+        } on ApiException catch (_) {
+          rethrow;
+        } on Failure catch (_) {
+          rethrow;
+        } on DioException catch (e) {
+          if (e.response?.statusCode == Errors.eRegionNotAvailable ||
+              e.response?.statusCode == Errors.eUserDisabled ||
+              e.response?.statusCode == Errors.eTokenExpired) {
+            if (isCache) {
+              await _cacheFileLogRequest(filePath, fileName);
+            }
+            rethrow;
+          } else {
+            if (await NetworkHelper.instance.isNetworkAvailable()) {
+              final url = await ApiDomains.instance.getNextFileUrl();
+              if (url.isEmpty) {
+                if (isCache) {
+                  await _cacheFileLogRequest(filePath, fileName);
+                }
+                rethrow;
+              }
+              log('uploadLogs request failed for URL ${ApiLog().baseUrl}: $e');
+              ApiLog().setbaseUrl(url);
+            } else {
+              if (isCache) {
+                await _cacheFileLogRequest(filePath, fileName);
+              }
+              rethrow;
+            }
+          }
+        } catch (e) {
+          final url = await ApiDomains.instance.getNextFileUrl();
+          if (url.isEmpty) {
+            if (isCache) {
+              await _cacheFileLogRequest(filePath, fileName);
+            }
+            rethrow;
+          }
+          log('uploadLogs request failed for URL ${ApiLog().baseUrl}: $e');
+          ApiLog().setbaseUrl(url);
+        }
+      }
+    } catch (e) {
+      rethrow;
+    }
+  }
+
+  // 缓存文件日志请求数据
+  Future<void> _cacheFileLogRequest(String filePath, String fileName) async {
+    try {
+      final dir = await getApplicationSupportDirectory();
+      final cacheDir = Directory('${dir.path}/file_log_cache');
+      if (!await cacheDir.exists()) {
+        await cacheDir.create(recursive: true);
+      }
+      // 复制文件到缓存目录
+      final file = File(filePath);
+      await file.copy('${cacheDir.path}/${file.path.split('/').last}');
+      // 清理缓存目录
+      await _cleanupFileCacheDirectory(cacheDir);
+    } catch (e) {
+      log('Failed to cache file log request: $e');
+    }
+  }
+
+  // 清理文件缓存目录
+  Future<void> _cleanupFileCacheDirectory(Directory cacheDir) async {
+    try {
+      const maxFiles = 10; // 最多保留10个文件
+      final files = await cacheDir.list().toList();
+
+      // 按修改时间排序
+      files.sort(
+        (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
+      );
+
+      // 如果超过文件数量或大小限制,删除最旧的文件
+      while (files.length > maxFiles && files.isNotEmpty) {
+        final oldestFile = files.removeAt(0);
+        await oldestFile.delete();
+      }
+    } catch (e) {
+      log('Failed to cleanup cache directory: $e');
+    }
+  }
+
+  // 处理缓存的文件日志数据
+  Future<void> processCachedFileLogs() async {
+    try {
+      final dir = await getApplicationSupportDirectory();
+      final cacheDir = Directory('${dir.path}/file_log_cache');
+      if (!await cacheDir.exists()) {
+        return;
+      }
+
+      final files = await cacheDir.list().toList();
+      if (files.isEmpty) {
+        return;
+      }
+
+      // 按修改时间排序,先处理最旧的文件
+      files.sort(
+        (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
+      );
+
+      for (var fileEntity in files) {
+        try {
+          if (fileEntity is! File) continue;
+          final filePath = fileEntity.path;
+          final fileName = fileEntity.path.split('/').last.split('.').first;
+          // 尝试上传缓存的日志
+          await uploadLogFile(filePath, fileName, LogModule.NM_Log.name);
+
+          // 上传成功后删除缓存文件
+          await fileEntity.delete();
+        } catch (e) {
+          log('Failed to process cached log file ${fileEntity.path}: $e');
+          // 如果上传失败,保留文件等待下次尝试
+          continue;
+        }
+      }
+    } catch (e) {
+      log('Failed to process cached file logs: $e');
+    }
+  }
+
   // 缓存日志请求数据
   Future<void> _cacheLogRequest(dynamic items) async {
     try {
-      final dir = await getApplicationDocumentsDirectory();
+      final dir = await getApplicationSupportDirectory();
       final cacheDir = Directory('${dir.path}/log_cache');
       if (!await cacheDir.exists()) {
         await cacheDir.create(recursive: true);
@@ -1142,6 +1426,47 @@ class ApiController extends GetxService with WidgetsBindingObserver {
     }
   }
 
+  // 处理缓存的日志数据
+  Future<void> processCachedLogs() async {
+    try {
+      final dir = await getApplicationSupportDirectory();
+      final cacheDir = Directory('${dir.path}/log_cache');
+      if (!await cacheDir.exists()) {
+        return;
+      }
+
+      final files = await cacheDir.list().toList();
+      if (files.isEmpty) {
+        return;
+      }
+
+      // 按修改时间排序,先处理最旧的文件
+      files.sort(
+        (a, b) => a.statSync().modified.compareTo(b.statSync().modified),
+      );
+
+      for (var fileEntity in files) {
+        try {
+          if (fileEntity is! File) continue;
+          final content = await fileEntity.readAsString();
+          final items = jsonDecode(content);
+
+          // 尝试上传缓存的日志
+          await uploadLogs(items, isCache: false);
+
+          // 上传成功后删除缓存文件
+          await fileEntity.delete();
+        } catch (e) {
+          log('Failed to process cached log file ${fileEntity.path}: $e');
+          // 如果上传失败,保留文件等待下次尝试
+          continue;
+        }
+      }
+    } catch (e) {
+      log('Failed to process cached logs: $e');
+    }
+  }
+
   Future<void> uploadApiStatisticsLog(
     List<Map<String, dynamic>> logs, {
     LogModule module = LogModule.NM_ApiLaunchLog,
@@ -1292,7 +1617,7 @@ class ApiController extends GetxService with WidgetsBindingObserver {
   /// 格式化剩余时间显示
   String _formatRemainTime(int totalSeconds) {
     if (totalSeconds <= 0) {
-      return '';
+      return '0';
     }
 
     final days = totalSeconds ~/ 86400;

+ 165 - 14
lib/app/controllers/core_controller.dart

@@ -51,6 +51,15 @@ class CoreController extends GetxService {
 
   String locationSelectionType = 'auto';
 
+  // 标记 VPN 逻辑是否已开启(调用了 BaseCoreApi().connect())
+  bool _isVpnStarted = false;
+
+  // 标记是否有待处理的断开请求
+  bool _pendingDisconnect = false;
+
+  // 用于等待断开完成的 Completer
+  Completer<void>? _disconnectCompleter;
+
   @override
   void onInit() {
     super.onInit();
@@ -70,33 +79,134 @@ class CoreController extends GetxService {
     BaseCoreApi().isConnected().then((value) {
       if (value == true) {
         state = ConnectionState.connected;
+        _isVpnStarted = true;
       } else {
         state = ConnectionState.disconnected;
+        _isVpnStarted = false;
       }
     });
   }
 
-  void handleConnection() {
+  void handleConnection() async {
+    // 如果正在断开中,忽略操作
+    if (state == ConnectionState.disconnecting) {
+      log(TAG, '正在断开中,忽略操作');
+      return;
+    }
+
     if (state == ConnectionState.disconnected) {
       // 开始连接 - 轻微震动
-      state = ConnectionState.connecting;
+      state = ConnectionState.connectingVirtual;
+      _isVpnStarted = false;
+      _pendingDisconnect = false;
       HapticFeedbackManager.connectionStart();
       getDispatchInfo();
-    } else {
-      // 断开连接
+    } else if (state == ConnectionState.connectingVirtual) {
+      // 虚拟连接中点击断开
+      if (_isVpnStarted) {
+        // VPN 逻辑已开启,标记待断开,等待 _onVpnConnecting 返回后执行断开
+        log(TAG, 'VPN 已启动,标记待断开');
+        _pendingDisconnect = true;
+      } else {
+        // VPN 逻辑还没开启,取消请求并初始化状态
+        log(TAG, 'VPN 未启动,取消请求并初始化状态');
+        _cancelToken?.cancel('用户取消连接');
+        _cancelToken = null;
+        _uninitState();
+      }
+    } else if (state == ConnectionState.connecting) {
+      // 真实连接中点击断开 - 执行断开逻辑
+      log(TAG, '真实连接中,执行断开逻辑');
+      await _performDisconnect();
+    } else if (state == ConnectionState.connected) {
+      // 已连接状态,执行断开逻辑
+      await _performDisconnect();
+    }
+  }
+
+  /// 执行断开逻辑,等待断开完成
+  Future<void> _performDisconnect() async {
+    // 如果已经在断开中,等待断开完成(带超时)
+    if (_disconnectCompleter != null) {
+      log(TAG, '已经在断开中,等待完成');
+      try {
+        await _disconnectCompleter!.future.timeout(
+          const Duration(seconds: 10),
+          onTimeout: () {
+            log(TAG, '等待断开超时,强制清理');
+            _forceCleanupDisconnect();
+          },
+        );
+      } catch (e) {
+        log(TAG, '等待断开异常: $e');
+        _forceCleanupDisconnect();
+      }
+      return;
+    }
+
+    log(TAG, '开始执行断开逻辑, _isVpnStarted=$_isVpnStarted');
+
+    // 如果 VPN 根本没启动,直接清理状态即可
+    if (!_isVpnStarted) {
+      log(TAG, 'VPN 未启动,直接清理状态');
+      _uninitState();
+      return;
+    }
+
+    state = ConnectionState.disconnecting;
+    _disconnectCompleter = Completer<void>();
+
+    try {
       BaseCoreApi().disconnect();
+    } catch (e) {
+      log(TAG, 'disconnect 调用异常: $e');
+      _forceCleanupDisconnect();
+      return;
+    }
+
+    // 等待断开完成(带超时)
+    try {
+      await _disconnectCompleter!.future.timeout(
+        const Duration(seconds: 10),
+        onTimeout: () {
+          log(TAG, '断开超时,强制清理');
+          _forceCleanupDisconnect();
+        },
+      );
+    } catch (e) {
+      log(TAG, '断开等待异常: $e');
+      _forceCleanupDisconnect();
     }
   }
 
-  void selectLocationConnect() {
+  /// 强制清理断开状态
+  void _forceCleanupDisconnect() {
+    if (_disconnectCompleter != null && !_disconnectCompleter!.isCompleted) {
+      _disconnectCompleter!.complete();
+    }
+    _disconnectCompleter = null;
+    _isVpnStarted = false;
+    _pendingDisconnect = false;
+    _uninitState();
+  }
+
+  void selectLocationConnect() async {
+    if (state == ConnectionState.disconnecting) {
+      log(TAG, '正在断开中,等待断开完成');
+      if (_disconnectCompleter != null) {
+        await _disconnectCompleter!.future;
+      }
+    }
+
     if (state != ConnectionState.disconnected) {
-      BaseCoreApi().disconnect();
+      await _performDisconnect();
       // 延迟300ms
-      Future.delayed(const Duration(milliseconds: 300), () {
-        log(TAG, 'selectLocationConnect disconnected = $state');
-        state = ConnectionState.connecting;
-        getDispatchInfo();
-      });
+      await Future.delayed(const Duration(milliseconds: 300));
+      log(TAG, 'selectLocationConnect disconnected = $state');
+      state = ConnectionState.connectingVirtual;
+      _isVpnStarted = false;
+      _pendingDisconnect = false;
+      getDispatchInfo();
     } else {
       handleConnection();
     }
@@ -134,7 +244,10 @@ class CoreController extends GetxService {
         return;
       }
 
-      if (state == ConnectionState.connecting) {
+      if (state == ConnectionState.connectingVirtual) {
+        // 标记 VPN 逻辑已开启
+        _isVpnStarted = true;
+
         final sessionId = Uuid().v4();
         final socksPort = launch.nodesConfig!.socketPort!;
         final tunnelConfig = launch.nodesConfig!.tunnelConfig!;
@@ -181,7 +294,7 @@ class CoreController extends GetxService {
         return;
       }
 
-      if (state == ConnectionState.connecting) {
+      if (state == ConnectionState.connectingVirtual) {
         state = ConnectionState.disconnected;
       }
       handleErrorDialog(e, s);
@@ -192,7 +305,7 @@ class CoreController extends GetxService {
         _cancelToken = null;
       }
 
-      if (state == ConnectionState.connecting) {
+      if (state == ConnectionState.connectingVirtual) {
         state = ConnectionState.disconnected;
       }
       handleErrorDialog(e, s);
@@ -349,13 +462,37 @@ class CoreController extends GetxService {
   // VPN状态处理方法
   void _onVpnDisconnected() {
     log(TAG, 'VPN已断开连接');
+
+    // 完成断开 Completer
+    if (_disconnectCompleter != null && !_disconnectCompleter!.isCompleted) {
+      _disconnectCompleter!.complete();
+    }
+    _disconnectCompleter = null;
+
+    // 重置标志位
+    _isVpnStarted = false;
+    _pendingDisconnect = false;
+
     // 更新UI状态
     _uninitState();
+
+    // 上传 Boost 日志(不阻塞)
+    _apiController.uploadBoostLog();
+
     // FeedbackBottomSheet.show();
   }
 
   void _onVpnConnecting() {
     log(TAG, 'VPN正在连接');
+
+    // 检查是否有待处理的断开请求
+    if (_pendingDisconnect) {
+      log(TAG, '检测到待处理的断开请求,执行断开');
+      _pendingDisconnect = false;
+      _performDisconnect();
+      return;
+    }
+
     // 显示连接中状态
     state = ConnectionState.connecting;
   }
@@ -369,9 +506,23 @@ class CoreController extends GetxService {
 
   void _onVpnError(int code, String message) {
     log(TAG, 'VPN连接错误: code=$code, message=$message');
+
+    // 完成断开 Completer(如果有)
+    if (_disconnectCompleter != null && !_disconnectCompleter!.isCompleted) {
+      _disconnectCompleter!.complete();
+    }
+    _disconnectCompleter = null;
+
+    // 重置标志位
+    _isVpnStarted = false;
+    _pendingDisconnect = false;
+
     // 显示错误信息
     _uninitState();
     showErrorDialog(code, message);
+
+    // 上传 Boost 日志(不阻塞)
+    _apiController.uploadBoostLog();
   }
 
   void showErrorDialog(int code, String message) {

+ 6 - 0
lib/app/controllers/windows_core_api.dart

@@ -109,6 +109,9 @@ class WindowsCoreApi implements BaseCoreApi {
       final (status, data) = event;
       // 处理VPN连接状态
       switch (status) {
+        case ConnectionState.connectingVirtual:
+          // 虚拟连接中
+          break;
         case ConnectionState.connecting:
           _handleStateConnecting();
           break;
@@ -122,6 +125,9 @@ class WindowsCoreApi implements BaseCoreApi {
         case ConnectionState.disconnected:
           _handleStateDisconnected();
           break;
+        case ConnectionState.disconnecting:
+          // 断开中
+          break;
       }
     });
   }

+ 0 - 55
lib/app/data/models/disconnect_domain.dart

@@ -1,55 +0,0 @@
-class DisconnectDomain {
-  String domain;
-  int status;
-  String msg;
-  int startTime;
-  int endTime;
-  int count;
-
-  DisconnectDomain({
-    required this.domain,
-    required this.status,
-    required this.msg,
-    required this.startTime,
-    required this.endTime,
-    required this.count,
-  });
-
-  factory DisconnectDomain.empty() {
-    return DisconnectDomain(
-      domain: '',
-      status: 0,
-      msg: '',
-      startTime: 0,
-      endTime: 0,
-      count: 0,
-    );
-  }
-
-  factory DisconnectDomain.fromJson(Map<String, dynamic> json) {
-    return DisconnectDomain(
-      domain: json['domain'],
-      status: json['status'],
-      msg: json['msg'],
-      startTime: json['startTime'],
-      endTime: json['endTime'],
-      count: json['count'],
-    );
-  }
-
-  Map<String, dynamic> toJson() {
-    return {
-      'domain': domain,
-      'status': status,
-      'msg': msg,
-      'startTime': startTime,
-      'endTime': endTime,
-      'count': count,
-    };
-  }
-
-  @override
-  String toString() {
-    return 'DisconnectDomain(domain: $domain, status: $status, msg: $msg, startTime: $startTime, endTime: $endTime, count: $count)';
-  }
-}

+ 10 - 2
lib/app/data/sp/ix_sp.dart

@@ -33,15 +33,23 @@ class IXSP {
     _sharedPreferences = sharedPreferences;
   }
 
-  /// set theme current type as light theme
+  /// set theme current type as light theme (legacy, for compatibility)
   static Future<void> setThemeIsLight(bool lightTheme) =>
       _sharedPreferences.setBool(SPKeys.lightTheme, lightTheme);
 
-  /// get if the current theme type is light
+  /// get if the current theme type is light (legacy, for compatibility)
   static bool getThemeIsLight() =>
       _sharedPreferences.getBool(SPKeys.lightTheme) ??
       false; // todo set the default theme (true for light, false for dark)
 
+  /// 主题模式: 'system', 'dark', 'light'
+  static Future<void> setThemeMode(String mode) =>
+      _sharedPreferences.setString(SPKeys.themeMode, mode);
+
+  /// 获取主题模式,默认跟随系统
+  static String getThemeMode() =>
+      _sharedPreferences.getString(SPKeys.themeMode) ?? 'system';
+
   /// save current locale
   static Future<void> setCurrentLanguage(String languageCode) =>
       _sharedPreferences.setString(SPKeys.currentLocal, languageCode);

+ 11 - 5
lib/app/modules/home/controllers/home_controller.dart

@@ -74,13 +74,18 @@ class HomeController extends BaseController {
   void onInit() {
     super.onInit();
     _initializeLocations();
-    AwesomeNotificationsHelper.init();
     getBanner(position: 'nine');
     getBanner(position: 'banner');
-    // 延迟检查通知权限,避免阻塞初始化
-    Future.delayed(const Duration(milliseconds: 500), () {
-      AwesomeNotificationsHelper.showPushNoticeDialog();
-    });
+    AwesomeNotificationsHelper.init();
+    AwesomeNotificationsHelper.showPushNoticeDialog();
+    checkUpdate();
+  }
+
+  Future<void> checkUpdate() async {
+    final isVpnRunning = await BaseCoreApi().isConnected() ?? false;
+    if (!isVpnRunning) {
+      await apiController.checkUpdate();
+    }
   }
 
   /// 初始化位置数据
@@ -201,6 +206,7 @@ class HomeController extends BaseController {
     _saveLocationsToStorage();
   }
 
+  // 从节点列表中选择节点后需要延迟300ms
   void handleConnect({bool delay = false}) {
     if (delay) {
       // 延迟300ms

+ 18 - 2
lib/app/modules/home/views/home_view.dart

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide ConnectionState;
 import 'package:flutter_screenutil/flutter_screenutil.dart';
 import 'package:get/get.dart';
 import 'package:nomo/app/constants/iconfont/iconfont.dart';
+import 'package:nomo/app/data/sp/ix_sp.dart';
 import 'package:pull_to_refresh_flutter3/pull_to_refresh_flutter3.dart';
 import '../../../../config/theme/dark_theme_colors.dart';
 import '../../../../config/theme/theme_extensions/theme_extension.dart';
@@ -17,7 +18,7 @@ import '../../../widgets/country_icon.dart';
 import '../../../widgets/ix_image.dart';
 import '../controllers/home_controller.dart';
 
-import '../widgets/connection_round_button.dart';
+import '../widgets/connection_theme_button.dart';
 import '../widgets/menu_list.dart';
 
 class HomeView extends BaseView<HomeController> {
@@ -381,7 +382,7 @@ class HomeView extends BaseView<HomeController> {
 
   Widget _buildConnectionButton() {
     return Obx(
-      () => ConnectionRoundButton(
+      () => ConnectionThemeButton(
         state: controller.coreController.state,
         onTap: () {
           controller.collapseRecentLocations();
@@ -418,6 +419,7 @@ class HomeView extends BaseView<HomeController> {
         Get.toNamed(Routes.NODE);
       },
       child: Obx(() {
+        final isLight = IXSP.getThemeIsLight();
         return Container(
           height: 56.w,
           width: double.maxFinite,
@@ -425,6 +427,16 @@ class HomeView extends BaseView<HomeController> {
           decoration: BoxDecoration(
             color: Get.reactiveTheme.highlightColor,
             borderRadius: BorderRadius.circular(12.r),
+            boxShadow: isLight
+                ? [
+                    BoxShadow(
+                      color: Colors.black.withOpacity(0.06),
+                      offset: const Offset(0, 4),
+                      blurRadius: 16,
+                      spreadRadius: 0,
+                    ),
+                  ]
+                : null,
           ),
           child: Row(
             children: [
@@ -465,12 +477,16 @@ class HomeView extends BaseView<HomeController> {
   /// 构建最近位置卡片(支持展开/收缩)
   Widget _buildRecentLocationsCard() {
     return Obx(() {
+      final isLight = IXSP.getThemeIsLight();
       return Container(
         margin: EdgeInsets.symmetric(horizontal: 10.w),
         padding: EdgeInsets.only(left: 16.w, right: 16.w, top: 56.w, bottom: 0),
         decoration: BoxDecoration(
           color: Get.reactiveTheme.cardColor,
           borderRadius: BorderRadius.circular(12.r),
+          border: isLight
+              ? Border.all(color: Get.reactiveTheme.dividerColor, width: 1.w)
+              : null,
         ),
         child: Column(
           children: [

+ 18 - 3
lib/app/modules/home/widgets/connection_button.dart

@@ -101,8 +101,10 @@ class _ConnectionButtonState extends State<ConnectionButton>
   }
 
   Widget _buildImageWithAnimation(String imgPath) {
-    // 如果是connecting状态,添加旋转动画
-    if (widget.state == ConnectionState.connecting) {
+    // 如果是 connectingVirtual/connecting 或 disconnecting 状态,添加旋转动画
+    if (widget.state == ConnectionState.connectingVirtual ||
+        widget.state == ConnectionState.connecting ||
+        widget.state == ConnectionState.disconnecting) {
       // 启动旋转动画
       if (!_rotationController.isAnimating) {
         _rotationController.repeat();
@@ -172,6 +174,7 @@ class _ConnectionButtonState extends State<ConnectionButton>
         }
         _stopConnectingTimer(); // 停止计时器
         break;
+      case ConnectionState.connectingVirtual:
       case ConnectionState.connecting:
         gradientColor = [
           Get.reactiveTheme.highlightColor,
@@ -182,7 +185,8 @@ class _ConnectionButtonState extends State<ConnectionButton>
         text = _getConnectingText(); // 使用轮播文本
         textColor = Get.reactiveTheme.hintColor;
         // 只在状态改变时执行一次切换位置
-        if (_previousState != ConnectionState.connecting) {
+        if (_previousState != ConnectionState.connectingVirtual &&
+            _previousState != ConnectionState.connecting) {
           WidgetsBinding.instance.addPostFrameCallback((_) {
             if (mounted) {
               setState(() {
@@ -196,6 +200,17 @@ class _ConnectionButtonState extends State<ConnectionButton>
           _startConnectingTimer();
         }
         break;
+      case ConnectionState.disconnecting:
+        gradientColor = [
+          Get.reactiveTheme.highlightColor,
+          Get.reactiveTheme.hintColor,
+        ];
+        imgPath = Assets.switchStatusConnecting;
+        statusImgPath = Assets.connecting;
+        text = Strings.disconnecting.tr;
+        textColor = Get.reactiveTheme.hintColor;
+        _stopConnectingTimer(); // 停止计时器
+        break;
       case ConnectionState.connected:
         gradientColor = [
           Get.reactiveTheme.shadowColor,

+ 59 - 20
lib/app/modules/home/widgets/connection_round_button.dart

@@ -38,7 +38,9 @@ class RingPainter extends CustomPainter {
       case ConnectionState.error:
         _drawDashedRing(canvas, center, radius, strokeWidth);
         break;
+      case ConnectionState.connectingVirtual:
       case ConnectionState.connecting:
+      case ConnectionState.disconnecting:
         _drawGradientRing(canvas, center, radius, strokeWidth, true);
         break;
       case ConnectionState.connected:
@@ -222,10 +224,15 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
       value: 1.0,
     );
 
-    // 如果初始状态是 connecting,启动动画
-    if (widget.state == ConnectionState.connecting) {
+    // 如果初始状态是 connectingVirtual/connecting 或 disconnecting,启动动画
+    if (widget.state == ConnectionState.connectingVirtual ||
+        widget.state == ConnectionState.connecting ||
+        widget.state == ConnectionState.disconnecting) {
       _rotationController.repeat();
-      _startConnectingTimer();
+      if (widget.state == ConnectionState.connectingVirtual ||
+          widget.state == ConnectionState.connecting) {
+        _startConnectingTimer();
+      }
     }
   }
 
@@ -239,16 +246,23 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
   }
 
   void _handleStateChange(ConnectionState oldState) {
-    if (widget.state == ConnectionState.connecting) {
+    if (widget.state == ConnectionState.connectingVirtual ||
+        widget.state == ConnectionState.connecting ||
+        widget.state == ConnectionState.disconnecting) {
       _isStoppingRotation = false;
       if (!_rotationController.isAnimating) {
         _rotationController.repeat();
       }
-      if (_connectingTimer == null || !_connectingTimer!.isActive) {
-        _startConnectingTimer();
+      if (widget.state == ConnectionState.connectingVirtual ||
+          widget.state == ConnectionState.connecting) {
+        if (_connectingTimer == null || !_connectingTimer!.isActive) {
+          _startConnectingTimer();
+        }
+      } else {
+        _stopConnectingTimer();
       }
     } else {
-      // 从连接中切换到其他状态时,平滑停止旋转
+      // 从连接中/断开中切换到其他状态时,平滑停止旋转
       if (_rotationController.isAnimating && !_isStoppingRotation) {
         _isStoppingRotation = true;
         // 计算剩余角度,让动画平滑停止在顶部(0度位置)
@@ -336,8 +350,11 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
       case ConnectionState.disconnected:
       case ConnectionState.error:
         return Get.reactiveTheme.hintColor.withValues(alpha: 0.5);
+      case ConnectionState.connectingVirtual:
       case ConnectionState.connecting:
         return const Color(0xFF4FC3F7); // 浅蓝色
+      case ConnectionState.disconnecting:
+        return const Color(0xFFFF7043); // 橙色
       case ConnectionState.connected:
         return const Color(0xFF66BB6A); // 绿色
     }
@@ -349,8 +366,11 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
       case ConnectionState.disconnected:
       case ConnectionState.error:
         return Get.reactiveTheme.hintColor.withValues(alpha: 0.3);
+      case ConnectionState.connectingVirtual:
       case ConnectionState.connecting:
         return const Color(0xFF7E57C2); // 紫色
+      case ConnectionState.disconnecting:
+        return const Color(0xFFFFCA28); // 黄色
       case ConnectionState.connected:
         return const Color(0xFF26C6DA); // 青色
     }
@@ -400,14 +420,31 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
     );
   }
 
-  // 构建电源图标
-  Widget _buildPowerIcon(bool isConnected) {
+  // 根据状态和主题获取中心图片路径
+  String _getCenterImagePath(ConnectionState state) {
+    final isDark = Get.isDarkMode;
+    switch (state) {
+      case ConnectionState.disconnected:
+      case ConnectionState.error:
+        return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
+      case ConnectionState.connectingVirtual:
+      case ConnectionState.connecting:
+      case ConnectionState.disconnecting:
+        return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
+      case ConnectionState.connected:
+        return isDark ? Assets.darkConnected : Assets.lightConnected;
+    }
+  }
+
+  // 构建中心图片
+  Widget _buildCenterImage(ConnectionState state) {
+    final imagePath = _getCenterImagePath(state);
     return IXImage(
-      key: ValueKey('power_icon_$isConnected'),
-      source: isConnected ? Assets.connectedSwitch : Assets.disconnectedSwitch,
+      key: ValueKey('center_image_${state}_${Get.isDarkMode}'),
+      source: imagePath,
       sourceType: ImageSourceType.asset,
-      width: 48.w,
-      height: 48.w,
+      width: 40.w,
+      height: 40.w,
     );
   }
 
@@ -421,7 +458,6 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
     String statusImgPath;
     String text;
     Color textColor;
-    bool isConnected;
     bool shouldRotate;
 
     switch (widget.state) {
@@ -429,28 +465,31 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
         statusImgPath = Assets.disconnected;
         text = Strings.disconnected.tr;
         textColor = Get.reactiveTheme.hintColor;
-        isConnected = false;
         shouldRotate = false;
         break;
+      case ConnectionState.connectingVirtual:
       case ConnectionState.connecting:
         statusImgPath = Assets.connecting;
         text = _getConnectingText(); // 使用轮播文本
         textColor = Get.reactiveTheme.hintColor;
-        isConnected = false;
+        shouldRotate = true;
+        break;
+      case ConnectionState.disconnecting:
+        statusImgPath = Assets.connecting;
+        text = Strings.disconnecting.tr;
+        textColor = Get.reactiveTheme.hintColor;
         shouldRotate = true;
         break;
       case ConnectionState.connected:
         statusImgPath = Assets.connected;
         text = Strings.connected.tr;
         textColor = Get.reactiveTheme.textTheme.bodyLarge!.color!;
-        isConnected = true;
         shouldRotate = false;
         break;
       case ConnectionState.error:
         statusImgPath = Assets.error;
         text = Strings.error.tr;
         textColor = Get.reactiveTheme.hintColor;
-        isConnected = false;
         shouldRotate = false;
         break;
     }
@@ -499,7 +538,7 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
                 },
                 child: _buildRoundRing(widget.state, shouldRotate),
               ),
-              // 电源图标 - 纯淡入淡出
+              // 中心图片 - 纯淡入淡出
               AnimatedSwitcher(
                 duration: const Duration(milliseconds: 500),
                 switchInCurve: Curves.easeInOut,
@@ -522,7 +561,7 @@ class _ConnectionRoundButtonState extends State<ConnectionRoundButton>
                     ],
                   );
                 },
-                child: _buildPowerIcon(isConnected),
+                child: _buildCenterImage(widget.state),
               ),
             ],
           ),

+ 592 - 0
lib/app/modules/home/widgets/connection_theme_button.dart

@@ -0,0 +1,592 @@
+import 'package:flutter/material.dart' hide ConnectionState;
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'dart:math' as math;
+import 'dart:async';
+import 'package:get/get.dart';
+import 'package:nomo/app/widgets/ix_image.dart';
+
+import '../../../constants/assets.dart';
+import '../../../../config/theme/theme_extensions/theme_extension.dart';
+import '../../../../config/translations/strings_enum.dart';
+import '../../../constants/enums.dart';
+
+/// 连接中状态的流光边框画笔
+class ConnectingBorderPainter extends CustomPainter {
+  final double rotationAngle;
+  final Color primaryColor;
+  final Color secondaryColor;
+
+  ConnectingBorderPainter({
+    required this.rotationAngle,
+    required this.primaryColor,
+    required this.secondaryColor,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    final center = Offset(size.width / 2, size.height / 2);
+    final radius = size.width / 2 - 2;
+    const strokeWidth = 4.0;
+
+    final rect = Rect.fromCircle(center: center, radius: radius);
+
+    // 连接中状态:绘制平滑的流光尾巴效果
+    final gradient = SweepGradient(
+      startAngle: 0,
+      endAngle: 2 * math.pi,
+      colors: [
+        primaryColor.withValues(alpha: 0.0),
+        primaryColor.withValues(alpha: 0.05),
+        primaryColor.withValues(alpha: 0.2),
+        primaryColor.withValues(alpha: 0.5),
+        primaryColor.withValues(alpha: 0.8),
+        primaryColor,
+        secondaryColor,
+        secondaryColor.withValues(alpha: 0.8),
+        secondaryColor.withValues(alpha: 0.4),
+        secondaryColor.withValues(alpha: 0.1),
+        primaryColor.withValues(alpha: 0.0),
+      ],
+      stops: const [0.0, 0.1, 0.2, 0.35, 0.45, 0.5, 0.55, 0.65, 0.8, 0.9, 1.0],
+      transform: GradientRotation(rotationAngle - math.pi / 2),
+    );
+
+    final paint = Paint()
+      ..shader = gradient.createShader(rect)
+      ..style = PaintingStyle.stroke
+      ..strokeWidth = strokeWidth
+      ..strokeCap = StrokeCap.round
+      ..isAntiAlias = true;
+
+    canvas.drawCircle(center, radius, paint);
+  }
+
+  @override
+  bool shouldRepaint(covariant ConnectingBorderPainter oldDelegate) {
+    return oldDelegate.rotationAngle != rotationAngle ||
+        oldDelegate.primaryColor != primaryColor ||
+        oldDelegate.secondaryColor != secondaryColor;
+  }
+}
+
+class ConnectionThemeButton extends StatefulWidget {
+  final ConnectionState state;
+  final VoidCallback? onTap;
+
+  const ConnectionThemeButton({super.key, required this.state, this.onTap});
+
+  @override
+  State<ConnectionThemeButton> createState() => _ConnectionThemeButtonState();
+}
+
+class _ConnectionThemeButtonState extends State<ConnectionThemeButton>
+    with TickerProviderStateMixin {
+  late AnimationController _rotationController; // 旋转动画控制器
+  late AnimationController _fadeController; // 淡入淡出控制器
+  Timer? _connectingTimer; // 连接中状态的计时器
+  int _connectingTextIndex = 0; // 当前显示的连接文本索引(0-4)
+  ConnectionState? _previousState; // 保存前一个连接状态
+  bool _isStoppingRotation = false; // 是否正在停止旋转
+
+  @override
+  void initState() {
+    super.initState();
+    // 初始化旋转动画控制器
+    _rotationController = AnimationController(
+      duration: const Duration(milliseconds: 500),
+      vsync: this,
+    );
+
+    // 初始化淡入淡出控制器
+    _fadeController = AnimationController(
+      duration: const Duration(milliseconds: 600),
+      vsync: this,
+      value: 1.0,
+    );
+
+    // 如果初始状态是 connectingVirtual/connecting 或 disconnecting,启动动画
+    if (widget.state == ConnectionState.connectingVirtual ||
+        widget.state == ConnectionState.connecting ||
+        widget.state == ConnectionState.disconnecting) {
+      _rotationController.repeat();
+      if (widget.state == ConnectionState.connectingVirtual ||
+          widget.state == ConnectionState.connecting) {
+        _startConnectingTimer();
+      }
+    }
+  }
+
+  @override
+  void didUpdateWidget(ConnectionThemeButton oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // 处理状态变化
+    if (oldWidget.state != widget.state) {
+      _handleStateChange(oldWidget.state);
+    }
+  }
+
+  void _handleStateChange(ConnectionState oldState) {
+    if (widget.state == ConnectionState.connectingVirtual ||
+        widget.state == ConnectionState.connecting ||
+        widget.state == ConnectionState.disconnecting) {
+      // 进入旋转状态时,确保立即开始旋转
+      _isStoppingRotation = false;
+      // 先停止任何正在进行的动画,然后立即重新开始
+      _rotationController.stop();
+      _rotationController.repeat();
+
+      if (widget.state == ConnectionState.connectingVirtual ||
+          widget.state == ConnectionState.connecting) {
+        if (_connectingTimer == null || !_connectingTimer!.isActive) {
+          _startConnectingTimer();
+        }
+      } else {
+        _stopConnectingTimer();
+      }
+    } else {
+      // 从连接中/断开中切换到其他状态时,平滑停止旋转
+      if (_rotationController.isAnimating && !_isStoppingRotation) {
+        _isStoppingRotation = true;
+        // 计算剩余角度,让动画平滑停止在顶部(0度位置)
+        final currentValue = _rotationController.value;
+        // 停止重复,然后平滑减速到完整的一圈
+        _rotationController.stop();
+        _rotationController
+            .animateTo(
+              1.0,
+              duration: Duration(
+                milliseconds: ((1.0 - currentValue) * 400).toInt().clamp(
+                  100,
+                  400,
+                ),
+              ),
+              curve: Curves.easeOutCubic,
+            )
+            .then((_) {
+              if (mounted) {
+                _rotationController.reset();
+                _isStoppingRotation = false;
+              }
+            });
+      }
+      _stopConnectingTimer();
+    }
+  }
+
+  @override
+  void dispose() {
+    _rotationController.dispose();
+    _fadeController.dispose();
+    _connectingTimer?.cancel();
+    super.dispose();
+  }
+
+  void _onTap() {
+    if (widget.onTap != null) {
+      widget.onTap!();
+    }
+  }
+
+  // 启动连接中状态的文本轮播计时器
+  void _startConnectingTimer() {
+    _connectingTimer?.cancel();
+    _connectingTextIndex = -1;
+    _connectingTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
+      if (mounted) {
+        setState(() {
+          // 每秒切换到下一个文本,循环显示0-4
+          _connectingTextIndex = (_connectingTextIndex + 1) % 5;
+        });
+      }
+    });
+  }
+
+  // 停止连接中状态的文本轮播计时器
+  void _stopConnectingTimer() {
+    _connectingTimer?.cancel();
+    _connectingTimer = null;
+    _connectingTextIndex = 0;
+  }
+
+  // 根据索引获取对应的连接文本
+  String _getConnectingText() {
+    switch (_connectingTextIndex) {
+      case 0:
+        return Strings.securingData.tr;
+      case 1:
+        return Strings.encryptingTraffic.tr;
+      case 2:
+        return Strings.protectingPrivacy.tr;
+      case 3:
+        return Strings.safeConnection.tr;
+      case 4:
+        return Strings.yourDataIsSafe.tr;
+      default:
+        return Strings.connecting.tr;
+    }
+  }
+
+  // 连接中状态的流光边框颜色
+  static const Color _connectingPrimaryColor = Color(0xFF4FC3F7); // 浅蓝色
+  static const Color _connectingSecondaryColor = Color(0xFF7E57C2); // 紫色
+
+  // 断开中状态的流光边框颜色
+  static const Color _disconnectingPrimaryColor = Color(0xFFFF7043); // 橙色
+  static const Color _disconnectingSecondaryColor = Color(0xFFFFCA28); // 黄色
+
+  // 连接成功状态的渐变色
+  static const Color _connectedGradientStart = Color(0xFF0EA5E9);
+  static const Color _connectedGradientEnd = Color(0xFF3B82F6);
+
+  // 连接成功状态的阴影色
+  static const Color _connectedShadowColor = Color(
+    0x660B84FE,
+  ); // rgba(11, 132, 254, 0.40)
+
+  // 断开状态的阴影色
+  static const Color _disconnectedShadowColor = Color(
+    0x14000000,
+  ); // rgba(0, 0, 0, 0.08)
+
+  // 构建流光动画背景(连接中/断开中状态共用)
+  Widget _buildAnimatingBackground({
+    required double size,
+    required ValueKey key,
+    required Color primaryColor,
+    required Color secondaryColor,
+  }) {
+    return Container(
+      key: key,
+      width: size,
+      height: size,
+      decoration: BoxDecoration(
+        color: Get.reactiveTheme.highlightColor,
+        shape: BoxShape.circle,
+      ),
+      child: Stack(
+        alignment: Alignment.center,
+        children: [
+          // 流光边框
+          AnimatedBuilder(
+            animation: _rotationController,
+            builder: (context, child) {
+              return CustomPaint(
+                size: Size(size, size),
+                painter: ConnectingBorderPainter(
+                  rotationAngle: _rotationController.value * 2 * math.pi,
+                  primaryColor: primaryColor,
+                  secondaryColor: secondaryColor,
+                ),
+              );
+            },
+          ),
+          // 内层背景圆(遮住边框内侧)
+          Container(
+            width: size - 8.w,
+            height: size - 8.w,
+            decoration: BoxDecoration(
+              color: Get.reactiveTheme.highlightColor,
+              shape: BoxShape.circle,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  // 构建圆形按钮背景
+  Widget _buildButtonBackground(ConnectionState state, bool shouldRotate) {
+    final size = 170.w;
+
+    switch (state) {
+      case ConnectionState.disconnected:
+      case ConnectionState.error:
+        // 断开状态:highlightColor 背景 + 4.w dividerColor 边框 + 阴影
+        return Container(
+          key: ValueKey('bg_disconnected_$state'),
+          width: size,
+          height: size,
+          decoration: BoxDecoration(
+            color: Get.reactiveTheme.highlightColor,
+            shape: BoxShape.circle,
+            border: Border.all(
+              color: Get.reactiveTheme.dividerColor,
+              width: 4.w,
+            ),
+            boxShadow: [
+              BoxShadow(
+                color: _disconnectedShadowColor,
+                offset: const Offset(0, 8),
+                blurRadius: 24,
+                spreadRadius: 0,
+              ),
+            ],
+          ),
+        );
+
+      case ConnectionState.connectingVirtual:
+      case ConnectionState.connecting:
+        // 连接中状态:highlightColor 背景 + 蓝紫色流光边框
+        return _buildAnimatingBackground(
+          size: size,
+          key: const ValueKey('bg_connecting'),
+          primaryColor: _connectingPrimaryColor,
+          secondaryColor: _connectingSecondaryColor,
+        );
+
+      case ConnectionState.disconnecting:
+        // 断开中状态:highlightColor 背景 + 橙黄色流光边框
+        return _buildAnimatingBackground(
+          size: size,
+          key: const ValueKey('bg_disconnecting'),
+          primaryColor: _disconnectingPrimaryColor,
+          secondaryColor: _disconnectingSecondaryColor,
+        );
+
+      case ConnectionState.connected:
+        // 连接成功状态:渐变背景 + 蓝色阴影
+        return Container(
+          key: const ValueKey('bg_connected'),
+          width: size,
+          height: size,
+          decoration: BoxDecoration(
+            gradient: const LinearGradient(
+              begin: Alignment.topLeft,
+              end: Alignment.bottomRight,
+              colors: [_connectedGradientStart, _connectedGradientEnd],
+            ),
+            shape: BoxShape.circle,
+            boxShadow: [
+              BoxShadow(
+                color: _connectedShadowColor,
+                offset: const Offset(0, 8),
+                blurRadius: 20,
+                spreadRadius: 0,
+              ),
+            ],
+          ),
+        );
+    }
+  }
+
+  // 根据状态和主题获取中心图片路径
+  String _getCenterImagePath(ConnectionState state) {
+    final isDark = Get.isDarkMode;
+    switch (state) {
+      case ConnectionState.disconnected:
+      case ConnectionState.error:
+        return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
+      case ConnectionState.connectingVirtual:
+      case ConnectionState.connecting:
+      case ConnectionState.disconnecting:
+        return isDark ? Assets.darkDisconnected : Assets.lightDisconnected;
+      case ConnectionState.connected:
+        // 连接成功时使用白色图标(因为背景是蓝色渐变)
+        return Assets.darkConnected;
+    }
+  }
+
+  // 获取中心图片的 key(相同图片使用相同 key,避免不必要的动画)
+  String _getCenterImageKey(ConnectionState state) {
+    switch (state) {
+      case ConnectionState.disconnected:
+      case ConnectionState.error:
+        return 'center_disconnected_${Get.isDarkMode}';
+      case ConnectionState.connectingVirtual:
+      case ConnectionState.connecting:
+      case ConnectionState.disconnecting:
+        return 'center_connecting_${Get.isDarkMode}';
+      case ConnectionState.connected:
+        return 'center_connected';
+    }
+  }
+
+  // 构建中心图片
+  Widget _buildCenterImage(ConnectionState state) {
+    final imagePath = _getCenterImagePath(state);
+    return IXImage(
+      key: ValueKey(_getCenterImageKey(state)),
+      source: imagePath,
+      sourceType: ImageSourceType.asset,
+      width: 40.w,
+      height: 40.w,
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(onTap: _onTap, child: _buildMainButton());
+  }
+
+  Widget _buildMainButton() {
+    // 根据状态获取对应的资源和样式
+    String statusImgPath;
+    String text;
+    Color textColor;
+    bool shouldRotate;
+
+    switch (widget.state) {
+      case ConnectionState.disconnected:
+        statusImgPath = Assets.disconnected;
+        text = Strings.disconnected.tr;
+        textColor = Get.reactiveTheme.hintColor;
+        shouldRotate = false;
+        break;
+      case ConnectionState.connectingVirtual:
+      case ConnectionState.connecting:
+        statusImgPath = Assets.connecting;
+        text = _getConnectingText(); // 使用轮播文本
+        textColor = Get.reactiveTheme.hintColor;
+        shouldRotate = true;
+        break;
+      case ConnectionState.disconnecting:
+        statusImgPath = Assets.connecting;
+        text = Strings.disconnecting.tr;
+        textColor = Get.reactiveTheme.hintColor;
+        shouldRotate = true;
+        break;
+      case ConnectionState.connected:
+        statusImgPath = Assets.connected;
+        text = Strings.connected.tr;
+        textColor = Get.reactiveTheme.textTheme.bodyLarge!.color!;
+        shouldRotate = false;
+        break;
+      case ConnectionState.error:
+        statusImgPath = Assets.error;
+        text = Strings.error.tr;
+        textColor = Get.reactiveTheme.hintColor;
+        shouldRotate = false;
+        break;
+    }
+
+    // 更新前一个状态
+    if (_previousState != widget.state) {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        if (mounted) {
+          _previousState = widget.state;
+        }
+      });
+    }
+
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        // 圆形按钮区域
+        SizedBox(
+          width: 170.w,
+          height: 170.w,
+          child: Stack(
+            alignment: Alignment.center,
+            children: [
+              // 按钮背景 - 使用纯淡入淡出
+              AnimatedSwitcher(
+                duration: const Duration(milliseconds: 600),
+                switchInCurve: Curves.easeInOut,
+                switchOutCurve: Curves.easeInOut,
+                transitionBuilder: (Widget child, Animation<double> animation) {
+                  return FadeTransition(
+                    opacity: CurvedAnimation(
+                      parent: animation,
+                      curve: Curves.easeInOut,
+                    ),
+                    child: child,
+                  );
+                },
+                layoutBuilder: (currentChild, previousChildren) {
+                  return Stack(
+                    alignment: Alignment.center,
+                    children: [
+                      ...previousChildren,
+                      if (currentChild != null) currentChild,
+                    ],
+                  );
+                },
+                child: _buildButtonBackground(widget.state, shouldRotate),
+              ),
+              // 中心图片 - 纯淡入淡出
+              AnimatedSwitcher(
+                duration: const Duration(milliseconds: 500),
+                switchInCurve: Curves.easeInOut,
+                switchOutCurve: Curves.easeInOut,
+                transitionBuilder: (Widget child, Animation<double> animation) {
+                  return FadeTransition(
+                    opacity: CurvedAnimation(
+                      parent: animation,
+                      curve: Curves.easeInOut,
+                    ),
+                    child: child,
+                  );
+                },
+                layoutBuilder: (currentChild, previousChildren) {
+                  return Stack(
+                    alignment: Alignment.center,
+                    children: [
+                      ...previousChildren,
+                      if (currentChild != null) currentChild,
+                    ],
+                  );
+                },
+                child: _buildCenterImage(widget.state),
+              ),
+            ],
+          ),
+        ),
+        20.verticalSpaceFromWidth,
+        // 状态文字
+        AnimatedSwitcher(
+          duration: const Duration(milliseconds: 350),
+          switchInCurve: Curves.easeOutCubic,
+          switchOutCurve: Curves.easeInCubic,
+          transitionBuilder: (Widget child, Animation<double> animation) {
+            return FadeTransition(
+              opacity: CurvedAnimation(
+                parent: animation,
+                curve: Curves.easeInOut,
+              ),
+              child: SlideTransition(
+                position:
+                    Tween<Offset>(
+                      begin: const Offset(0, 0.15),
+                      end: Offset.zero,
+                    ).animate(
+                      CurvedAnimation(
+                        parent: animation,
+                        curve: Curves.easeOutCubic,
+                      ),
+                    ),
+                child: child,
+              ),
+            );
+          },
+          child: SizedBox(
+            key: ValueKey('status_$text'), // 使用文本作为 key,确保文字改变时触发动画
+            height: 20.w,
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.center,
+              crossAxisAlignment: CrossAxisAlignment.center,
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                IXImage(
+                  source: statusImgPath,
+                  sourceType: ImageSourceType.asset,
+                  width: 14.w,
+                  height: 14.w,
+                ),
+                4.horizontalSpace,
+                Text(
+                  text,
+                  style: TextStyle(
+                    fontSize: 14.sp,
+                    fontWeight: FontWeight.w500,
+                    height: 1.4,
+                    color: textColor,
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 5 - 0
lib/app/modules/home/widgets/menu_list.dart

@@ -4,6 +4,7 @@ import 'package:get/get.dart';
 import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
 
 import '../../../data/models/banner/banner_list.dart';
+import '../../../data/sp/ix_sp.dart';
 import '../../../widgets/ix_image.dart';
 import '../controllers/home_controller.dart';
 
@@ -92,6 +93,7 @@ class MenuList extends StatelessWidget {
 
   /// 构建单个菜单项
   Widget _buildMenuItem(Banner banner, HomeController controller) {
+    final isLight = IXSP.getThemeIsLight();
     return GestureDetector(
       onTap: () => controller.onBannerTap(banner),
       child: Container(
@@ -99,6 +101,9 @@ class MenuList extends StatelessWidget {
         decoration: BoxDecoration(
           color: Get.reactiveTheme.cardColor,
           borderRadius: BorderRadius.circular(10.r),
+          border: isLight
+              ? Border.all(color: Get.reactiveTheme.dividerColor, width: 1.w)
+              : null,
         ),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,

+ 36 - 0
lib/app/modules/setting/views/setting_view.dart

@@ -408,6 +408,42 @@ class SettingView extends BaseView<SettingController> {
               },
             ),
             _buildDivider(),
+            _buildSettingItem(
+              svgPath: Assets.settingsTheme,
+              iconColor: DarkThemeColors.settingAppLinearGradientStartColor,
+              iconGradient: LinearGradient(
+                colors: [
+                  DarkThemeColors.settingAppLinearGradientStartColor,
+                  DarkThemeColors.settingAppLinearGradientEndColor,
+                ],
+                begin: Alignment.topCenter,
+                end: Alignment.bottomCenter,
+              ),
+              title: Strings.theme.tr,
+              trailing: Row(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Text(
+                    '',
+                    style: TextStyle(
+                      fontSize: 13.sp,
+                      color: Get.reactiveTheme.hintColor,
+                    ),
+                  ),
+                  8.horizontalSpace,
+                  Icon(
+                    IconFont.icon02,
+                    size: 20.w,
+                    color: Get.reactiveTheme.hintColor,
+                  ),
+                ],
+              ),
+              onTap: () {
+                // TODO: 跳转到语言选择页面
+                Get.toNamed(Routes.THEME);
+              },
+            ),
+            _buildDivider(),
             _buildSettingItem(
               icon: IconFont.icon37,
               iconColor: DarkThemeColors.settingAppLinearGradientStartColor,

+ 10 - 0
lib/app/modules/theme/bindings/theme_binding.dart

@@ -0,0 +1,10 @@
+import 'package:get/get.dart';
+
+import '../controllers/theme_controller.dart';
+
+class ThemeBinding extends Binding {
+  @override
+  List<Bind> dependencies() {
+    return [Bind.lazyPut(() => ThemeController())];
+  }
+}

+ 75 - 0
lib/app/modules/theme/controllers/theme_controller.dart

@@ -0,0 +1,75 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+import 'package:get/get.dart';
+
+import '../../../../config/translations/strings_enum.dart';
+import '../../../data/sp/ix_sp.dart';
+
+/// 主题模式信息
+class ThemeModeInfo {
+  final String code; // 'system', 'dark', 'light'
+  final String name;
+
+  ThemeModeInfo({required this.code, required this.name});
+}
+
+class ThemeController extends GetxController {
+  /// 当前选中的主题模式
+  final selectedMode = 'system'.obs;
+
+  /// 主题模式列表
+  List<ThemeModeInfo> get themeModes => [
+    ThemeModeInfo(code: 'system', name: Strings.followSystem.tr),
+    ThemeModeInfo(code: 'dark', name: Strings.darkMode.tr),
+    ThemeModeInfo(code: 'light', name: Strings.lightMode.tr),
+  ];
+
+  @override
+  void onInit() {
+    super.onInit();
+    // 加载当前主题模式
+    selectedMode.value = IXSP.getThemeMode();
+  }
+
+  /// 选择主题模式
+  void selectThemeMode(String mode) {
+    if (selectedMode.value == mode) return;
+
+    selectedMode.value = mode;
+    IXSP.setThemeMode(mode);
+
+    // 应用主题
+    _applyTheme(mode);
+  }
+
+  /// 应用主题
+  void _applyTheme(String mode) {
+    ThemeMode themeMode;
+    bool isLight;
+
+    switch (mode) {
+      case 'light':
+        themeMode = ThemeMode.light;
+        isLight = true;
+        break;
+      case 'dark':
+        themeMode = ThemeMode.dark;
+        isLight = false;
+        break;
+      case 'system':
+      default:
+        themeMode = ThemeMode.system;
+        // 获取系统当前的主题
+        final brightness =
+            SchedulerBinding.instance.platformDispatcher.platformBrightness;
+        isLight = brightness == Brightness.light;
+        break;
+    }
+
+    // 更新 GetX 主题
+    Get.changeThemeMode(themeMode);
+
+    // 同步更新 legacy 的 isLight 标记(兼容旧代码)
+    IXSP.setThemeIsLight(isLight);
+  }
+}

+ 112 - 0
lib/app/modules/theme/views/theme_view.dart

@@ -0,0 +1,112 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:get/get.dart';
+import 'package:nomo/app/base/base_view.dart';
+import 'package:nomo/app/widgets/click_opacity.dart';
+import 'package:nomo/app/widgets/ix_app_bar.dart';
+import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
+
+import '../../../../config/translations/strings_enum.dart';
+import '../controllers/theme_controller.dart';
+
+class ThemeView extends BaseView<ThemeController> {
+  const ThemeView({super.key});
+
+  @override
+  PreferredSizeWidget? get appBar => IXAppBar(title: Strings.theme.tr);
+
+  @override
+  Widget buildContent(BuildContext context) {
+    return SingleChildScrollView(
+      child: Padding(
+        padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.w),
+        child: Container(
+          decoration: BoxDecoration(
+            color: Get.reactiveTheme.highlightColor,
+            borderRadius: BorderRadius.circular(12.r),
+          ),
+          child: Obx(
+            () => Column(
+              children: controller.themeModes.asMap().entries.map((entry) {
+                final index = entry.key;
+                final themeMode = entry.value;
+                final isSelected =
+                    controller.selectedMode.value == themeMode.code;
+
+                return Column(
+                  children: [
+                    _buildThemeOption(
+                      themeMode: themeMode,
+                      isSelected: isSelected,
+                      onTap: () => controller.selectThemeMode(themeMode.code),
+                    ),
+                    if (index < controller.themeModes.length - 1)
+                      _buildDivider(),
+                  ],
+                );
+              }).toList(),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  /// 构建主题选项
+  Widget _buildThemeOption({
+    required ThemeModeInfo themeMode,
+    required bool isSelected,
+    required VoidCallback onTap,
+  }) {
+    return ClickOpacity(
+      onTap: onTap,
+      child: Padding(
+        padding: EdgeInsets.all(14.w),
+        child: Row(
+          children: [
+            // 主题名称
+            Expanded(
+              child: Text(
+                themeMode.name,
+                style: TextStyle(
+                  fontSize: 14.sp,
+                  height: 1.4,
+                  fontWeight: FontWeight.w600,
+                  color: isSelected
+                      ? Get.reactiveTheme.shadowColor
+                      : Get.reactiveTheme.textTheme.bodyLarge!.color,
+                ),
+              ),
+            ),
+
+            // 选中指示器
+            Container(
+              width: 20.w,
+              height: 20.w,
+              decoration: BoxDecoration(
+                shape: BoxShape.circle,
+                color: isSelected
+                    ? Get.reactiveTheme.shadowColor
+                    : Colors.transparent,
+                border: Border.all(
+                  color: isSelected
+                      ? Get.reactiveTheme.shadowColor
+                      : Colors.grey[400]!,
+                  width: 1.5.w,
+                ),
+              ),
+              child: isSelected
+                  ? Icon(Icons.check, color: Colors.white, size: 16.w)
+                  : null,
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  /// 构建分割线
+  Widget _buildDivider() {
+    return Divider(color: Get.reactiveTheme.dividerColor, height: 1.w);
+  }
+}

+ 9 - 0
lib/app/routes/app_pages.dart

@@ -42,6 +42,8 @@ import '../modules/splittunneling/selectapp/views/splittunneling_selectapp_view.
 import '../modules/splittunneling/views/splittunneling_view.dart';
 import '../modules/subscription/bindings/subscription_binding.dart';
 import '../modules/subscription/views/subscription_view.dart';
+import '../modules/theme/bindings/theme_binding.dart';
+import '../modules/theme/views/theme_view.dart';
 import '../modules/web/bindings/web_binding.dart';
 import '../modules/web/views/web_view.dart';
 
@@ -204,6 +206,13 @@ class AppPages {
       transition: Transition.downToUp,
       curve: Curves.easeInOut,
     ),
+    GetPage(
+      name: _Paths.THEME,
+      page: () => const ThemeView(),
+      binding: ThemeBinding(),
+      transition: Transition.native,
+      curve: Curves.easeInOut,
+    ),
   ];
 
   /// 私有构造函数,防止被实例化

+ 2 - 0
lib/app/routes/app_routes.dart

@@ -25,6 +25,7 @@ abstract class Routes {
   static const LOGIN = _Paths.LOGIN;
   static const SUBSCRIPTION = _Paths.SUBSCRIPTION;
   static const MEDIALOCATION = _Paths.MEDIALOCATION;
+  static const THEME = _Paths.THEME;
 }
 
 abstract class _Paths {
@@ -50,4 +51,5 @@ abstract class _Paths {
   static const LOGIN = '/login';
   static const SUBSCRIPTION = '/subscription';
   static const MEDIALOCATION = '/medialocation';
+  static const THEME = '/theme';
 }

+ 2 - 2
lib/config/theme/dark_theme_colors.dart

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
 // TODO add your dark theme colors palette
 class DarkThemeColors {
   // PRIMARY
-  static const Color primaryColor = Color(0xFF0086FF);
-  static const Color shadowColor = Color(0xFF00ABF5);
+  static const Color primaryColor = Color(0xFF3B82F6);
+  static const Color shadowColor = Color(0xFF0EA5E9);
 
   // SECONDARY
   static Color accentColor = Colors.white;

+ 9 - 9
lib/config/theme/light_theme_colors.dart

@@ -3,8 +3,8 @@ import 'package:flutter/material.dart';
 // TODO add your light theme colors palette
 class LightThemeColors {
   // PRIMARY
-  static const Color primaryColor = Color(0xFF0086FF);
-  static const Color shadowColor = Color(0xFF00ABF5);
+  static const Color primaryColor = Color(0xFF3B82F6);
+  static const Color shadowColor = Color(0xFF0EA5E9);
 
   // SECONDARY COLOR
   static const Color accentColor = Colors.black;
@@ -71,15 +71,15 @@ class LightThemeColors {
   static const Color bottomBarUnselectedColor = Color(0xFF51535D);
   static const Color bottomBarBackgroundColor = Color(0xFFFFFFFF);
 
-  static const Color bg1 = Color(0xFFFFFFFF);
-  static const Color bg2 = Color(0xFFEFEFEF);
-  static const Color bg3 = Color(0xFFF9FAFC);
+  static const Color bg1 = Color(0xFFEFF1F5);
+  static const Color bg2 = Color(0xFFFFFFFF);
+  static const Color bg3 = Color(0xFFFFFFFF);
   static const Color bgDisable = Color(0xFFEFEFEF);
   static const Color bgScrim = Color(0x99000000);
-  static const Color text1 = Color(0xFF000000);
+  static const Color text1 = Color(0xFF1A1C21);
   static const Color text2 = Color(0xFF646776);
-  static const Color textDisable = Color(0xFFCECECE);
+  static const Color textDisable = Color(0xFFB0B3B8);
   static const Color textBrand = Color(0xFFFFFFFF);
-  static const Color strokes1 = Color(0xFFE6E7EB);
-  static const Color strokes2 = Color(0xFFF2F1F4);
+  static const Color strokes1 = Color(0xFFDCDFE6);
+  static const Color strokes2 = Color(0xFFEBEDF0);
 }

+ 1 - 0
lib/config/translations/ar_AR/ar_ar_translation.dart

@@ -73,6 +73,7 @@ final Map<String, String> arAR = {
   Strings.disconnected: 'غير متصل',
   Strings.open: 'فتح',
   Strings.disconnect: 'قطع الاتصال',
+  Strings.disconnecting: 'جاري قطع الاتصال...',
   Strings.connect: 'اتصال',
   Strings.opening: 'جاري الفتح',
   Strings.connectedSuccessfully: 'تم الاتصال بنجاح',

+ 1 - 0
lib/config/translations/de_DE/de_de_translation.dart

@@ -75,6 +75,7 @@ const Map<String, String> deDE = {
   Strings.disconnected: 'Getrennt',
   Strings.open: 'Öffnen',
   Strings.disconnect: 'Trennen',
+  Strings.disconnecting: 'Trennung läuft...',
   Strings.connect: 'Verbinden',
   Strings.opening: 'Wird geöffnet',
   Strings.connectedSuccessfully: 'Erfolgreich verbunden',

+ 7 - 0
lib/config/translations/en_US/en_us_translation.dart

@@ -78,6 +78,7 @@ Map<String, String> enUs = {
   Strings.disconnected: 'Disconnected',
   Strings.open: 'Open',
   Strings.disconnect: 'Disconnect',
+  Strings.disconnecting: 'Disconnecting...',
   Strings.connect: 'Connect',
   Strings.opening: 'Opening',
   Strings.connectedSuccessfully: 'Connected successfully',
@@ -467,4 +468,10 @@ Map<String, String> enUs = {
   Strings.pleaseEnterValidEmail: 'Please enter a valid email address',
   Strings.feedbackSubmitted: 'Feedback submitted, we will reply to you soon',
   Strings.feedbackSubmitFailed: 'Submit failed, please try again later',
+
+  // Theme
+  Strings.theme: 'Theme',
+  Strings.followSystem: 'Follow System',
+  Strings.darkMode: 'Dark',
+  Strings.lightMode: 'Light',
 };

+ 1 - 0
lib/config/translations/es_ES/es_es_translation.dart

@@ -74,6 +74,7 @@ const Map<String, String> esEs = {
   Strings.disconnected: 'Desconectado',
   Strings.open: 'Abrir',
   Strings.disconnect: 'Desconectar',
+  Strings.disconnecting: 'Desconectando...',
   Strings.connect: 'Conectar',
   Strings.opening: 'Abriendo',
   Strings.connectedSuccessfully: 'Conectado exitosamente',

+ 1 - 0
lib/config/translations/fa_IR/fa_ir_translation.dart

@@ -74,6 +74,7 @@ const Map<String, String> faIR = {
   Strings.disconnected: 'قطع اتصال',
   Strings.open: 'باز کردن',
   Strings.disconnect: 'قطع اتصال',
+  Strings.disconnecting: 'در حال قطع اتصال...',
   Strings.connect: 'اتصال',
   Strings.opening: 'در حال باز شدن',
   Strings.connectedSuccessfully: 'با موفقیت متصل شد',

+ 1 - 0
lib/config/translations/fr_FR/fr_fr_translation.dart

@@ -75,6 +75,7 @@ const Map<String, String> frFR = {
   Strings.disconnected: 'Déconnecté',
   Strings.open: 'Ouvrir',
   Strings.disconnect: 'Déconnecter',
+  Strings.disconnecting: 'Déconnexion...',
   Strings.connect: 'Connecter',
   Strings.opening: 'Ouverture',
   Strings.connectedSuccessfully: 'Connecté avec succès',

+ 1 - 0
lib/config/translations/hi_IN/hi_in_translation.dart

@@ -52,6 +52,7 @@ Map<String, String> hiIN = {
   Strings.disconnected: 'डिस्कनेक्टेड',
   Strings.open: 'खोलें',
   Strings.disconnect: 'डिस्कनेक्ट करें',
+  Strings.disconnecting: 'डिस्कनेक्ट हो रहा है...',
   Strings.connect: 'कनेक्ट करें',
   Strings.opening: 'खुल रहा है',
   Strings.connectedSuccessfully: 'सफलतापूर्वक कनेक्ट हुआ',

+ 1 - 0
lib/config/translations/id_ID/id_id_translation.dart

@@ -52,6 +52,7 @@ Map<String, String> idID = {
   Strings.disconnected: 'Terputus',
   Strings.open: 'Buka',
   Strings.disconnect: 'Putuskan',
+  Strings.disconnecting: 'Memutuskan...',
   Strings.connect: 'Hubungkan',
   Strings.opening: 'Membuka',
   Strings.connectedSuccessfully: 'Berhasil terhubung',

+ 1 - 0
lib/config/translations/ja_JP/ja_jp_translation.dart

@@ -74,6 +74,7 @@ const Map<String, String> jaJP = {
   Strings.disconnected: '切断',
   Strings.open: '開く',
   Strings.disconnect: '切断',
+  Strings.disconnecting: '切断中...',
   Strings.connect: '接続',
   Strings.opening: '開いています',
   Strings.connectedSuccessfully: '正常に接続されました',

+ 1 - 0
lib/config/translations/ko_KR/ko_kr_translation.dart

@@ -71,6 +71,7 @@ const Map<String, String> koKR = {
   Strings.disconnected: '연결 끊김',
   Strings.open: '열기',
   Strings.disconnect: '연결 해제',
+  Strings.disconnecting: '연결 해제 중...',
   Strings.connect: '연결',
   Strings.opening: '열고 있습니다',
   Strings.connectedSuccessfully: '성공적으로 연결되었습니다',

+ 1 - 0
lib/config/translations/my_MM/my_mm_translation.dart

@@ -74,6 +74,7 @@ const Map<String, String> myMM = {
   Strings.disconnected: 'ချိတ်ဆက်ခြင်းမရှိ',
   Strings.open: 'ဖွင့်ပါ',
   Strings.disconnect: 'ချိတ်ဆက်မှုဖြတ်ပါ',
+  Strings.disconnecting: 'ချိတ်ဆက်မှုဖြတ်နေသည်...',
   Strings.connect: 'ချိတ်ဆက်ပါ',
   Strings.opening: 'ဖွင့်နေသည်',
   Strings.connectedSuccessfully: 'အောင်မြင်စွာ ချိတ်ဆက်ပြီး',

+ 1 - 0
lib/config/translations/pt_BR/pt_br_translation.dart

@@ -76,6 +76,7 @@ Map<String, String> ptBR = {
   Strings.disconnected: 'Desconectado',
   Strings.open: 'Abrir',
   Strings.disconnect: 'Desconectar',
+  Strings.disconnecting: 'Desconectando...',
   Strings.connect: 'Conectar',
   Strings.opening: 'Abrindo',
   Strings.connectedSuccessfully: 'Conectado com sucesso',

+ 1 - 0
lib/config/translations/ru_RU/ru_ru_translation.dart

@@ -76,6 +76,7 @@ const Map<String, String> ruRU = {
   Strings.disconnected: 'Отключено',
   Strings.open: 'Открыть',
   Strings.disconnect: 'Отключиться',
+  Strings.disconnecting: 'Отключение...',
   Strings.connect: 'Подключиться',
   Strings.opening: 'Открывается',
   Strings.connectedSuccessfully: 'Успешно подключено',

+ 6 - 0
lib/config/translations/strings_enum.dart

@@ -70,6 +70,7 @@ class Strings {
   static const String disconnected = 'Disconnected';
   static const String open = 'Open';
   static const String disconnect = 'Disconnect';
+  static const String disconnecting = 'Disconnecting';
   static const String connect = 'Connect';
   static const String opening = 'Opening';
   static const String connectedSuccessfully = 'Connected successfully';
@@ -475,4 +476,9 @@ class Strings {
       "Feedback submitted, we will reply to you soon";
   static const String feedbackSubmitFailed =
       "Submit failed, please try again later";
+
+  static const String theme = 'Theme';
+  static const String followSystem = 'Follow System';
+  static const String darkMode = 'Dark';
+  static const String lightMode = 'Light';
 }

+ 1 - 0
lib/config/translations/th_TH/th_th_translation.dart

@@ -52,6 +52,7 @@ Map<String, String> thTH = {
   Strings.disconnected: 'ยกเลิกการเชื่อมต่อแล้ว',
   Strings.open: 'เปิด',
   Strings.disconnect: 'ยกเลิกการเชื่อมต่อ',
+  Strings.disconnecting: 'กำลังยกเลิกการเชื่อมต่อ...',
   Strings.connect: 'เชื่อมต่อ',
   Strings.opening: 'กำลังเปิด',
   Strings.connectedSuccessfully: 'เชื่อมต่อสำเร็จ',

+ 1 - 0
lib/config/translations/tk_TM/tk_tm_translation.dart

@@ -75,6 +75,7 @@ Map<String, String> tkTM = {
   Strings.disconnected: 'Aýryldy',
   Strings.open: 'Aç',
   Strings.disconnect: 'Aýyr',
+  Strings.disconnecting: 'Aýyrylýar...',
   Strings.connect: 'Baglan',
   Strings.opening: 'Açylýar',
   Strings.connectedSuccessfully: 'Üstünlikli baglandy',

+ 1 - 0
lib/config/translations/tl_PH/tl_ph_translation.dart

@@ -52,6 +52,7 @@ Map<String, String> tlPH = {
   Strings.disconnected: 'Nadiskonekta',
   Strings.open: 'Buksan',
   Strings.disconnect: 'Idiskonekta',
+  Strings.disconnecting: 'Nagdidiskonekta...',
   Strings.connect: 'Kumonekta',
   Strings.opening: 'Binubuksan',
   Strings.connectedSuccessfully: 'Matagumpay na nakakonekta',

+ 1 - 0
lib/config/translations/tr_TR/tr_tr_translation.dart

@@ -52,6 +52,7 @@ Map<String, String> trTR = {
   Strings.disconnected: 'Bağlantı kesildi',
   Strings.open: 'Aç',
   Strings.disconnect: 'Bağlantıyı kes',
+  Strings.disconnecting: 'Bağlantı kesiliyor...',
   Strings.connect: 'Bağlan',
   Strings.opening: 'Açılıyor',
   Strings.connectedSuccessfully: 'Başarıyla bağlandı',

+ 1 - 0
lib/config/translations/vi_VN/vi_vn_translation.dart

@@ -55,6 +55,7 @@ Map<String, String> viVN = {
   Strings.disconnected: 'Đã ngắt kết nối',
   Strings.open: 'Mở',
   Strings.disconnect: 'Ngắt kết nối',
+  Strings.disconnecting: 'Đang ngắt kết nối...',
   Strings.connect: 'Kết nối',
   Strings.opening: 'Đang mở',
   Strings.connectedSuccessfully: 'Kết nối thành công',

+ 7 - 0
lib/config/translations/zh_TW/zh_tw_translation.dart

@@ -73,6 +73,7 @@ Map<String, String> zhTW = {
   Strings.disconnected: '已斷線',
   Strings.open: '開啟',
   Strings.disconnect: '斷開連線',
+  Strings.disconnecting: '正在斷開連線...',
   Strings.connect: '連線',
   Strings.opening: '開啟中',
   Strings.connectedSuccessfully: '連線成功',
@@ -428,4 +429,10 @@ Map<String, String> zhTW = {
   Strings.pleaseEnterValidEmail: '請輸入有效的電子郵件地址',
   Strings.feedbackSubmitted: '意見回饋已提交,我們會盡快回覆您',
   Strings.feedbackSubmitFailed: '提交失敗,請稍後再試',
+
+  // Theme
+  Strings.theme: '主題',
+  Strings.followSystem: '跟隨系統',
+  Strings.darkMode: '深色',
+  Strings.lightMode: '淺色',
 };

+ 2 - 3
lib/utils/awesome_notifications_helper.dart

@@ -28,12 +28,11 @@ class AwesomeNotificationsHelper {
     try {
       // 初始化通知渠道和组
       await _initNotification();
-
       // 设置监听
       listenToActionButtons();
-
       // 检查权限但不立即请求
-      await checkNotificationPermission();
+      bool isAllowed = await checkNotificationPermission();
+      if (!isAllowed) {}
     } catch (e) {
       log(TAG, 'Notification initialization failed: $e');
     }

+ 369 - 0
lib/utils/gzip_manager.dart

@@ -0,0 +1,369 @@
+import 'dart:async';
+import 'dart:io';
+import 'package:archive/archive.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:path/path.dart' as path;
+
+import 'log/logger.dart';
+
+/// GZIP文件管理器
+/// 用于创建和分享GZIP压缩文件,不需要密码保护
+class GzipManager {
+  static const String TAG = 'GzipManager';
+  static final GzipManager _instance = GzipManager._internal();
+  factory GzipManager() => _instance;
+  GzipManager._internal();
+
+  // 防重复点击机制
+  DateTime? _lastGenerateTime;
+  String? _lastGeneratedFilePath;
+  static const Duration _throttleDuration = Duration(minutes: 1);
+
+  /// 生成GZIP压缩文件并分享
+  ///
+  /// [filePaths] 要压缩的文件路径列表
+  /// [gzipFileName] gzip文件名,默认为 "nomo_logs_时间戳.tar.gz"
+  /// [deleteExisting] 是否删除已存在的同名文件,默认为true
+  ///
+  /// 返回生成的gzip文件路径,如果失败返回null
+  Future<String?> generateAndShareGzip({
+    required List<String> filePaths,
+    String? gzipFileName,
+    bool deleteExisting = true,
+  }) async {
+    try {
+      // 检查防重复点击
+      if (_shouldUseLastGeneratedFile()) {
+        if (_lastGeneratedFilePath != null &&
+            await File(_lastGeneratedFilePath!).exists()) {
+          // await _shareFile(_lastGeneratedFilePath!);
+          return _lastGeneratedFilePath;
+        }
+      }
+
+      // 验证文件路径
+      final validFilePaths = await _validateFilePaths(filePaths);
+      if (validFilePaths.isEmpty) {
+        throw Exception('no valid file paths');
+      }
+
+      // 生成gzip文件
+      final gzipFilePath = await _generateGzip(
+        filePaths: validFilePaths,
+        gzipFileName: gzipFileName,
+        deleteExisting: deleteExisting,
+      );
+
+      if (gzipFilePath != null) {
+        // 更新防重复点击状态
+        _lastGenerateTime = DateTime.now();
+        _lastGeneratedFilePath = gzipFilePath;
+
+        // 分享文件
+        // await _shareFile(gzipFilePath);
+        return gzipFilePath;
+      }
+
+      return null;
+    } catch (e) {
+      log(TAG, 'generate gzip file failed: $e');
+      return null;
+    }
+  }
+
+  /// 检查是否应该使用上次生成的文件
+  bool _shouldUseLastGeneratedFile() {
+    if (_lastGenerateTime == null) return false;
+
+    final timeDiff = DateTime.now().difference(_lastGenerateTime!);
+    return timeDiff < _throttleDuration;
+  }
+
+  /// 验证文件路径
+  Future<List<String>> _validateFilePaths(List<String> filePaths) async {
+    final validPaths = <String>[];
+
+    for (final filePath in filePaths) {
+      final file = File(filePath);
+      if (await file.exists()) {
+        validPaths.add(filePath);
+      } else {
+        log(TAG, 'file not exists: $filePath');
+      }
+    }
+
+    return validPaths;
+  }
+
+  /// 生成GZIP压缩文件
+  Future<String?> _generateGzip({
+    required List<String> filePaths,
+    String? gzipFileName,
+    bool deleteExisting = true,
+  }) async {
+    try {
+      // 获取临时目录
+      final tempDir = await getTemporaryDirectory();
+
+      // 生成gzip文件名
+      final timestamp = DateTime.now().millisecondsSinceEpoch;
+      final fileName = gzipFileName ?? 'nomo_logs_$timestamp.tar.gz';
+      final gzipFilePath = path.join(tempDir.path, fileName);
+
+      // 删除已存在的文件
+      if (deleteExisting) {
+        final existingFile = File(gzipFilePath);
+        if (await existingFile.exists()) {
+          await existingFile.delete();
+        }
+      }
+
+      // 创建GZIP压缩文件
+      return await _createGzipFile(
+        filePaths: filePaths,
+        gzipFilePath: gzipFilePath,
+      );
+    } catch (e) {
+      log(TAG, 'generate gzip file failed: $e');
+      return null;
+    }
+  }
+
+  /// 创建GZIP压缩文件
+  Future<String?> _createGzipFile({
+    required List<String> filePaths,
+    required String gzipFilePath,
+  }) async {
+    try {
+      // 创建TAR归档
+      final tarArchive = Archive();
+
+      // 添加文件到TAR归档
+      for (final filePath in filePaths) {
+        final file = File(filePath);
+        final fileName = path.basename(filePath);
+        final fileBytes = await file.readAsBytes();
+
+        // 创建TAR文件条目
+        final tarFile = ArchiveFile(fileName, fileBytes.length, fileBytes);
+
+        tarArchive.addFile(tarFile);
+      }
+
+      // 将TAR归档转换为字节
+      final tarData = TarEncoder().encode(tarArchive);
+
+      // 使用GZIP压缩TAR数据
+      final gzipData = GZipEncoder().encode(tarData);
+      final gzipFile = File(gzipFilePath);
+      await gzipFile.writeAsBytes(gzipData);
+
+      log(TAG, 'gzip file generated successfully: $gzipFilePath');
+      return gzipFilePath;
+    } catch (e) {
+      log(TAG, 'create gzip file failed: $e');
+      rethrow;
+    }
+  }
+
+  /// 解压GZIP文件
+  ///
+  /// [gzipFilePath] gzip文件路径
+  /// [outputDir] 输出目录,默认为临时目录
+  Future<List<String>> extractGzip({
+    required String gzipFilePath,
+    String? outputDir,
+  }) async {
+    try {
+      final gzipFile = File(gzipFilePath);
+      if (!await gzipFile.exists()) {
+        throw Exception('gzip文件不存在: $gzipFilePath');
+      }
+
+      // 读取gzip文件
+      var gzipBytes = await gzipFile.readAsBytes();
+      log(TAG, 'read gzip file size: ${gzipBytes.length} bytes');
+
+      // 解压GZIP
+      log(TAG, 'start to decompress gzip file...');
+      final tarBytes = GZipDecoder().decodeBytes(gzipBytes);
+      log(
+        TAG,
+        'gzip decompressed successfully, tar size: ${tarBytes.length} bytes',
+      );
+
+      // 解压TAR
+      log(TAG, 'start to extract tar archive...');
+      final archive = TarDecoder().decodeBytes(tarBytes);
+      log(TAG, 'tar extracted successfully, contains ${archive.length} files');
+
+      // 确定输出目录
+      final outputDirectory = outputDir ?? (await getTemporaryDirectory()).path;
+      final extractedFiles = <String>[];
+
+      // 提取文件
+      for (final file in archive) {
+        if (file.isFile) {
+          // 写入解压后的文件
+          final outputPath = path.join(outputDirectory, file.name);
+          final outputFile = File(outputPath);
+          await outputFile.writeAsBytes(file.content);
+
+          extractedFiles.add(outputPath);
+          log(TAG, 'extract file: ${file.name} -> $outputPath');
+        }
+      }
+
+      log(TAG, 'successfully extracted ${extractedFiles.length} files');
+      return extractedFiles;
+    } catch (e) {
+      log(TAG, 'extract gzip file failed: $e');
+      log(TAG, 'error type: ${e.runtimeType}');
+      rethrow;
+    }
+  }
+
+  /// 清除缓存的文件
+  Future<void> clearCache() async {
+    try {
+      if (_lastGeneratedFilePath != null) {
+        final file = File(_lastGeneratedFilePath!);
+        if (await file.exists()) {
+          await file.delete();
+        }
+      }
+      _lastGeneratedFilePath = null;
+      _lastGenerateTime = null;
+    } catch (e) {
+      log(TAG, 'clear cache failed: $e');
+    }
+  }
+
+  /// 获取上次生成的文件路径
+  String? get lastGeneratedFilePath => _lastGeneratedFilePath;
+
+  /// 获取上次生成的时间
+  DateTime? get lastGenerateTime => _lastGenerateTime;
+
+  /// 检查是否在防重复点击时间窗口内
+  bool get isInThrottleWindow => _shouldUseLastGeneratedFile();
+
+  /// 获取文件大小信息
+  Future<Map<String, dynamic>> getFileInfo(String filePath) async {
+    try {
+      final file = File(filePath);
+      if (await file.exists()) {
+        final stat = await file.stat();
+        return {
+          'path': filePath,
+          'name': path.basename(filePath),
+          'size': stat.size,
+          'modified': stat.modified,
+          'created': stat.changed,
+        };
+      }
+      return {};
+    } catch (e) {
+      log(TAG, 'get file info failed: $e');
+      return {};
+    }
+  }
+
+  /// 批量获取文件信息
+  Future<List<Map<String, dynamic>>> getFilesInfo(
+    List<String> filePaths,
+  ) async {
+    final filesInfo = <Map<String, dynamic>>[];
+
+    for (final filePath in filePaths) {
+      final info = await getFileInfo(filePath);
+      if (info.isNotEmpty) {
+        filesInfo.add(info);
+      }
+    }
+
+    return filesInfo;
+  }
+
+  /// 检查文件是否为GZIP格式
+  Future<bool> isGzipFile(String filePath) async {
+    try {
+      final file = File(filePath);
+      if (!await file.exists()) {
+        return false;
+      }
+
+      // 读取文件头部字节
+      final bytes = await file.openRead(0, 2).first;
+      if (bytes.length < 2) {
+        return false;
+      }
+
+      // GZIP文件头标识:0x1f 0x8b
+      return bytes[0] == 0x1f && bytes[1] == 0x8b;
+    } catch (e) {
+      log(TAG, 'check gzip file format failed: $e');
+      return false;
+    }
+  }
+
+  /// 获取GZIP文件中的文件列表
+  Future<List<String>> getGzipFileList(String gzipFilePath) async {
+    try {
+      final gzipFile = File(gzipFilePath);
+      if (!await gzipFile.exists()) {
+        throw Exception('gzip file not exists: $gzipFilePath');
+      }
+
+      // 读取并解压GZIP
+      var gzipBytes = await gzipFile.readAsBytes();
+      final tarBytes = GZipDecoder().decodeBytes(gzipBytes);
+
+      // 解析TAR归档
+      final archive = TarDecoder().decodeBytes(tarBytes);
+
+      final fileList = <String>[];
+      for (final file in archive) {
+        if (file.isFile) {
+          fileList.add(file.name);
+        }
+      }
+
+      return fileList;
+    } catch (e) {
+      log(TAG, 'get gzip file list failed: $e');
+      rethrow;
+    }
+  }
+
+  /// 获取压缩比信息
+  Future<Map<String, dynamic>> getCompressionInfo(String gzipFilePath) async {
+    try {
+      final gzipFile = File(gzipFilePath);
+      if (!await gzipFile.exists()) {
+        return {};
+      }
+
+      final gzipSize = await gzipFile.length();
+
+      // 解压获取原始大小
+      var gzipBytes = await gzipFile.readAsBytes();
+      final tarBytes = GZipDecoder().decodeBytes(gzipBytes);
+      final originalSize = tarBytes.length;
+
+      final compressionRatio = originalSize > 0
+          ? ((1 - gzipSize / originalSize) * 100).toStringAsFixed(2)
+          : '0.00';
+
+      return {
+        'originalSize': originalSize,
+        'compressedSize': gzipSize,
+        'compressionRatio': '$compressionRatio%',
+        'spaceSaved': originalSize - gzipSize,
+      };
+    } catch (e) {
+      log(TAG, 'get compression info failed: $e');
+      return {};
+    }
+  }
+}