From a921b5be89fb5d31c968611682a942b6e544e9e6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 12:51:00 +0800 Subject: [PATCH 01/60] fix(android): avoid cross-drive Kotlin cache failures --- android/gradle.properties | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/gradle.properties b/android/gradle.properties index 0399e24fd..211d2814d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,9 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# Avoid cross-drive cache failures when the Windows Pub cache and project +# are on different drives. +kotlin.incremental=false # Preserve the app-level 64-bit ABI list instead of Flutter's default list. disable-abi-filtering=true # This builtInKotlin flag was added automatically by Flutter migrator From 179f21211dae0969694a6e68654fc4016c419dd7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 17:59:35 +0800 Subject: [PATCH 02/60] refactor(ios): drop compatibility below iOS 16 --- README.md | 2 +- docs/app.md | 4 ++-- docs/validation.md | 2 +- ios/Runner.xcodeproj/project.pbxproj | 28 ++++++++++++------------- ios/widget/widget.swift | 24 +++++++-------------- swift/All/Model.swift | 2 +- swift/All/Tools.swift | 22 ------------------- swift/App/Vpn.swift | 10 ++++----- swift/App/pigeon/HostApi.swift | 2 +- swift/Tunnel/PacketTunnelProvider.swift | 20 +++++++++--------- 10 files changed, 42 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 3e2ab0104..e46363715 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Choose the official store for your device: -- **iPhone / iPad:** [App Store](https://apps.apple.com/us/app/onexray/id6745748773) — iOS / iPadOS 15+. +- **iPhone / iPad:** [App Store](https://apps.apple.com/us/app/onexray/id6745748773) — iOS / iPadOS 16+. - **macOS:** [Mac App Store](https://apps.apple.com/us/app/onexray/id6745748773) — macOS 13+, Apple silicon or Intel. - **Android phones / tablets:** [Google Play](https://play.google.com/store/apps/details?id=net.yuandev.onexray) — Android 10+, arm64-v8a or x86_64. - **Windows:** [Microsoft Store](https://apps.microsoft.com/detail/9NJ0MVHW215D) — Windows 10 20H2+ (x64) or Windows 11 (ARM64). diff --git a/docs/app.md b/docs/app.md index 86f5ff225..c75b99c80 100644 --- a/docs/app.md +++ b/docs/app.md @@ -260,9 +260,9 @@ Android 设置页可请求固定 `home_widget`,不把请求完成当作已添 ### iOS/iPadOS Widget -iOS/iPadOS 15 起提供连接 Widget,支持 `systemSmall`、`systemMedium`;16 起增加 +iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMedium`、 `accessoryCircular`、`accessoryRectangular`,可添加位置由系统版本与设备决定。 -iOS/iPadOS 15–16 静态展示状态,点击打开连接页; +iOS/iPadOS 16 静态展示状态,点击打开连接页; 17 起主屏幕小号/中号 Widget 增加显式“启动 VPN”“停止 VPN”,通过前台 App Intent 打开 App。 锁屏两种 accessory 只导航。当前仅接入 Widget,不发布可发现的 Siri/App Shortcuts, 也不提供 Control Widget 或 Live Activity。 diff --git a/docs/validation.md b/docs/validation.md index 39ff94a1f..7debb1e57 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -229,7 +229,7 @@ Android 模拟器直接使用当前分支构建覆盖已经安装的 OneXray, 仅显示已连接或杀进程后的成功不能代替此项。 - 完整配置适配在 Android 验证 Raw/提供方真实 VPN 启动、DNS 查询、受控网页响应、连续重连和隧道 DNS 设置变更。 iOS 模拟器只验证配置与 SOCKS 路径,不能验收真机 DNS、DoT 或 includeAllNetworks;这些项目需要 Apple 真机。 -- iOS/iPadOS Widget 最低部署版本为 15;15 验证主屏幕小号/中号静态导航,16 起增加两种锁屏 accessory。 +- iOS/iPadOS Widget 最低部署版本为 16.0;16 验证主屏幕小号/中号静态导航及两种锁屏 accessory。 在系统图库与实际主屏幕/锁屏验证系统支持的 family; 17 起验证主屏幕小号/中号启动、停止实际前台打开 App,并使用当前选择而非最近生成的 saved-start。 覆盖冷启动、App 已运行/后台、重复点击和启动中停止,确认连接协调器、权限失败与页面/通知反馈。 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a20e27350..0bd493ec7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - EB3100000000000000000001 /* FileExportApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3100000000000000000002 /* FileExportApi.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -30,6 +29,7 @@ EB240000000000000000010A /* AppleWidgetApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000014 /* AppleWidgetApi.swift */; }; EB240000000000000000010B /* Widget.strings in Resources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000020 /* Widget.strings */; }; EB240000000000000000010C /* AppleVpnStartAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000016 /* AppleVpnStartAdmission.swift */; }; + EB3100000000000000000001 /* FileExportApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3100000000000000000002 /* FileExportApi.swift */; }; EB67F754308F0B400096B893 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB67F753308F0B400096B893 /* WidgetKit.framework */; }; EB67F756308F0B400096B893 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB67F755308F0B400096B893 /* SwiftUI.framework */; }; EB67F767308F0B400096B893 /* widgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB67F752308F0B400096B893 /* widgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -114,7 +114,6 @@ 54C0E75B86BC41128CD6F654 /* BackupApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupApi.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - EB3100000000000000000002 /* FileExportApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExportApi.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -139,6 +138,7 @@ EB2400000000000000000023 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "widget/zh-Hant.lproj/Widget.strings"; sourceTree = SOURCE_ROOT; }; EB2400000000000000000024 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = widget/ru.lproj/Widget.strings; sourceTree = SOURCE_ROOT; }; EB2400000000000000000025 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = widget/fa.lproj/Widget.strings; sourceTree = SOURCE_ROOT; }; + EB3100000000000000000002 /* FileExportApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExportApi.swift; sourceTree = ""; }; EB390D812EC84AF400B741BA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Main.strings; sourceTree = ""; }; EB390D822EC84AF400B741BA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/LaunchScreen.strings; sourceTree = ""; }; EB67F752308F0B400096B893 /* widgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = widgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -713,7 +713,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -740,7 +740,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -805,7 +805,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -860,7 +860,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -889,7 +889,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -922,7 +922,7 @@ INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -959,7 +959,7 @@ INFOPLIST_FILE = widget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1009,7 +1009,7 @@ INFOPLIST_FILE = widget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1056,7 +1056,7 @@ INFOPLIST_FILE = widget/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = OneXray; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1099,7 +1099,7 @@ INFOPLIST_FILE = tunnel/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = tunnel; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1144,7 +1144,7 @@ INFOPLIST_FILE = tunnel/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = tunnel; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1185,7 +1185,7 @@ INFOPLIST_FILE = tunnel/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = tunnel; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index 420989b93..68cb4903b 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -4,12 +4,9 @@ import AppIntents private let connectionURL = URL(string: "onexray://widget/connect")! -private var connectionWidgetFamilies: [WidgetFamily] { - if #available(iOS 16.0, *) { - return [.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular] - } - return [.systemSmall, .systemMedium] -} +private let connectionWidgetFamilies: [WidgetFamily] = [ + .systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular, +] struct ConnectionWidgetEntry: TimelineEntry { let date: Date @@ -72,10 +69,7 @@ private struct ConnectionWidgetView: View { private var emphasizeStop: Bool { entry.status == .connected || entry.status == .connecting } private var isAccessory: Bool { - if #available(iOS 16.0, *) { - return family == .accessoryCircular || family == .accessoryRectangular - } - return false + family == .accessoryCircular || family == .accessoryRectangular } var body: some View { @@ -97,14 +91,14 @@ private struct ConnectionWidgetView: View { } @ViewBuilder private var content: some View { - if #available(iOS 16.0, *), family == .accessoryCircular { + if family == .accessoryCircular { ZStack { AccessoryWidgetBackground() Image(systemName: statusIcon).font(.title2) } .privacySensitive() .accessibilityLabel("OneXray, \(statusText)") - } else if #available(iOS 16.0, *), family == .accessoryRectangular { + } else if family == .accessoryRectangular { VStack(alignment: .leading, spacing: 3) { Text("OneXray").font(.headline) Label(statusText, systemImage: statusIcon).font(.caption).privacySensitive() @@ -350,11 +344,7 @@ private struct ConnectionWidgetButtonStyle: ButtonStyle { .foregroundStyle(foreground) .frame(maxWidth: .infinity, minHeight: 44) .background { - if #available(iOS 16.0, *) { - RoundedRectangle(cornerRadius: 8).fill(fill).widgetAccentable() - } else { - RoundedRectangle(cornerRadius: 8).fill(fill) - } + RoundedRectangle(cornerRadius: 8).fill(fill).widgetAccentable() } .overlay { RoundedRectangle(cornerRadius: 8) diff --git a/swift/All/Model.swift b/swift/All/Model.swift index 962238af8..b7ce97c07 100644 --- a/swift/All/Model.swift +++ b/swift/All/Model.swift @@ -100,7 +100,7 @@ struct StartVpnRequest: Codable { YGLog("tunnel appGroupId=\(appGroupId())") if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId()) { YGLog("tunnel groupURL=\(groupURL)") - let requestUrl = groupURL.adaptedAppendPath(path: StartModelFile) + let requestUrl = groupURL.appending(path: StartModelFile) do { let request = try fromUrl(requestUrl) return request diff --git a/swift/All/Tools.swift b/swift/All/Tools.swift index f67d04223..7a2730fd0 100644 --- a/swift/All/Tools.swift +++ b/swift/All/Tools.swift @@ -26,25 +26,3 @@ enum LibXrayInvoker { return String(cString: response) } } - -extension URL { - func adaptedAppendPath(path: String) -> URL { - if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { - return self.appending(path: path) - } else { - return self.appendingPathComponent(path) - } - } - - func adaptedPath() -> String { - if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) { - return self.path(percentEncoded: false) - } else { - let path = self.path - if let cleanPath = path.removingPercentEncoding { - return cleanPath - } - return path - } - } -} diff --git a/swift/App/Vpn.swift b/swift/App/Vpn.swift index 3d1301547..8d3a56b46 100644 --- a/swift/App/Vpn.swift +++ b/swift/App/Vpn.swift @@ -310,7 +310,7 @@ class VPNManager { observedConnecting = true } else if status == .disconnected, awaitingStart, observedConnecting { awaitingStart = false - if #available(iOS 16.0, macOS 13.0, *), let connection = vpn?.connection { + if let connection = vpn?.connection { #if os(iOS) let generation = startAdmission.generation #endif @@ -451,7 +451,7 @@ class VPNManager { } let state: NativeVpnCommandState do { - try await SimulatorProxy.start(request, at: groupURL.adaptedAppendPath(path: StartModelFile)) + try await SimulatorProxy.start(request, at: groupURL.appending(path: StartModelFile)) try checkStartAdmission(generation) state = .success } catch { @@ -707,8 +707,8 @@ class VPNManager { else { return nil } - var u = userGroup.adaptedPath() - var e = extGroup.adaptedPath() + var u = userGroup.path(percentEncoded: false) + var e = extGroup.path(percentEncoded: false) while u.hasSuffix("/") { u.removeLast() } @@ -742,7 +742,7 @@ class VPNManager { guard let userGroup = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId()) else { throw VPNError.routingDataSyncFailed } - let directory = userGroup.adaptedAppendPath(path: "dat") + let directory = userGroup.appending(path: "dat") guard let local = try DatFileTransfer.manifest(in: directory) else { throw VPNError.routingDataSyncFailed } diff --git a/swift/App/pigeon/HostApi.swift b/swift/App/pigeon/HostApi.swift index 655cffdcf..86a6467e3 100644 --- a/swift/App/pigeon/HostApi.swift +++ b/swift/App/pigeon/HostApi.swift @@ -18,7 +18,7 @@ final class AppHostApi: @preconcurrency BridgeHostApi { func getTunFilesDir(completion: @escaping (Result) -> Void) { if let groupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId()) { - let path = groupUrl.adaptedPath() + let path = groupUrl.path(percentEncoded: false) completion(.success(path)) } else { completion(.success("")) diff --git a/swift/Tunnel/PacketTunnelProvider.swift b/swift/Tunnel/PacketTunnelProvider.swift index 4d07c9d29..6f443d217 100644 --- a/swift/Tunnel/PacketTunnelProvider.swift +++ b/swift/Tunnel/PacketTunnelProvider.swift @@ -125,21 +125,21 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { throw TunnelError.noGroupContainer } let fm = FileManager.default - let runDirectory = extGroupURL.adaptedAppendPath(path: "run") + let runDirectory = extGroupURL.appending(path: "run") if try !runtimeDirectoryExists(runDirectory) { try fm.createDirectory(at: runDirectory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) } for name in ["access.log", "error.log"] { - let file = runDirectory.adaptedAppendPath(path: name) + let file = runDirectory.appending(path: name) var attributes = stat() - if lstat(file.adaptedPath(), &attributes) == 0 { + if lstat(file.path(percentEncoded: false), &attributes) == 0 { try fm.removeItem(at: file) } else if errno != ENOENT { throw RuntimeStateError.invalid } } // A failed transfer may leave only this shared temporary directory. - let staging = extGroupURL.adaptedAppendPath(path: "dat.staging") + let staging = extGroupURL.appending(path: "dat.staging") if try runtimeDirectoryExists(staging) { try fm.removeItem(at: staging) } Self.stateQueue.sync { self.pendingStartSignal = false @@ -343,9 +343,9 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard Constants.useSystemExtension, let container = extensionGroupContainerURL() else { throw RuntimeStateError.unsupported } - let directory = container.adaptedAppendPath(path: "run") + let directory = container.appending(path: "run") guard try runtimeDirectoryExists(directory) else { return nil } - return directory.adaptedAppendPath(path: access ? "access.log" : "error.log") + return directory.appending(path: access ? "access.log" : "error.log") } private func runtimeDirectoryExists(_ directory: URL) throws -> Bool { @@ -363,11 +363,11 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // MARK: - dat staging operations private func datDir() -> URL? { - extensionGroupContainerURL()?.adaptedAppendPath(path: "dat") + extensionGroupContainerURL()?.appending(path: "dat") } private func stagingDir() -> URL? { - extensionGroupContainerURL()?.adaptedAppendPath(path: "dat.staging") + extensionGroupContainerURL()?.appending(path: "dat.staging") } // MARK: - Xray lifecycle @@ -414,7 +414,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { YGLog("PacketTunnelProvider TunnelError.noGroupContainer") throw TunnelError.noGroupContainer } - let datPath = dat.adaptedPath() + let datPath = dat.path(percentEncoded: false) env["xray.location.asset"] = datPath env["xray.location.cert"] = datPath if var log = root["log"] as? [String: Any] { @@ -423,7 +423,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let file = try logFile(access: access) else { throw TunnelError.noGroupContainer } - log[key] = file.adaptedPath() + log[key] = file.path(percentEncoded: false) } root["log"] = log } From 1994c5391a16485e946a9262ef3a7dee6cfadcf8 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 18:13:55 +0800 Subject: [PATCH 03/60] refactor(ui): introduce six destinations and a shared single-column shell --- lib/l10n/app_en.arb | 37 ++- lib/l10n/app_fa.arb | 37 ++- lib/l10n/app_ru.arb | 37 ++- lib/l10n/app_zh.arb | 37 ++- lib/l10n/app_zh_Hant.arb | 37 ++- lib/pages/connect/routing/controller.dart | 236 +++++++++++++++++ lib/pages/connect/routing/page.dart | 284 +++++++++++++++++++++ lib/pages/main/adaptive_shell.dart | 39 ++- lib/pages/main/advanced.dart | 46 +++- lib/pages/main/navigation.dart | 7 +- lib/pages/main/url.dart | 9 + lib/pages/settings/page.dart | 58 +---- lib/pages/shared/connection_action.dart | 6 +- lib/pages/shared/widgets/page_app_bar.dart | 29 ++- lib/pages/theme/color.dart | 108 ++++---- lib/pages/theme/font.dart | 26 +- lib/pages/theme/layout.dart | 27 +- lib/pages/theme/theme.dart | 11 +- test/pages/main/adaptive_shell_test.dart | 60 +++-- 19 files changed, 947 insertions(+), 184 deletions(-) create mode 100644 lib/pages/connect/routing/controller.dart create mode 100644 lib/pages/connect/routing/page.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b392d5141..1f5434b12 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3144,5 +3144,40 @@ "jsonEditorDiscardChanges": "Discard changes", "tunnelDnsIpv4Invalid": "Enter a valid IPv4 address.", "tunnelDnsIpv6Invalid": "Enter a valid IPv6 address.", - "tunnelDnsServerNameRequired": "Enter the DNS over TLS server name." + "tunnelDnsServerNameRequired": "Enter the DNS over TLS server name.", + "navigationRouting": "Routing", + "serverNodesTab": "Nodes", + "serverCompleteConfigurationsTab": "Configurations", + "serverAllSources": "All sources", + "connectionPlan": "Connection plan", + "serverSelectionAutomaticScope": "Automatic scope", + "serverSelectionFixedNode": "Fixed node", + "connectionCandidates": "{count} candidates", + "@connectionCandidates": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRequiredEntries": "{count} entry nodes required", + "@connectionRequiredEntries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRememberedPlanUnavailable": "The previous plan is unavailable. Select a scope or node.", + "clearConnectionResources": "Clear connection resources", + "clearConnectionResourcesWarning": "Remove servers, subscriptions, routing configurations and saved connection choices. App preferences, permissions and the backup location are kept.", + "routingPageDescription": "Choose how traffic is routed, or edit your custom rules.", + "routingStoredPreferenceNotice": "Complete configurations use their own routing. This selection applies when you return to ordinary servers.", + "connectionRestoreOrdinary": "Use previous ordinary plan", + "connectionPreparing": "Preparing connection", + "xrayDiagnostics": "Diagnostics", + "xrayDiagnosticsHint": "Review connection errors, runtime configuration and logs.", + "xrayRefreshStatus": "Refresh status", + "xrayNoConnectionError": "There is no current connection error.", + "xrayNoRuntimeConfiguration": "No runtime configuration yet. Connect to view the actual startup configuration." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index ac281f300..9e2986c2b 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -966,5 +966,40 @@ "jsonEditorDiscardChanges": "کنار گذاشتن تغییرات", "tunnelDnsIpv4Invalid": "یک نشانی معتبر IPv4 وارد کنید.", "tunnelDnsIpv6Invalid": "یک نشانی معتبر IPv6 وارد کنید.", - "tunnelDnsServerNameRequired": "نام سرور DNS over TLS را وارد کنید." + "tunnelDnsServerNameRequired": "نام سرور DNS over TLS را وارد کنید.", + "navigationRouting": "مسیریابی", + "serverNodesTab": "گره‌ها", + "serverCompleteConfigurationsTab": "پیکربندی‌ها", + "serverAllSources": "همهٔ منابع", + "connectionPlan": "برنامهٔ اتصال", + "serverSelectionAutomaticScope": "محدودهٔ انتخاب خودکار", + "serverSelectionFixedNode": "گرهٔ ثابت", + "connectionCandidates": "{count} گرهٔ نامزد", + "@connectionCandidates": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRequiredEntries": "{count} گرهٔ ورودی لازم است", + "@connectionRequiredEntries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRememberedPlanUnavailable": "برنامهٔ قبلی در دسترس نیست. یک محدوده یا گره انتخاب کنید.", + "clearConnectionResources": "پاک کردن منابع اتصال", + "clearConnectionResourcesWarning": "سرورها، اشتراک‌ها، پیکربندی‌های مسیر و انتخاب‌های اتصال حذف می‌شوند. تنظیمات برنامه، مجوزها و محل پشتیبان حفظ می‌شوند.", + "routingPageDescription": "روش مسیریابی ترافیک را انتخاب کنید یا قوانین خود را ویرایش کنید.", + "routingStoredPreferenceNotice": "پیکربندی‌های کامل مسیرهای خود را دارند. این انتخاب پس از بازگشت به گره‌های عادی اعمال می‌شود.", + "connectionRestoreOrdinary": "استفاده از برنامهٔ عادی قبلی", + "connectionPreparing": "در حال آماده‌سازی اتصال", + "xrayDiagnostics": "عیب‌یابی", + "xrayDiagnosticsHint": "خطاهای اتصال، پیکربندی اجرا و گزارش‌ها را ببینید.", + "xrayRefreshStatus": "تازه‌سازی وضعیت", + "xrayNoConnectionError": "اکنون خطایی در اتصال وجود ندارد.", + "xrayNoRuntimeConfiguration": "هنوز پیکربندی اجرایی موجود نیست. برای مشاهدهٔ ورودی واقعی راه‌اندازی متصل شوید." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3bfa907d1..d5620008e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -966,5 +966,40 @@ "jsonEditorDiscardChanges": "Отменить изменения", "tunnelDnsIpv4Invalid": "Введите корректный адрес IPv4.", "tunnelDnsIpv6Invalid": "Введите корректный адрес IPv6.", - "tunnelDnsServerNameRequired": "Введите имя сервера DNS over TLS." + "tunnelDnsServerNameRequired": "Введите имя сервера DNS over TLS.", + "navigationRouting": "Маршрутизация", + "serverNodesTab": "Узлы", + "serverCompleteConfigurationsTab": "Конфигурации", + "serverAllSources": "Все источники", + "connectionPlan": "План подключения", + "serverSelectionAutomaticScope": "Область автовыбора", + "serverSelectionFixedNode": "Фиксированный узел", + "connectionCandidates": "Кандидатов: {count}", + "@connectionCandidates": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRequiredEntries": "Требуется входных узлов: {count}", + "@connectionRequiredEntries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRememberedPlanUnavailable": "Предыдущий план недоступен. Выберите область или узел.", + "clearConnectionResources": "Удалить ресурсы подключения", + "clearConnectionResourcesWarning": "Удалить серверы, подписки, маршруты и сохранённый выбор подключения. Настройки приложения, разрешения и расположение резервной копии сохранятся.", + "routingPageDescription": "Выберите способ маршрутизации или измените свои правила.", + "routingStoredPreferenceNotice": "Полные конфигурации используют собственные маршруты. Этот выбор применяется после возврата к обычным узлам.", + "connectionRestoreOrdinary": "Использовать предыдущий обычный план", + "connectionPreparing": "Подготовка подключения", + "xrayDiagnostics": "Диагностика", + "xrayDiagnosticsHint": "Просмотр ошибок подключения, конфигурации и журналов.", + "xrayRefreshStatus": "Обновить статус", + "xrayNoConnectionError": "Текущих ошибок подключения нет.", + "xrayNoRuntimeConfiguration": "Рабочая конфигурация пока отсутствует. Подключитесь, чтобы просмотреть параметры запуска." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ded26e8a3..cb6b88dcb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -966,5 +966,40 @@ "jsonEditorDiscardChanges": "放弃更改", "tunnelDnsIpv4Invalid": "请输入有效的 IPv4 地址。", "tunnelDnsIpv6Invalid": "请输入有效的 IPv6 地址。", - "tunnelDnsServerNameRequired": "请输入 DNS over TLS 服务器名称。" + "tunnelDnsServerNameRequired": "请输入 DNS over TLS 服务器名称。", + "navigationRouting": "路由", + "serverNodesTab": "节点", + "serverCompleteConfigurationsTab": "完整配置", + "serverAllSources": "全部来源", + "connectionPlan": "连接计划", + "serverSelectionAutomaticScope": "自动选择范围", + "serverSelectionFixedNode": "固定节点", + "connectionCandidates": "{count} 个候选节点", + "@connectionCandidates": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRequiredEntries": "需要 {count} 个接入节点", + "@connectionRequiredEntries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRememberedPlanUnavailable": "上次的连接计划已不可用,请重新选择范围或节点。", + "clearConnectionResources": "清除连接资源", + "clearConnectionResourcesWarning": "删除服务器、订阅、路由配置和连接选择,保留 App 偏好、权限和备份位置。", + "routingPageDescription": "选择流量处理方式,或编辑自定义规则。", + "routingStoredPreferenceNotice": "完整配置使用自带路由。这里的选择将在切换回普通节点后生效。", + "connectionRestoreOrdinary": "使用上次普通连接计划", + "connectionPreparing": "正在准备连接", + "xrayDiagnostics": "诊断与排查", + "xrayDiagnosticsHint": "查看连接错误、运行配置与日志。", + "xrayRefreshStatus": "刷新状态", + "xrayNoConnectionError": "当前没有连接错误。", + "xrayNoRuntimeConfiguration": "尚无运行配置。建立连接后可查看实际启动输入。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index c3c4f5882..53e300a5a 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -966,5 +966,40 @@ "jsonEditorDiscardChanges": "放棄變更", "tunnelDnsIpv4Invalid": "請輸入有效的 IPv4 位址。", "tunnelDnsIpv6Invalid": "請輸入有效的 IPv6 位址。", - "tunnelDnsServerNameRequired": "請輸入 DNS over TLS 伺服器名稱。" + "tunnelDnsServerNameRequired": "請輸入 DNS over TLS 伺服器名稱。", + "navigationRouting": "路由", + "serverNodesTab": "節點", + "serverCompleteConfigurationsTab": "完整設定", + "serverAllSources": "全部來源", + "connectionPlan": "連線計畫", + "serverSelectionAutomaticScope": "自動選擇範圍", + "serverSelectionFixedNode": "固定節點", + "connectionCandidates": "{count} 個候選節點", + "@connectionCandidates": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRequiredEntries": "需要 {count} 個接入節點", + "@connectionRequiredEntries": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "connectionRememberedPlanUnavailable": "上次的連線計畫已無法使用,請重新選擇範圍或節點。", + "clearConnectionResources": "清除連線資源", + "clearConnectionResourcesWarning": "刪除伺服器、訂閱、路由設定和連線選擇,保留 App 偏好、權限和備份位置。", + "routingPageDescription": "選擇流量處理方式,或編輯自訂規則。", + "routingStoredPreferenceNotice": "完整設定使用自帶路由。這裡的選擇將在切換回普通節點後生效。", + "connectionRestoreOrdinary": "使用上次一般連線計畫", + "connectionPreparing": "正在準備連線", + "xrayDiagnostics": "診斷與排查", + "xrayDiagnosticsHint": "查看連線錯誤、執行設定與日誌。", + "xrayRefreshStatus": "重新整理狀態", + "xrayNoConnectionError": "目前沒有連線錯誤。", + "xrayNoRuntimeConfiguration": "尚無執行設定。建立連線後可查看實際啟動輸入。" } diff --git a/lib/pages/connect/routing/controller.dart b/lib/pages/connect/routing/controller.dart new file mode 100644 index 000000000..54aaf7a94 --- /dev/null +++ b/lib/pages/connect/routing/controller.dart @@ -0,0 +1,236 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/connect/dialogs.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/shared/alert.dart'; +import 'package:onexray/pages/shared/connection_action.dart'; +import 'package:onexray/pages/shared/page_cubit.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/provider_mode.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/routing/custom/configuration.dart'; +import 'package:onexray/service/connect/routing/custom/editor.dart'; +import 'package:onexray/service/connect/routing/custom/service.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/shared/failure.dart'; + +class RoutingPageState { + final ConnectionConfiguration? configuration; + final List profiles; + final bool completeConfiguration; + final bool busy; + final bool failed; + final String? error; + + const RoutingPageState({ + this.configuration, + this.profiles = const [], + this.completeConfiguration = false, + this.busy = false, + this.failed = false, + this.error, + }); +} + +class RoutingPageController extends PageCubit { + final AppDatabase db; + final ConnectionCoordinator coordinator; + final List> _subscriptions = []; + int _revision = 0; + + RoutingPageController({ + AppDatabase? database, + ConnectionCoordinator? coordinator, + }) : db = database ?? AppDatabase(), + coordinator = coordinator ?? ConnectionCoordinator.instance, + super(const RoutingPageState()) { + this.coordinator.state.addListener(_runtimeChanged); + load(); + } + + void _update({ + ConnectionConfiguration? configuration, + List? profiles, + bool? completeConfiguration, + bool? busy, + bool? failed, + String? error, + }) => emit( + RoutingPageState( + configuration: configuration ?? state.configuration, + profiles: profiles ?? state.profiles, + completeConfiguration: + completeConfiguration ?? state.completeConfiguration, + busy: busy ?? state.busy, + failed: failed ?? state.failed, + error: error, + ), + ); + + Future load() async { + _update(failed: false); + try { + final configuration = await coordinator.configuration; + final rows = await db.routingProfileDao.allRows; + for (final row in rows) { + CustomRoutingService.readConfiguration(row); + } + if (!isPageActive) return; + _update(configuration: configuration, profiles: rows); + await _readCompleteConfiguration(); + if (_subscriptions.isEmpty && isPageActive) { + _subscriptions.add( + db.connectionConfigDao.watch().listen((row) { + try { + _update( + configuration: ConnectionConfiguration.fromJson( + jsonDecode(row.configurationJson) as Map, + ), + ); + unawaited(_readCompleteConfiguration()); + } catch (_) { + _update(failed: true); + } + }, onError: (_) => _update(failed: true)), + ); + _subscriptions.add( + db.routingProfileDao.allRowsStream.listen((rows) { + try { + for (final row in rows) { + CustomRoutingService.readConfiguration(row); + } + _update(profiles: rows); + } catch (_) { + _update(failed: true); + } + }, onError: (_) => _update(failed: true)), + ); + } + } catch (_) { + _update(failed: true); + } + } + + void _runtimeChanged() => unawaited(_readCompleteConfiguration()); + + Future _readCompleteConfiguration() async { + final configuration = state.configuration; + if (configuration == null) return; + final revision = ++_revision; + try { + final complete = + configuration.connection.expert || + await usesProviderConfiguration( + db, + configuration.connection, + runtime: coordinator.state.value.runtime, + ); + if (isPageActive && revision == _revision) { + _update(completeConfiguration: complete); + } + } catch (_) { + if (revision == _revision) _update(failed: true); + } + } + + RoutingConfiguration configurationFor(RoutingProfileData row) => + CustomRoutingService.readConfiguration(row); + + Future select(BuildContext context, TrafficMode mode, {int? id}) async { + if (state.busy) return; + _update(busy: true); + try { + final current = await coordinator.configuration; + final complete = + current.connection.expert || + await usesProviderConfiguration( + db, + current.connection, + runtime: coordinator.state.value.runtime, + ); + if (!context.mounted || !isPageActive) return; + await applyConnectionChange( + context, + coordinator, + { + 'trafficMode': mode.name, + 'customId': mode == TrafficMode.custom ? id : null, + }, + affectsRuntime: !complete, + label: (settings) { + final l = AppLocalizations.of(context)!; + if (mode == TrafficMode.smart) return l.prototypeSmartRouting; + if (mode == TrafficMode.allVpn) return l.prototypeAllViaVpn; + return state.profiles.firstWhere((row) => row.id == id).name; + }, + ); + } catch (failure) { + if (context.mounted) + _update( + error: appFailureMessage(AppLocalizations.of(context)!, failure), + ); + } finally { + _update(busy: false); + } + } + + Future edit(BuildContext context, [RoutingProfileData? row]) async { + if (state.busy) return; + final advanced = + row?.advanced ?? await showCustomRoutingModeDialog(context); + if (advanced == null || !context.mounted) return; + await context.pushScoped( + advanced + ? AppPageDestination.advancedRouting + : AppPageDestination.customRouting, + extra: row?.id, + ); + } + + Future delete(BuildContext context, RoutingProfileData row) async { + if (state.busy) return; + final l = AppLocalizations.of(context)!; + _update(busy: true); + try { + await CustomRoutingEditorService( + database: db, + coordinator: coordinator, + ).delete( + row, + confirm: (selected, reconnect) => context.mounted + ? ContextAlert.showConfirmDialog( + context, + title: l.prototypeDeleteName(row.name), + content: reconnect + ? l.prototypeDeletingRouteReconnectNotice + : selected + ? l.prototypeDeletedRouteSmartNotice + : l.prototypeRemoveRouteNotice, + confirmLabel: reconnect + ? l.prototypeSwitchAndReconnect + : selected + ? l.prototypeDeleteAndUseSmartRouting + : l.prototypeDeleteRoute, + ) + : Future.value(false), + ); + } catch (failure) { + _update(error: appFailureMessage(l, failure)); + } finally { + _update(busy: false); + } + } + + @override + Future disposePageResources() async { + _revision++; + coordinator.state.removeListener(_runtimeChanged); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + } +} diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart new file mode 100644 index 000000000..484bfae1b --- /dev/null +++ b/lib/pages/connect/routing/page.dart @@ -0,0 +1,284 @@ +import 'dart:async'; + +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/connect/routing/controller.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class RoutingPage extends StatefulWidget { + const RoutingPage({super.key}); + + @override + State createState() => _RoutingPageState(); +} + +class _RoutingPageState extends State { + final controller = RoutingPageController(); + + @override + void dispose() { + unawaited(controller.close()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BlocProvider.value( + value: controller, + child: BlocBuilder( + builder: (context, state) { + final l = AppLocalizations.of(context)!; + final settings = state.configuration?.connection; + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; + final palette = ColorManager.palette(context); + return Scaffold( + appBar: PageAppBar(title: Text(l.navigationRouting)), + body: SafeArea( + child: settings == null + ? Center( + child: state.failed + ? TextButton( + onPressed: controller.load, + child: Text(l.prototypeRetry), + ) + : const CircularProgressIndicator(), + ) + : SettingsPageScroll( + padding: EdgeInsetsDirectional.fromSTEB( + gutter, + 16, + gutter, + 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + l.routingPageDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.mutedForeground), + ), + if (state.completeConfiguration) + ShadAlert( + icon: const Icon(LucideIcons.info, size: 18), + description: Text(l.routingStoredPreferenceNotice), + ), + SettingSection( + title: l.prototypeTrafficMethod, + padding: EdgeInsets.zero, + dividerIndent: 0, + children: [ + _choice( + context, + title: l.prototypeSmartRouting, + description: l.prototypeSmartRoutingDescription, + selected: + settings.trafficMode == TrafficMode.smart, + icon: LucideIcons.sparkles, + onUse: () => + controller.select(context, TrafficMode.smart), + onEdit: () => context.pushScoped( + AppPageDestination.smartRouting, + ), + ), + _choice( + context, + title: l.prototypeAllViaVpn, + description: l.prototypeAllViaVpnDescription, + selected: + settings.trafficMode == TrafficMode.allVpn, + icon: LucideIcons.shield, + onUse: () => controller.select( + context, + TrafficMode.allVpn, + ), + ), + ], + ), + SettingSection( + title: l.prototypeCustomRouting, + description: l.prototypeCustomRouteLimit, + action: ShadButton.outline( + size: ShadButtonSize.sm, + enabled: !state.busy && state.profiles.length < 3, + onPressed: () => controller.edit(context), + leading: const Icon(LucideIcons.plus, size: 16), + child: Text(l.prototypeNewCustomRoute), + ), + padding: EdgeInsets.zero, + dividerIndent: 0, + children: [ + for (final row in state.profiles) + _choice( + context, + title: row.name, + description: row.advanced + ? l.routingAdvancedJson + : '${l.routingNormalConfiguration} · ${l.prototypeRuleCount(controller.configurationFor(row).ruleCount)}', + selected: + settings.trafficMode == + TrafficMode.custom && + settings.customId == row.id, + icon: row.advanced + ? LucideIcons.fileJson + : LucideIcons.listOrdered, + onUse: () => controller.select( + context, + TrafficMode.custom, + id: row.id, + ), + onEdit: () => controller.edit(context, row), + onDelete: () => controller.delete(context, row), + ), + if (state.profiles.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l.prototypeCustomRoutingDescription, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: palette.mutedForeground, + ), + ), + ), + ], + ), + SettingSection( + title: l.prototypeRoutingData, + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: l.prototypeRoutingDataSummary, + subtitle: l.prototypeRoutingDataHint, + leading: const Icon( + LucideIcons.database, + size: 19, + ), + decorateLeading: false, + showChevron: true, + onTap: () => context.pushScoped( + AppPageDestination.routingData, + ), + ), + ], + ), + if (state.failed || state.error != null) + ShadAlert.destructive( + description: Text( + state.error ?? l.prototypeTemporarilyUnavailable, + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + + Widget _choice( + BuildContext context, { + required String title, + required String description, + required bool selected, + required IconData icon, + required VoidCallback onUse, + VoidCallback? onEdit, + VoidCallback? onDelete, + }) { + final l = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + return Semantics( + selected: selected, + child: Container( + color: selected ? palette.selectedSurface : null, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 12, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12, top: 2), + child: Icon( + icon, + size: 20, + color: selected ? palette.primary : palette.mutedStrong, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + description, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.mutedForeground), + ), + ], + ), + ), + if (selected) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: Icon( + LucideIcons.circleCheck, + size: 20, + color: palette.primary, + ), + ), + ], + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ShadButton( + size: ShadButtonSize.sm, + enabled: !controller.state.busy && !selected, + onPressed: onUse, + child: Text( + selected ? l.prototypeCurrentSelection : l.prototypeUse, + ), + ), + if (onEdit != null) + ShadButton.outline( + size: ShadButtonSize.sm, + enabled: !controller.state.busy, + onPressed: onEdit, + child: Text(l.prototypeEdit), + ), + if (onDelete != null) + ShadButton.ghost( + size: ShadButtonSize.sm, + enabled: !controller.state.busy, + onPressed: onDelete, + leading: const Icon(LucideIcons.trash2, size: 16), + child: Text(l.prototypeDelete), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index a2a64228f..e27735232 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -255,6 +255,8 @@ class _AdaptiveMainShellState extends State { .values[navigationShell.currentIndex] .rootPath ? null + : navigationShell.currentIndex == AppPrimaryDestination.settings.index + ? null : DecoratedBox( key: const ValueKey('primary-mobile-navigation'), decoration: BoxDecoration( @@ -269,7 +271,9 @@ class _AdaptiveMainShellState extends State { AppPrimaryDestination.values[index], ), destinations: [ - for (final primary in AppPrimaryDestination.values) + for (final primary in AppPrimaryDestination.values.where( + (p) => p != AppPrimaryDestination.settings, + )) NavigationDestination( key: ValueKey('primary-navigation-${primary.name}'), icon: _navigationIcon( @@ -392,20 +396,29 @@ class _AdaptiveMainShellState extends State { ), ), ), - for (final primary in AppPrimaryDestination.values) ...[ + for (final primary in AppPrimaryDestination.values.where( + (p) => p != AppPrimaryDestination.settings, + )) ...[ if (primary.index > 0) const SizedBox(height: AppSpacing.sidebarRowGap), _desktopDestination(context, primary), ], ], ), - if (appUpdateInfo != null) - Padding( - padding: const EdgeInsets.only(top: AppSpacing.sidebarRowGap), - child: _DesktopUpdateReminder( - onTap: () => _showDesktopUpdate(context, appUpdateInfo), - ), - ), + Column( + children: [ + _desktopDestination(context, AppPrimaryDestination.settings), + if (appUpdateInfo != null) + Padding( + padding: const EdgeInsets.only( + top: AppSpacing.sidebarRowGap, + ), + child: _DesktopUpdateReminder( + onTap: () => _showDesktopUpdate(context, appUpdateInfo), + ), + ), + ], + ), ], ), ), @@ -491,7 +504,9 @@ class _AdaptiveMainShellState extends State { return switch (primary) { AppPrimaryDestination.connect => LucideIcons.link, AppPrimaryDestination.servers => LucideIcons.layers3, - AppPrimaryDestination.advanced => LucideIcons.terminal, + AppPrimaryDestination.routing => LucideIcons.route, + AppPrimaryDestination.tunnel => LucideIcons.shieldCheck, + AppPrimaryDestination.xray => LucideIcons.activity, AppPrimaryDestination.settings => LucideIcons.settings, }; } @@ -501,7 +516,9 @@ class _AdaptiveMainShellState extends State { return switch (primary) { AppPrimaryDestination.connect => localizations.prototypeConnect, AppPrimaryDestination.servers => localizations.prototypeServers, - AppPrimaryDestination.advanced => localizations.prototypeAdvanced, + AppPrimaryDestination.routing => localizations.navigationRouting, + AppPrimaryDestination.tunnel => localizations.prototypeVpnTunnel, + AppPrimaryDestination.xray => 'Xray', AppPrimaryDestination.settings => localizations.prototypeSettings, }; } diff --git a/lib/pages/main/advanced.dart b/lib/pages/main/advanced.dart index 46d786e41..dfca4ff93 100644 --- a/lib/pages/main/advanced.dart +++ b/lib/pages/main/advanced.dart @@ -1,23 +1,47 @@ import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/advanced/page.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/tunnel/page.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/xray/page.dart'; import 'package:onexray/pages/main/navigation.dart'; /// Route wiring stays in the shell; platform pages only edit their own drafts. -class AdvancedRootPage extends StatelessWidget { +class AdvancedRootPage extends TunnelRootPage { const AdvancedRootPage({super.key}); +} + +class TunnelRootPage extends StatelessWidget { + const TunnelRootPage({super.key}); @override - Widget build(BuildContext context) => AdvancedPage( - openTunnel: (context, destination, draft) => - context.pushScoped(switch (destination) { - TunnelDestination.apple => AppPageDestination.appleVpn, - TunnelDestination.android => AppPageDestination.androidVpn, - TunnelDestination.windows => AppPageDestination.windowsVpn, - TunnelDestination.interface => AppPageDestination.outboundInterface, - }, extra: draft), - xrayBuilder: (context) => XrayRuntimePage( + Widget build(BuildContext context) => Scaffold( + appBar: PageAppBar( + title: Text(AppLocalizations.of(context)!.prototypeVpnTunnel), + ), + body: VpnTunnelPane( + openTunnel: (context, destination, draft) => + context.pushScoped(switch (destination) { + TunnelDestination.apple => AppPageDestination.appleVpn, + TunnelDestination.android => AppPageDestination.androidVpn, + TunnelDestination.windows => AppPageDestination.windowsVpn, + TunnelDestination.interface => AppPageDestination.outboundInterface, + }, extra: draft), + ), + ); +} + +class XrayRootPage extends StatelessWidget { + const XrayRootPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: PageAppBar( + title: Text( + AppLocalizations.of(context)!.prototypeXrayRuntimeDiagnostics, + ), + ), + body: XrayRuntimePage( onGeodata: (context) => context.pushScoped(AppPageDestination.routingData), onUpdates: (context) => context.pushScoped(AppPageDestination.autoUpdate), diff --git a/lib/pages/main/navigation.dart b/lib/pages/main/navigation.dart index b3d422c9e..cceee504b 100644 --- a/lib/pages/main/navigation.dart +++ b/lib/pages/main/navigation.dart @@ -7,7 +7,9 @@ import 'package:onexray/service/settings/app_update/service.dart'; enum AppPrimaryDestination { connect(AppPageDestination.connect), servers(AppPageDestination.servers), - advanced(AppPageDestination.advanced), + routing(AppPageDestination.routing), + tunnel(AppPageDestination.tunnel), + xray(AppPageDestination.xray), settings(AppPageDestination.settings); final AppPageDestination page; @@ -33,6 +35,9 @@ enum AppPageDestination { connect("connect"), servers("servers"), advanced("advanced"), + routing("routing"), + tunnel("tunnel"), + xray("xray"), settings("settings"), serversImport("servers-import"), serverGroup("server-group"), diff --git a/lib/pages/main/url.dart b/lib/pages/main/url.dart index dd3fdbd36..10cb68bf9 100644 --- a/lib/pages/main/url.dart +++ b/lib/pages/main/url.dart @@ -42,6 +42,7 @@ import 'package:onexray/pages/servers/page.dart'; import 'package:onexray/pages/servers/order/controller.dart'; import 'package:onexray/pages/servers/order/page.dart'; import 'package:onexray/pages/connect/page.dart'; +import 'package:onexray/pages/connect/routing/page.dart'; import 'package:onexray/pages/settings/page.dart'; import 'package:onexray/pages/connect/routing/smart/exit_picker.dart'; import 'package:onexray/pages/servers/controller.dart'; @@ -112,6 +113,11 @@ abstract final class RouterPath { ), GoRoute(path: RouterPath.privacy, redirect: (_, _) => RouterPath.setup), GoRoute(path: RouterPath.firstRun, redirect: (_, _) => RouterPath.setup), + GoRoute(path: '/advanced', redirect: (_, _) => '/tunnel'), + GoRoute( + path: '/advanced/:page', + redirect: (_, state) => '/tunnel/${state.pathParameters['page']}', + ), StatefulShellRoute.indexedStack( // Forwarded branch events would overwrite the root modal state. notifyRootObserver: false, @@ -217,6 +223,9 @@ final _pageRoutes = <_PageRoute>[ _route(AppPageDestination.connect, (_, _) => const ConnectPage()), _route(AppPageDestination.servers, (_, _) => const ServersPage()), _route(AppPageDestination.advanced, (_, _) => const AdvancedRootPage()), + _route(AppPageDestination.routing, (_, _) => const RoutingPage()), + _route(AppPageDestination.tunnel, (_, _) => const TunnelRootPage()), + _route(AppPageDestination.xray, (_, _) => const XrayRootPage()), _route(AppPageDestination.settings, (_, _) => const SettingsPage()), _route(AppPageDestination.localApi, (_, _) => const LocalApiPage()), _route(AppPageDestination.lanProxy, (_, _) => const LanProxyPage()), diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index cb2c5eeb9..3ec261c6d 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -266,7 +266,7 @@ class SettingsPage extends StatelessWidget { child: Padding( padding: EdgeInsetsDirectional.fromSTEB( mobile ? 12 : AppSpacing.page, - mobile ? 27 : 43, + mobile ? 16 : 24, mobile ? 12 : AppSpacing.page, mobile ? 20 : 42, ), @@ -286,49 +286,19 @@ class SettingsPage extends StatelessWidget { ), ), ), - Builder( - builder: (context) => - MediaQuery.sizeOf(context).width > - AppLayout.compactDesktopBreakpoint - ? Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - children: [ - appearance, - SizedBox(height: sectionGap), - language, - SizedBox(height: sectionGap), - startup, - ], - ), - ), - const SizedBox(width: 56), - Expanded( - child: Column( - children: [ - data, - SizedBox(height: sectionGap), - about, - ], - ), - ), - ], - ) - : Column( - children: [ - appearance, - SizedBox(height: sectionGap), - language, - SizedBox(height: sectionGap), - startup, - SizedBox(height: sectionGap), - data, - SizedBox(height: sectionGap), - about, - ], - ), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + appearance, + SizedBox(height: sectionGap), + language, + SizedBox(height: sectionGap), + startup, + SizedBox(height: sectionGap), + data, + SizedBox(height: sectionGap), + about, + ], ), ], ), diff --git a/lib/pages/shared/connection_action.dart b/lib/pages/shared/connection_action.dart index 9fa242276..91ef56514 100644 --- a/lib/pages/shared/connection_action.dart +++ b/lib/pages/shared/connection_action.dart @@ -58,6 +58,7 @@ Future applyConnectionChange( ConnectionCoordinator coordinator, Map values, { required String Function(ConnectionSettings) label, + bool affectsRuntime = true, Future Function()? writeAssets, Future Function()? validateAssets, bool rethrowErrors = false, @@ -72,7 +73,9 @@ Future applyConnectionChange( ); if (next.encode() == current.encode() && writeAssets == null) return current; if (!context.mounted) return null; - final reconnect = coordinator.state.value.phase == ConnectionPhase.connected; + final reconnect = + affectsRuntime && + coordinator.state.value.phase == ConnectionPhase.connected; if (reconnect && !await showApplyAndReconnectDialog( context, @@ -89,6 +92,7 @@ Future applyConnectionChange( validateAssets: validateAssets, expectedConfiguration: current.encode(), allowReconnect: reconnect, + affectsRuntime: affectsRuntime, ); saved = await coordinator.configuration; }, rethrowErrors: rethrowErrors); diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index 636920ab1..93cd23305 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -1,6 +1,11 @@ import 'package:macos_window_utils/widgets/macos_toolbar_passthrough.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/main/desktop_window.dart'; +import 'package:go_router/go_router.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; /// Keeps the themed AppBar while forwarding clicks on controls that overlap /// the native macOS titlebar. Empty titlebar space keeps native window gestures. @@ -42,16 +47,34 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { : const BackButton(); } } + final effectiveActions = [...?actions]; + final router = GoRouter.maybeOf(context); + if (router != null && + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint) { + final path = GoRouterState.of(context).uri.path; + if (AppPrimaryDestination.values.any( + (p) => p != AppPrimaryDestination.settings && p.rootPath == path, + )) { + effectiveActions.add( + IconButton( + key: const ValueKey('mobile-settings-action'), + tooltip: AppLocalizations.of(context)!.prototypeSettings, + icon: const Icon(LucideIcons.settings2), + onPressed: () => context.pushScoped(AppPageDestination.settings), + ), + ); + } + } return AppBar( title: title, leading: forwardClicks && effectiveLeading != null ? _AnimatedToolbarPassthrough(child: effectiveLeading) : effectiveLeading, actions: forwardClicks - ? actions - ?.map((child) => _AnimatedToolbarPassthrough(child: child)) + ? effectiveActions + .map((child) => _AnimatedToolbarPassthrough(child: child)) .toList() - : actions, + : effectiveActions, bottom: bottom, ); } diff --git a/lib/pages/theme/color.dart b/lib/pages/theme/color.dart index d1cfab066..a53c4b47e 100644 --- a/lib/pages/theme/color.dart +++ b/lib/pages/theme/color.dart @@ -69,47 +69,47 @@ class AppPalette { // Semantic mapping of references/onexray-app-prototype/src/theme/tokens.json. // Interactive colors and solid button fills differ in the dark theme. static const light = AppPalette( - background: Color(0xFFFEFEFE), - foreground: Color(0xFF0E1020), - card: Color(0xFFFEFEFE), - cardForeground: Color(0xFF0E1020), - popover: Color(0xFFFEFEFE), - popoverForeground: Color(0xFF0E1020), - primary: Color(0xFF1F6AF9), - primaryHover: Color(0xFF185BD8), - primaryText: Color(0xFF185BD8), - primarySolid: Color(0xFF1F6AF9), - primarySolidHover: Color(0xFF185BD8), + background: Color(0xFFF2F4F8), + foreground: Color(0xFF202735), + card: Color(0xFFFFFFFF), + cardForeground: Color(0xFF202735), + popover: Color(0xFFFFFFFF), + popoverForeground: Color(0xFF202735), + primary: Color(0xFF3268EF), + primaryHover: Color(0xFF2455D2), + primaryText: Color(0xFF2455D2), + primarySolid: Color(0xFF3268EF), + primarySolidHover: Color(0xFF2455D2), primaryForeground: Color(0xFFFFFFFF), surfaceHover: Color(0xFFF4F7FA), - secondary: Color(0xFFFEFEFE), - secondaryForeground: Color(0xFF0E1020), - muted: Color(0xFFF8FAFC), - mutedForeground: Color(0xFF6F7688), + secondary: Color(0xFFFFFFFF), + secondaryForeground: Color(0xFF202735), + muted: Color(0xFFF2F4F8), + mutedForeground: Color(0xFF697386), mutedStrong: Color(0xFF565D6D), accent: Color(0xFFEEF5FF), - accentForeground: Color(0xFF1F6AF9), + accentForeground: Color(0xFF3268EF), destructive: Color(0xFFCF2828), destructiveSolid: Color(0xFFCF2828), destructiveSolidHover: Color(0xFFB82020), destructiveForeground: Color(0xFFFFFFFF), - border: Color(0xFFDAE0E5), - borderStrong: Color(0xFFCBD3DC), - input: Color(0xFFDAE0E5), - ring: Color(0xFF1F6AF9), + border: Color(0xFFE7EAF0), + borderStrong: Color(0xFFCFD5E0), + input: Color(0xFFE7EAF0), + ring: Color(0xFF3268EF), selection: Color(0xFFEEF5FF), scannerBackground: Color(0xFF101B35), overlay: Color.fromRGBO(9, 17, 29, 0.48), - header: Color(0xFFFEFEFE), - brand: Color(0xFF071B55), - sidebar: Color(0xFFF8FAFC), + header: Color(0xFFF2F4F8), + brand: Color(0xFF202735), + sidebar: Color(0xFFF2F4F8), sidebarForeground: Color(0xFF565D6D), - sidebarPrimary: Color(0xFF1F6AF9), + sidebarPrimary: Color(0xFF3268EF), sidebarPrimaryForeground: Color(0xFFFFFFFF), sidebarAccent: Color(0xFFE4ECF7), sidebarAccentForeground: Color(0xFF133D70), - sidebarBorder: Color(0xFFDAE0E5), - sidebarRing: Color(0xFF1F6AF9), + sidebarBorder: Color(0xFFE7EAF0), + sidebarRing: Color(0xFF3268EF), selectedSurface: Color(0xFFEEF5FF), running: Color(0xFF1FA04E), runningText: Color(0xFF15803D), @@ -129,48 +129,48 @@ class AppPalette { ); static const dark = AppPalette( - background: Color(0xFF10151E), - foreground: Color(0xFFF2F5F8), - card: Color(0xFF10151E), - cardForeground: Color(0xFFF2F5F8), - popover: Color(0xFF10151E), - popoverForeground: Color(0xFFF2F5F8), - primary: Color(0xFF69A5FF), + background: Color(0xFF17181C), + foreground: Color(0xFFF2F3F5), + card: Color(0xFF222328), + cardForeground: Color(0xFFF2F3F5), + popover: Color(0xFF292A30), + popoverForeground: Color(0xFFF2F3F5), + primary: Color(0xFF91B3FF), primaryHover: Color(0xFF8AB8FF), primaryText: Color(0xFF8AB8FF), - primarySolid: Color(0xFF1F6AF9), - primarySolidHover: Color(0xFF185BD8), + primarySolid: Color(0xFF3268EF), + primarySolidHover: Color(0xFF2455D2), primaryForeground: Color(0xFFFFFFFF), - surfaceHover: Color(0xFF1C2532), - secondary: Color(0xFF10151E), - secondaryForeground: Color(0xFFF2F5F8), - muted: Color(0xFF151C27), - mutedForeground: Color(0xFF9AA6B6), + surfaceHover: Color(0xFF2D2E35), + secondary: Color(0xFF17181C), + secondaryForeground: Color(0xFFF2F3F5), + muted: Color(0xFF222328), + mutedForeground: Color(0xFFA9AFBB), mutedStrong: Color(0xFFC2CAD5), - accent: Color(0xFF172D4C), - accentForeground: Color(0xFF69A5FF), + accent: Color(0xFF2A2E37), + accentForeground: Color(0xFF91B3FF), destructive: Color(0xFFFF6262), destructiveSolid: Color(0xFFCF2828), destructiveSolidHover: Color(0xFFB82020), destructiveForeground: Color(0xFFFFFFFF), - border: Color(0xFF2B3543), - borderStrong: Color(0xFF3A4655), - input: Color(0xFF2B3543), - ring: Color(0xFF69A5FF), - selection: Color(0xFF172D4C), + border: Color(0xFF35363E), + borderStrong: Color(0xFF50525B), + input: Color(0xFF35363E), + ring: Color(0xFF91B3FF), + selection: Color(0xFF2A2E37), scannerBackground: Color(0xFF101B35), overlay: Color.fromRGBO(9, 17, 29, 0.48), - header: Color(0xFF10151E), + header: Color(0xFF17181C), brand: Color(0xFFA9CAFF), - sidebar: Color(0xFF151C27), + sidebar: Color(0xFF17181C), sidebarForeground: Color(0xFFC2CAD5), - sidebarPrimary: Color(0xFF69A5FF), + sidebarPrimary: Color(0xFF91B3FF), sidebarPrimaryForeground: Color(0xFFFFFFFF), - sidebarAccent: Color(0xFF1B232F), + sidebarAccent: Color(0xFF2A2E37), sidebarAccentForeground: Color(0xFFDDEDFF), - sidebarBorder: Color(0xFF2B3543), - sidebarRing: Color(0xFF69A5FF), - selectedSurface: Color(0xFF172D4C), + sidebarBorder: Color(0xFF35363E), + sidebarRing: Color(0xFF91B3FF), + selectedSurface: Color(0xFF2A2E37), running: Color(0xFF55D484), runningText: Color(0xFF55D484), runningBadge: Color(0xFF6EE7B7), diff --git a/lib/pages/theme/font.dart b/lib/pages/theme/font.dart index 784471437..4e0740153 100644 --- a/lib/pages/theme/font.dart +++ b/lib/pages/theme/font.dart @@ -23,23 +23,23 @@ abstract final class AppTypography { fontFamilyFallback: AppFontFamily.sansFallback, ); - static final pageTitle = _style(31, 700); - static final panelTitle = _style(19, 700); - static final sectionTitle = _style(14, 700); - static final rowTitle = _style(13, 700); - static final rowValue = _style(13); - static final supporting = _style(12); - static final metadata = _style(12); - static final control = _style(13, 600); - static final navigationLabel = _style(16, 500); - static final selectedNavigationLabel = _style(16, 600); + static final pageTitle = _style(26, 600); + static final panelTitle = _style(16, 600); + static final sectionTitle = _style(16, 600); + static final rowTitle = _style(14, 600); + static final rowValue = _style(14); + static final supporting = _style(13); + static final metadata = _style(13); + static final control = _style(14, 600); + static final navigationLabel = _style(14, 500); + static final selectedNavigationLabel = _style(14, 600); static final badge = _style(12, 600); static final code = _style(12); - static final metric = _style(20, 600); + static final metric = _style(22, 500); static final pageAction = _style(12, 600); - static final settingsSectionTitle = _style(14, 700); - static final settingsSectionDesktopTitle = _style(16, 700); + static final settingsSectionTitle = _style(16, 600); + static final settingsSectionDesktopTitle = _style(16, 600); static final settingsRow = _style(14, 600); static final settingsFieldTitle = _style(14, 600); static final settingsValueLabel = _style(13, 400); diff --git a/lib/pages/theme/layout.dart b/lib/pages/theme/layout.dart index 3e2d0c873..fac65ee53 100644 --- a/lib/pages/theme/layout.dart +++ b/lib/pages/theme/layout.dart @@ -16,12 +16,12 @@ abstract final class AppLayout { static const macOSTitlebarHeight = 28.0; static const mobileBreakpoint = 720.0; static const compactDesktopBreakpoint = 900.0; - static const desktopSidebarWidth = 225.0; + static const desktopSidebarWidth = 216.0; static const compactSidebarWidth = 190.0; - static const standardMaxWidth = 1120.0; - static const advancedMaxWidth = 980.0; - static const settingsMaxWidth = 1176.0; - static const routingMaxWidth = 1220.0; + static const standardMaxWidth = 960.0; + static const advancedMaxWidth = 960.0; + static const settingsMaxWidth = 960.0; + static const routingMaxWidth = 960.0; static const routingEditorMaxWidth = 760.0; static const contentBreakpoint = 840.0; static const pageActionMinHeight = 72.0; @@ -38,7 +38,7 @@ abstract final class AppLayout { static const switchWidth = 42.0; static const switchHeight = 24.0; static const switchThumbMargin = 2.0; - static const emptyStateDesktopMinHeight = 520.0; + static const emptyStateDesktopMinHeight = 280.0; static const emptyStateTextMaxWidth = 420.0; static const connectStatusMinHeight = 158.0; static const connectDesktopStatusMinHeight = 256.0; @@ -65,10 +65,9 @@ abstract final class AppLayout { } abstract final class AppSpacing { - static double advancedDesktopGutter(double viewportWidth) => - (viewportWidth * .04).clamp(28.0, 48.0); + static double advancedDesktopGutter(double viewportWidth) => page; static const page = 28.0; - static const desktopPageTop = 43.0; + static const desktopPageTop = 24.0; static const desktopPageBottom = 42.0; static const sidebarHorizontal = 17.0; static const sidebarVertical = 36.0; @@ -80,7 +79,7 @@ abstract final class AppSpacing { static const sidebarBrandBottom = 40.0; static const sidebarRowHeight = 50.0; static const sidebarRowGap = 7.0; - static const mobilePage = 14.0; + static const mobilePage = 16.0; static const mobileHeaderHorizontal = 16.0; static const connectPageHorizontal = 15.0; static const connectPageTop = 4.0; @@ -96,12 +95,12 @@ abstract final class AppSpacing { abstract final class AppRadii { static const small = 4.0; - static const card = 8.0; - static const control = 7.0; + static const card = 18.0; + static const control = 12.0; static const compact = 6.0; static const chip = 5.0; static const indicator = 3.0; static const pill = 999.0; - static const dialog = 12.0; - static const mobileDialog = 15.0; + static const dialog = 20.0; + static const mobileDialog = 20.0; } diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index cc40b8662..3851c7803 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -49,7 +49,10 @@ abstract final class AppTheme { : Brightness.light, statusBarBrightness: brightness, ), - shape: Border(bottom: BorderSide(color: palette.border)), + titleTextStyle: AppTypography.pageTitle.copyWith( + fontSize: mobile ? 24 : 26, + color: palette.foreground, + ), ); static ThemeData secondaryPage(BuildContext context) { @@ -209,6 +212,12 @@ abstract final class AppTheme { colorScheme: shadColorScheme(brightness), radius: const BorderRadius.all(Radius.circular(AppRadii.control)), textTheme: AppTypography.shad, + cardTheme: ShadCardTheme( + backgroundColor: palette.card, + radius: BorderRadius.circular(AppRadii.card), + border: ShadBorder.all(color: palette.border, width: 1), + shadows: const [], + ), primaryButtonTheme: ShadButtonTheme( backgroundColor: palette.primarySolid, hoverBackgroundColor: palette.primarySolidHover, diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index 5a3f1773f..d566e6b8b 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -22,18 +22,22 @@ void main() { expect(AppPrimaryDestination.values.map((route) => route.name), [ 'connect', 'servers', - 'advanced', + 'routing', + 'tunnel', + 'xray', 'settings', ]); expect(AppPrimaryDestination.values.map((route) => route.rootPath), [ '/connect', '/servers', - '/advanced', + '/routing', + '/tunnel', + '/xray', '/settings', ]); expect( - AppPrimaryDestination.fromPath('/advanced/routing-data'), - AppPrimaryDestination.advanced, + AppPrimaryDestination.fromPath('/xray/routing-data'), + AppPrimaryDestination.xray, ); }); @@ -47,6 +51,8 @@ void main() { '/setup/region', '/privacy', '/firstRun', + '/advanced', + '/advanced/:page', ]); final shell = routes.whereType().single; for (final branch in shell.branches) { @@ -256,28 +262,22 @@ void main() { ); expect(navigationBar.height, isNull); expect(navigationBar.selectedIndex, AppPrimaryDestination.connect.index); - expect( - navigationBar.destinations, - hasLength(AppPrimaryDestination.values.length), - ); + expect(navigationBar.destinations, hasLength(5)); expect( tester.widget(connectDestination).label, 'Connect', ); - expect(find.byType(Badge), findsOneWidget); - await tester.tap(find.text('Settings')); + expect(find.byType(Badge), findsNothing); + router.go('/settings'); await tester.pumpAndSettle(); expect(router.routeInformationProvider.value.uri.path, '/settings'); - expect( - tester.widget(find.byType(NavigationBar)).selectedIndex, - AppPrimaryDestination.settings.index, - ); + expect(find.byType(NavigationBar), findsNothing); for (final path in ['/settings', '/settings/details']) { router.go(path); await tester.pumpAndSettle(); final root = path == '/settings'; final content = root ? 'settings-content' : 'details'; - expect(navigation, root ? findsOneWidget : findsNothing); + expect(navigation, findsNothing); expect(find.text(content), findsOneWidget); final branchNavigator = Navigator.of(tester.element(find.text(content))); @@ -309,7 +309,7 @@ void main() { expect(find.byType(AppDialog), findsNothing); expect(router.routeInformationProvider.value.uri.path, path); - expect(navigation, root ? findsOneWidget : findsNothing); + expect(navigation, findsNothing); expect(find.text(content), findsOneWidget); expect(tester.takeException(), isNull); } @@ -324,7 +324,7 @@ void main() { expect(find.byType(AppDialog), findsNothing); expect(router.routeInformationProvider.value.uri.path, '/settings'); expect(find.text('settings-content'), findsOneWidget); - expect(navigation, findsOneWidget); + expect(navigation, findsNothing); expect(tester.takeException(), isNull); router.go('/settings/details'); @@ -797,9 +797,13 @@ void main() { await tester.pumpAndSettle(); for (final primary in AppPrimaryDestination.values) { - await tester.tap( - find.byKey(ValueKey('primary-navigation-${primary.name}')), - ); + if (primary == AppPrimaryDestination.settings) { + router.go(primary.rootPath); + } else { + await tester.tap( + find.byKey(ValueKey('primary-navigation-${primary.name}')), + ); + } await tester.pumpAndSettle(); await tester.tap(find.text('open-${primary.name}')); await tester.pumpAndSettle(); @@ -818,12 +822,16 @@ void main() { router.routeInformationProvider.value.uri.path, primary.rootPath, ); - expect( - tester - .widget(find.byType(NavigationBar)) - .selectedIndex, - primary.index, - ); + if (primary == AppPrimaryDestination.settings) { + expect(find.byType(NavigationBar), findsNothing); + } else { + expect( + tester + .widget(find.byType(NavigationBar)) + .selectedIndex, + primary.index, + ); + } expect(tester.takeException(), isNull); } }, From 995ad8a047a46aff43f285ba8b4d47a710c667a8 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 18:14:23 +0800 Subject: [PATCH 04/60] feat(connect): select readable nodes without startup probes and remember ordinary plans --- docs/app.md | 3 + docs/backup.md | 4 +- docs/data-management.md | 3 +- docs/xray-configuration.md | 10 +- lib/service/connect/coordinator.dart | 8 + lib/service/connect/failure.dart | 3 +- lib/service/connect/ordinary_plan.dart | 96 +++ lib/service/connect/preparation.dart | 17 +- lib/service/connect/resolver.dart | 138 ++-- lib/service/connect/runtime.dart | 48 +- lib/service/connect/settings.dart | 21 + lib/service/servers/catalog.dart | 7 +- lib/service/servers/server.dart | 1 - test/service/connect/coordinator_test.dart | 6 +- test/service/connect/failure_test.dart | 2 +- test/service/connect/ordinary_plan_test.dart | 284 +++++++++ test/service/connect/preparation_test.dart | 92 ++- test/service/connect/resolver_test.dart | 591 +++++++----------- test/service/servers/catalog_test.dart | 2 +- .../servers/import_selection_flow_test.dart | 5 +- .../manual_group_integration_test.dart | 7 +- test/service/settings/backup/assets_test.dart | 6 + .../menu/tray/connection_failure_test.dart | 2 +- .../notification/entry_behavior_test.dart | 4 +- test/service/shared/ping/service_test.dart | 13 +- 25 files changed, 839 insertions(+), 534 deletions(-) create mode 100644 lib/service/connect/ordinary_plan.dart create mode 100644 test/service/connect/ordinary_plan_test.dart diff --git a/docs/app.md b/docs/app.md index c75b99c80..0d2b41e69 100644 --- a/docs/app.md +++ b/docs/app.md @@ -71,6 +71,9 @@ App 管理的普通模式选择自动、订阅、全部手动节点或固定节 提供方方式逐项选择完整配置,不需要开启专家模式;连接页显示“提供方配置”和路由摘要, App 流量方式切换不可用,Smart/Custom 偏好继续保存。模式改变或缓存隐藏后,运行名称仍来自 metadata 快照。 专家模式用 Raw JSON 区域替换整个服务器区域,保留普通选择但不混用。 +固定提供方完整配置前的普通选择由本机可选记忆保留,与选择一起事务提交;提供方之间切换不覆盖旧普通意图。 +返回普通计划先读取并确认,取消不修改选择或记忆;旧用户无记忆、引用缺失或来源方式不兼容时回到自动并要求重新选择。 +普通路由偏好继续保留;记忆不进入资产备份,恢复/清理连接资产时清空。Raw 沿用原普通选择保留行为。 仅打开 Raw 浏览区、尚未选择 Raw 时,不立即修改持久化连接目标;无关路由或平台偏好更新保留该本地浏览状态。 其他页面或托盘保存新的专家模式、Raw 或服务器选择后,连接页同步对应视图,避免旧 Raw 浏览状态阻挡普通节点或提供方配置连接。 配置类型、数量、节点链路和规则语义见 [Xray 配置](xray-configuration.md)。 diff --git a/docs/backup.md b/docs/backup.md index 44a08c7d2..c4ea43337 100644 --- a/docs/backup.md +++ b/docs/backup.md @@ -79,7 +79,7 @@ iOS 与 macOS 独立匹配,macOS SE 使用 `macos`;Windows 各分发使用 ` 最终出口若是本地节点,仍作为普通节点保存。 - 自定义 Geodata 只保存名称/类型/HTTPS URL,包含待下载声明,省略默认组;不保存任何资源文件。 - `data` 保留数据库 Base64 原字符串,不通过分享格式或普通模型裁剪 Raw/高级内容。 - 不保存数据库 ID、收藏、测速缓存、首页选择、专家模式、App 偏好、API 或 Android 自动化授权; + 不保存数据库 ID、收藏、测速缓存、首页选择、普通计划记忆、专家模式、App 偏好、API 或 Android 自动化授权; 服务器拨号随当前平台分区保存,显示排序仍为本机设置。 订阅完整配置与派生行不因活动方式改变而纳入本地资产备份;复制出的个人 Raw 按普通 Raw 保存。 @@ -101,7 +101,7 @@ Dart isolate 不能消除插件元数据主线程风险,云占位文件和线 新资产生成新 ID,不重置自增序列,旧编辑/回包不能覆盖新资产。 同平台 VPN 设置与连接资产在同一事务中恢复;无匹配分区时保留原平台策略。 保持目标设备的物理网卡、其它平台草稿、偏好、授权和备份设置;恢复为普通模式、自动节点、智能路由, -清空最终出口及 Raw/Custom 当前选择。完成后不自动连接、不立即覆盖云备份,也不追加订阅刷新或测速。 +清空最终出口、Raw/Custom 当前选择及本机普通计划记忆。完成后不自动连接、不立即覆盖云备份,也不追加订阅刷新或测速。 恢复完全离线:默认资源使用当前有效文件或启动准备的内置资源; 只有同名、同类型、同 URL 且有效的自定义文件才复用,其余登记待下载。 diff --git a/docs/data-management.md b/docs/data-management.md index 0332fbe37..5431b7f8a 100644 --- a/docs/data-management.md +++ b/docs/data-management.md @@ -55,7 +55,8 @@ v1–v2 创建新结构时不新增退休地区列,v3–v6 删除已有列, 名称、延迟等显示排序是设备偏好,保存后通过共享事件同步,重启沿用;缺失或未知值回到默认。 显示排序不改写手动顺序,切回默认时使用已保存的位置。手动分组和顺序进入资产备份,显示排序偏好不进入备份。 订阅刷新仍沿用正常替换与保护合同,新节点依据当前保存后的 ID/名称/延迟重新参与排序, -不维护跨刷新节点身份或手工位置。显示顺序与连接候选解析分离,自动接入仍按成功延迟、ID 选择, +不维护跨刷新节点身份或手工位置。显示顺序与连接候选解析分离,自动接入成功延迟优先,其余统一按 ID 排后; +测速不构成启动条件,连接不发起或等待测速,活动可读候选足量即选取。 已运行连接不随排序或测速更新迁移。偏好不进入备份;清理全部数据恢复默认顺序。 ### 测速结果 diff --git a/docs/xray-configuration.md b/docs/xray-configuration.md index 2edab899b..db475ad61 100644 --- a/docs/xray-configuration.md +++ b/docs/xray-configuration.md @@ -21,11 +21,15 @@ App 保留自身名称、资产数量/交换格式、文件链接安全、事务 ## 普通配置编译 -自动或来源选择先按路由要求选择 1–3 个健康的普通接入节点;固定节点及全部使用 VPN 使用一个。 -普通节点按延迟、ID 选择;数量不足或无法取得足量健康节点时,在同一范围内按 ID 升序选择首份可读取的活动提供方完整配置。 -只有候选缺少、数量不足或健康不足可触发该选择;取消、读取、测速服务异常及配置、编译、权限或启动错误直接报告,不重试其它提供方。 +自动或来源选择先按路由要求选择 1–3 个活动、可读取的普通接入节点;固定节点及全部使用 VPN 使用一个。 +已有成功延迟按耗时、ID 优先,全部未测、失败、超时或其它非成功结果统一排后按 ID 选择,不以测速结果限制启动。 +连接前不发起、等待或重试节点测速,独立手动/后台测速不阻塞解析;当前快照足量即进入编译与启动。 +只有候选缺少或结构数量不足时,才在同一范围内按 ID 升序选择首份可读取的活动提供方完整配置。 +空范围允许沿原规则重试全局自动,数量不足不重试全局;固定普通节点的重试不引入提供方。 +固定资源缺失、来源 mode 不兼容、最终出口缺失/自引用、取消、读取、配置、编译、权限或启动错误直接报告,不绕过前置检查或重试其它提供方。 完整配置不进入普通节点组合、最终出口或单 outbound 测速,也不与普通节点比较延迟。 自动或来源意图仍保存在设置中,实际选中的完整配置只进入运行快照,不改成固定选择。 +全局回退仅在成功启动后提交自动选择;本次运行 metadata 保留原选择、来源名称快照和回退原因,旧 metadata 缺字段仍可读取。 已运行节点不因后台测速或订阅更新热替换;节点命名见 [服务器及分享](subscriptions-and-sharing.md), 显示排序独立于自动候选选择,见 [数据管理](data-management.md#数据显示顺序)。 diff --git a/lib/service/connect/coordinator.dart b/lib/service/connect/coordinator.dart index 2bc1fe46c..d4e87c91e 100644 --- a/lib/service/connect/coordinator.dart +++ b/lib/service/connect/coordinator.dart @@ -7,6 +7,9 @@ import 'package:onexray/core/pigeon/flutter_api.dart'; import 'package:onexray/core/pigeon/host_api.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/service/connect/failure.dart'; +import 'package:onexray/service/connect/ordinary_plan.dart'; +export 'package:onexray/service/connect/ordinary_plan.dart' + show OrdinaryPlanRestore; import 'package:onexray/service/connect/preparation.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/runtime_host.dart'; @@ -153,6 +156,10 @@ class ConnectionCoordinator with WidgetsBindingObserver { Future readCurrentRuntime() => _readRuntime(); + /// The caller confirms/applies the returned plan using the normal change flow. + Future restoreOrdinaryPlan() async => + OrdinaryPlanService(db).restore(await configuration); + /// Custom preparation (node/route drafts) shares the same temporary reference /// protection as the default path. apply clears it on success and failure. void reportResolvedNodes(Set ids) { @@ -496,6 +503,7 @@ class ConnectionCoordinator with WidgetsBindingObserver { stored.encode() != expectedConfiguration) { throw const ConnectionHostException('configurationChanged'); } + next = await OrdinaryPlanService(db).retain(stored, next); final current = await _inspect(await _currentRuntime()); final shouldStart = !disconnect && (connect || (affectsRuntime && current.connected)); diff --git a/lib/service/connect/failure.dart b/lib/service/connect/failure.dart index a57a28c69..310380ed2 100644 --- a/lib/service/connect/failure.dart +++ b/lib/service/connect/failure.dart @@ -30,7 +30,7 @@ String connectionFailureMessage( final message = switch (issue) { 'selectionUnavailable' => '${l.prototypeNoAvailableEntries} · ${l.prototypeAddServers}', - 'insufficientCandidates' || 'insufficientHealthyServers' => + 'insufficientCandidates' => error is ConnectionResolutionException && error.requiredCount > 0 ? '${l.prototypeNotEnoughServers} (${error.availableCount}/${error.requiredCount})' : l.prototypeNotEnoughServers, @@ -65,7 +65,6 @@ bool connectionFailureNeedsWindow(Object error) => switch (connectionFailureReason(error)) { 'selectionUnavailable' || 'insufficientCandidates' || - 'insufficientHealthyServers' || 'cancelled' => false, _ => true, }; diff --git a/lib/service/connect/ordinary_plan.dart b/lib/service/connect/ordinary_plan.dart new file mode 100644 index 000000000..b473d875b --- /dev/null +++ b/lib/service/connect/ordinary_plan.dart @@ -0,0 +1,96 @@ +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/db/database/enum.dart'; +import 'package:onexray/service/connect/compiler.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; + +class OrdinaryPlanRestore { + final ConnectionConfiguration configuration; + final bool requiresSelection; + + const OrdinaryPlanRestore( + this.configuration, { + required this.requiresSelection, + }); +} + +/// Device-local intent bookkeeping, committed by the connection coordinator. +/// Reading a return plan neither changes saved settings nor starts a connection. +class OrdinaryPlanService { + final AppDatabase db; + OrdinaryPlanService(this.db); + + Future _provider(ServerSelection selection) async => + selection.kind == SelectionKind.server && + (await db.coreConfigDao.searchRow(selection.id!))?.type == + CoreConfigType.Remnawave.name; + + Future _ordinary(ServerSelection selection) async { + if (selection.kind == SelectionKind.automatic) return true; + if (selection.kind == SelectionKind.source) { + if (selection.id == 0) return true; + final source = await db.subscriptionDao.searchRow(selection.id!); + return source != null && source.mode == SubscriptionMode.appManaged.name; + } + final row = await db.coreConfigDao.searchActiveOutboundRow(selection.id!); + if (row == null) return false; + try { + ResolvedServer.fromRow(row); + return true; + } on FormatException { + return false; + } + } + + Future retain( + ConnectionConfiguration stored, + ConnectionConfiguration next, + ) async { + var remembered = stored.connection.rememberedOrdinarySelection; + if (!next.connection.expert) { + if (await _provider(next.connection.selection)) { + if (!await _provider(stored.connection.selection) && + await _ordinary(stored.connection.selection)) { + remembered = stored.connection.selection; + } + } else if (_different( + stored.connection.selection, + next.connection.selection, + ) && + await _ordinary(next.connection.selection)) { + remembered = next.connection.selection; + } + } + return ConnectionConfiguration( + connection: ConnectionSettings.fromJson({ + ...next.connection.toJson(), + 'rememberedOrdinarySelection': remembered?.toJson(), + }), + policy: next.policy, + ); + } + + Future restore(ConnectionConfiguration current) async { + final selection = current.connection.selection; + final candidate = await _provider(selection) + ? current.connection.rememberedOrdinarySelection + : selection; + final valid = candidate != null && await _ordinary(candidate); + final restored = valid ? candidate! : const ServerSelection.automatic(); + return OrdinaryPlanRestore( + ConnectionConfiguration( + connection: ConnectionSettings.fromJson({ + ...current.connection.toJson(), + 'expert': false, + 'selection': restored.toJson(), + 'rememberedOrdinarySelection': valid ? restored.toJson() : null, + }), + policy: current.policy, + ), + requiresSelection: !valid, + ); + } + + bool _different(ServerSelection first, ServerSelection second) => + first.kind != second.kind || first.id != second.id; +} diff --git a/lib/service/connect/preparation.dart b/lib/service/connect/preparation.dart index 4992d533b..7c207d2c5 100644 --- a/lib/service/connect/preparation.dart +++ b/lib/service/connect/preparation.dart @@ -164,6 +164,7 @@ class ConnectionPreparation { ResolvedProfile? profile; CoreConfigData? profileRow; String? sourceMode; + String? originalSelectionName; if (!settings.expert && settings.selection.kind == SelectionKind.server) { final id = settings.selection.id!; final selected = await db.coreConfigDao.searchRow(id); @@ -173,6 +174,7 @@ class ConnectionPreparation { ); } final active = await db.coreConfigDao.searchActiveServerRow(id); + originalSelectionName = selected.name; if (active == null) { throw const FormatException( 'The selected configuration is incompatible with the subscription mode', @@ -191,6 +193,7 @@ class ConnectionPreparation { throw const FormatException('The selected subscription is unavailable'); } sourceMode = source.mode; + originalSelectionName = source.name; } await ConnectionPlatformRequirements(platform: platform) .ensureOutboundInterface(policy.xrayOutboundInterfaceName); @@ -221,6 +224,7 @@ class ConnectionPreparation { custom = CustomRoutingService.readConfiguration(row); } String? notice; + RuntimeSelectionFallback? selectionFallback; List entries = const []; if (profile == null) { ResolvedConnectionTarget target; @@ -248,10 +252,7 @@ class ConnectionPreparation { throw const FormatException('The selected subscription mode changed'); } if (settings.selection.kind == SelectionKind.automatic || - !{ - ConnectionResolutionFailure.selectionUnavailable, - ConnectionResolutionFailure.insufficientHealthyServers, - }.contains(error.reason)) { + error.reason != ConnectionResolutionFailure.selectionUnavailable) { rethrow; } final retryFromFixed = settings.selection.kind == SelectionKind.server; @@ -259,7 +260,7 @@ class ConnectionPreparation { ...settings.toJson(), 'selection': const ServerSelection.automatic().toJson(), }); - // Preserve the existing ordinary-node retry for a failed fixed node. + // Preserve the existing ordinary-node retry for an unreadable fixed node. // Only automatic/source intent may introduce a provider target. target = retryFromFixed ? ResolvedConnectionTarget.entries( @@ -279,6 +280,11 @@ class ConnectionPreparation { policy: policy, ); notice = 'selectionReset'; + selectionFallback = RuntimeSelectionFallback( + originalSelection: input.connection.selection, + reason: error.reason.name, + sourceName: originalSelectionName, + ); } entries = target.entries; profile = target.profile; @@ -426,6 +432,7 @@ class ConnectionPreparation { platform: platform, request: request, notice: notice, + selectionFallback: selectionFallback, ); } } diff --git a/lib/service/connect/resolver.dart b/lib/service/connect/resolver.dart index fde3ea84f..abde79de2 100644 --- a/lib/service/connect/resolver.dart +++ b/lib/service/connect/resolver.dart @@ -5,19 +5,16 @@ import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/service/connect/compiler.dart'; import 'package:onexray/service/connect/settings.dart'; -import 'package:onexray/service/shared/ping/service.dart'; import 'package:onexray/service/connect/routing/custom/configuration.dart'; enum ConnectionResolutionFailure { selectionUnavailable, insufficientCandidates, - insufficientHealthyServers, finalExitUnavailable, selfReference, invalidSettings, cancelled, readFailed, - probeFailed, } class ConnectionResolutionException implements Exception { @@ -54,31 +51,40 @@ class ResolvedConnectionTarget { /// Resolves a committed selection to independent values, never a live session. /// The rows stream must emit its current rows whenever it is subscribed to; it -/// may then watch batch commits. The final re-read also supports one-shot readers. +/// may then watch commits. Only the first snapshot is used for each resolution. class ConnectionResolver { final Stream> Function() _rows; - final Future Function(List) _probe; - ConnectionResolver({ - Stream> Function()? rows, - Future Function(List)? probe, - }) : _rows = rows ?? _databaseRows, - _probe = probe ?? PingService().pingConfigIds; + ConnectionResolver({Stream> Function()? rows}) + : _rows = rows ?? _databaseRows; static Stream> _databaseRows() => AppDatabase().coreConfigDao.watchServers(); - /// Cancellation ends only this wait. Already scheduled probes keep updating - /// candidate caches and cannot change resolved inputs or a running VPN. + /// Shared by connection previews. Failure/unknown sentinels are never delays. + static int compareCandidates(CoreConfigData first, CoreConfigData second) { + final a = PingDelayConstants.isSuccessful(first.delay); + final b = PingDelayConstants.isSuccessful(second.delay); + if (a != b) return a ? -1 : 1; + if (a) { + final delay = first.delay.compareTo(second.delay); + if (delay != 0) return delay; + } + return first.id.compareTo(second.id); + } + + /// Cancellation ends only this read. Independent probes never block resolution + /// or mutate already resolved inputs or a running VPN. Future> resolve( ConnectionSettings settings, { RoutingConfiguration? custom, Future? cancelled, }) async => (await _resolve(settings, custom, cancelled)).entries; - /// Automatic and source selection prefer sufficient healthy ordinary nodes, + /// Automatic and source selection prefer sufficient readable ordinary nodes, /// then the first locally readable provider profile by ID within that scope. - /// Profiles are complete graphs and never enter ordinary-node probing. + /// Cached successful delays rank first; all other candidates rank by ID. + /// Resolution never starts or waits for a node probe. Future resolveTarget( ConnectionSettings settings, { RoutingConfiguration? custom, @@ -109,7 +115,6 @@ class ConnectionResolver { final result = Completer(); StreamSubscription>? subscription; - var probeStarted = false; void fail(ConnectionResolutionFailure reason, [int available = 0]) { if (!result.isCompleted) { @@ -123,23 +128,7 @@ class ConnectionResolver { } } - late void Function({required bool finalRead}) listen; - - Future probeAndReread(List ids) async { - try { - await _probe(ids); - if (result.isCompleted) return; - await subscription?.cancel(); - if (result.isCompleted) return; - // A Drift watch reload may trail the write Future. Read fresh rows after - // the queue drains instead of declaring failure from its previous event. - listen(finalRead: true); - } catch (_) { - fail(ConnectionResolutionFailure.probeFailed); - } - } - - void consider(List rows, {required bool finalRead}) { + void consider(List rows) { if (result.isCompleted) return; void unavailable( @@ -210,67 +199,16 @@ class ConnectionResolver { return; } } - final successful = - candidates - .where( - (candidate) => - PingDelayConstants.isSuccessful(candidate.row.delay), - ) - .toList() - ..sort((a, b) { - final delay = a.row.delay.compareTo(b.row.delay); - return delay == 0 ? a.row.id.compareTo(b.row.id) : delay; - }); - if (successful.length >= required) { - result.complete( - ResolvedConnectionTarget.entries( - successful.take(required).map((candidate) => candidate.server), - ), - ); - return; - } - if (finalRead) { - unavailable( - ConnectionResolutionFailure.insufficientHealthyServers, - successful.length, - ); - return; - } - if (probeStarted) return; - final unmeasured = candidates - .where((candidate) => PingService.isUnmeasured(candidate.row)) - .map((candidate) => candidate.row.id) - .toList(); - if (unmeasured.isEmpty) { - unavailable( - ConnectionResolutionFailure.insufficientHealthyServers, - successful.length, - ); - return; - } - probeStarted = true; - unawaited(probeAndReread(unmeasured)); + candidates.sort((a, b) { + return compareCandidates(a.row, b.row); + }); + result.complete( + ResolvedConnectionTarget.entries( + candidates.take(required).map((candidate) => candidate.server), + ), + ); } - listen = ({required bool finalRead}) { - var emitted = false; - try { - subscription = _rows().listen( - (rows) { - emitted = true; - consider(rows, finalRead: finalRead); - }, - onError: (Object _, StackTrace _) { - fail(ConnectionResolutionFailure.readFailed); - }, - onDone: () { - if (!emitted) fail(ConnectionResolutionFailure.readFailed); - }, - ); - } catch (_) { - fail(ConnectionResolutionFailure.readFailed); - } - }; if (cancelled != null) { unawaited( cancelled.then( @@ -283,7 +221,23 @@ class ConnectionResolver { ), ); } - listen(finalRead: false); + var emitted = false; + try { + subscription = _rows().listen( + (rows) { + emitted = true; + consider(rows); + }, + onError: (Object _, StackTrace _) { + fail(ConnectionResolutionFailure.readFailed); + }, + onDone: () { + if (!emitted) fail(ConnectionResolutionFailure.readFailed); + }, + ); + } catch (_) { + fail(ConnectionResolutionFailure.readFailed); + } try { return await result.future; } finally { diff --git a/lib/service/connect/runtime.dart b/lib/service/connect/runtime.dart index 8e3ba87e0..61351c3bd 100644 --- a/lib/service/connect/runtime.dart +++ b/lib/service/connect/runtime.dart @@ -106,6 +106,39 @@ class RuntimeProfile { }; } +class RuntimeSelectionFallback { + final ServerSelection originalSelection; + final String reason; + final String? sourceName; + + const RuntimeSelectionFallback({ + required this.originalSelection, + required this.reason, + this.sourceName, + }); + + Map toJson() => { + 'originalSelection': originalSelection.toJson(), + 'reason': reason, + if (sourceName != null) 'sourceName': sourceName, + }; + + factory RuntimeSelectionFallback.fromJson(Map value) { + if (value['originalSelection'] is! Map || + value['reason'] is! String || + (value['sourceName'] != null && value['sourceName'] is! String)) { + throw const FormatException('Invalid selection fallback metadata'); + } + return RuntimeSelectionFallback( + originalSelection: ServerSelection.fromJson( + value['originalSelection'] as Map, + ), + reason: value['reason'] as String, + sourceName: value['sourceName'] as String?, + ); + } +} + /// The prepared or active runtime input. It is never stored separately. /// start.json retains only the small metadata required to restore the UI and /// protect active nodes; the Xray JSON remains solely in coreInvokeText. @@ -118,6 +151,7 @@ class ConnectionRuntime { final RuntimeNode? finalExit; final RuntimeProfile? profile; final String? notice; + final RuntimeSelectionFallback? selectionFallback; final DateTime startedAt; late final List observationPaths = configuration.connection.expert || profile != null @@ -138,6 +172,7 @@ class ConnectionRuntime { this.profile, required this.startedAt, this.notice, + this.selectionFallback, }); factory ConnectionRuntime.create({ @@ -146,6 +181,7 @@ class ConnectionRuntime { required ConnectionPlatform platform, required StartVpnRequest request, String? notice, + RuntimeSelectionFallback? selectionFallback, DateTime? startedAt, }) { startedAt ??= DateTime.now(); @@ -166,6 +202,8 @@ class ConnectionRuntime { 'entries': [for (final entry in entries) entry.toJson()], 'finalExit': finalExit?.toJson(), if (profile != null) 'profile': profile.toJson(), + if (selectionFallback != null) + 'selectionFallback': selectionFallback.toJson(), }); final storedRequest = StartVpnRequest( request.tun, @@ -184,6 +222,7 @@ class ConnectionRuntime { finalExit: finalExit, profile: profile, notice: notice, + selectionFallback: selectionFallback, startedAt: startedAt, ); } @@ -209,7 +248,9 @@ class ConnectionRuntime { (metadata['finalExit'] != null && metadata['finalExit'] is! Map) || (metadata['profile'] != null && - metadata['profile'] is! Map)) { + metadata['profile'] is! Map) || + (metadata['selectionFallback'] != null && + metadata['selectionFallback'] is! Map)) { throw const FormatException('Invalid runtime metadata'); } final run = LibXrayRunConfig.fromInvokeText(invokeText).request; @@ -227,6 +268,11 @@ class ConnectionRuntime { metadata['startedAt'] as int, ), request: request, + selectionFallback: metadata['selectionFallback'] == null + ? null + : RuntimeSelectionFallback.fromJson( + metadata['selectionFallback'] as Map, + ), xrayJson: run.xrayJson!, entries: List.unmodifiable( (metadata['entries'] as List).map( diff --git a/lib/service/connect/settings.dart b/lib/service/connect/settings.dart index d306ae6a6..b78ea046f 100644 --- a/lib/service/connect/settings.dart +++ b/lib/service/connect/settings.dart @@ -129,6 +129,10 @@ class ConnectionSettings { final bool expert; final int? rawId; final ServerSelection selection; + + /// Device-local ordinary intent retained while a provider graph is selected. + /// It is never an input to compilation or part of connection-asset backups. + final ServerSelection? rememberedOrdinarySelection; final TrafficMode trafficMode; final int? customId; final SmartRoutingSettings smart; @@ -137,6 +141,7 @@ class ConnectionSettings { this.expert = false, this.rawId, this.selection = const ServerSelection.automatic(), + this.rememberedOrdinarySelection, this.trafficMode = TrafficMode.smart, this.customId, SmartRoutingSettings? smart, @@ -149,6 +154,9 @@ class ConnectionSettings { selection: ServerSelection.fromJson( value['selection'] as Map? ?? {'kind': 'automatic'}, ), + rememberedOrdinarySelection: _readRememberedSelection( + value['rememberedOrdinarySelection'], + ), trafficMode: TrafficMode.values.byName( value['trafficMode'] as String? ?? 'smart', ), @@ -162,6 +170,8 @@ class ConnectionSettings { 'expert': expert, 'rawId': rawId, 'selection': selection.toJson(), + if (rememberedOrdinarySelection != null) + 'rememberedOrdinarySelection': rememberedOrdinarySelection!.toJson(), 'trafficMode': trafficMode.name, 'customId': customId, 'smart': smart.toJson(), @@ -183,4 +193,15 @@ class ConnectionSettings { int? get finalExitId => !expert && trafficMode == TrafficMode.smart ? smart.finalExitId : null; + + static ServerSelection? _readRememberedSelection(Object? value) { + if (value is! Map) return null; + final kind = value['kind']; + if (kind == 'automatic') return const ServerSelection.automatic(); + final id = value['id']; + if (id is! int) return null; + if (kind == 'source' && id >= 0) return ServerSelection.source(id); + if (kind == 'server' && id > 0) return ServerSelection.server(id); + return null; + } } diff --git a/lib/service/servers/catalog.dart b/lib/service/servers/catalog.dart index 6d47f35ba..8d36e600d 100644 --- a/lib/service/servers/catalog.dart +++ b/lib/service/servers/catalog.dart @@ -177,12 +177,7 @@ class ServerCatalog { _active(row) && display(row).readable; - bool selectable(CoreConfigData row) => - _active(row) && - display(row).readable && - (row.type == CoreConfigType.Remnawave.name || - row.delay == PingDelayConstants.unknown || - PingDelayConstants.isSuccessful(row.delay)); + bool selectable(CoreConfigData row) => _active(row) && display(row).readable; static Stream watch(AppDatabase db, {AppEventBus? eventBus}) => Stream.multi((sink) { diff --git a/lib/service/servers/server.dart b/lib/service/servers/server.dart index 4d358a567..a3d4a7f86 100644 --- a/lib/service/servers/server.dart +++ b/lib/service/servers/server.dart @@ -103,7 +103,6 @@ class ServerAssetService { if (row.type == CoreConfigType.Remnawave.name) { return ServerDisplay.fromRow(row).readable; } - if (measured(row) && !healthy(row)) return false; try { ResolvedServer.fromRow(row); return true; diff --git a/test/service/connect/coordinator_test.dart b/test/service/connect/coordinator_test.dart index 28588d771..aa4c083e6 100644 --- a/test/service/connect/coordinator_test.dart +++ b/test/service/connect/coordinator_test.dart @@ -1525,7 +1525,7 @@ void main() { 'resolver reason and counts survive refresh and clear on successful retry', () async { const error = ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ); @@ -1546,10 +1546,10 @@ void main() { ), ); await expectLater(coordinator.connect(), throwsA(same(error))); - expect(coordinator.state.value.issue, 'insufficientHealthyServers'); + expect(coordinator.state.value.issue, 'insufficientCandidates'); expect(coordinator.state.value.error, same(error)); await coordinator.refresh(); - expect(coordinator.state.value.issue, 'insufficientHealthyServers'); + expect(coordinator.state.value.issue, 'insufficientCandidates'); expect(coordinator.state.value.error, same(error)); fail = false; await coordinator.connect(); diff --git a/test/service/connect/failure_test.dart b/test/service/connect/failure_test.dart index eee79ce3c..1f6351101 100644 --- a/test/service/connect/failure_test.dart +++ b/test/service/connect/failure_test.dart @@ -59,7 +59,7 @@ void main() { connectionFailureMessage( l, error: const ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 2, ), diff --git a/test/service/connect/ordinary_plan_test.dart b/test/service/connect/ordinary_plan_test.dart new file mode 100644 index 000000000..4b77ed7dc --- /dev/null +++ b/test/service/connect/ordinary_plan_test.dart @@ -0,0 +1,284 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/ordinary_plan.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/connect/settings.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late AppDatabase db; + late int ordinary, source, provider, otherProvider; + + setUp(() async { + db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + ordinary = await db.coreConfigDao.insertRow( + _row('ordinary', 'outbound', 0), + ); + source = await db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: 'provider', + url: 'https://example.com/provider', + timestamp: DateTime(2026), + mode: const Value('providerManaged'), + ), + ); + provider = await db.coreConfigDao.insertRow( + _row('provider', 'Remnawave', source), + ); + otherProvider = await db.coreConfigDao.insertRow( + _row('other provider', 'Remnawave', source), + ); + }); + + test( + 'optional memory is compatible with old configuration and malformed hints', + () { + expect( + ConnectionSettings.fromJson({}).rememberedOrdinarySelection, + isNull, + ); + for (final value in [ + null, + 'invalid', + {'kind': 'server', 'id': '7'}, + {'kind': 'source', 'id': -1}, + {'kind': 'future'}, + ]) { + expect( + ConnectionSettings.fromJson({'rememberedOrdinarySelection': value}) + .rememberedOrdinarySelection, + isNull, + ); + } + final original = ConnectionSettings( + rememberedOrdinarySelection: const ServerSelection.source(0), + ); + expect( + ConnectionSettings.fromJson(original.toJson()) + .rememberedOrdinarySelection + ?.toJson(), + {'kind': 'source', 'id': 0}, + ); + }, + ); + + test('provider switches capture ordinary intent once and returning is read-only until applied', () async { + final coordinator = _coordinator(db); + addTearDown(coordinator.dispose); + await coordinator.initialize(observe: false, registerReferences: false); + final original = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(ordinary), + trafficMode: TrafficMode.allVpn, + ), + ); + await db.connectionConfigDao.commit(configurationJson: original.encode()); + Future choose(int id) async { + final current = await coordinator.configuration; + await coordinator.apply( + ConnectionConfiguration( + connection: ConnectionSettings.fromJson({ + ...current.connection.toJson(), + 'selection': ServerSelection.server(id).toJson(), + }), + policy: current.policy, + ), + ); + } + + await choose(provider); + expect( + (await coordinator.configuration) + .connection + .rememberedOrdinarySelection + ?.id, + ordinary, + ); + await choose(otherProvider); + final stored = await coordinator.configuration; + expect(stored.connection.rememberedOrdinarySelection?.id, ordinary); + final restored = await coordinator.restoreOrdinaryPlan(); + expect(restored.requiresSelection, isFalse); + expect(restored.configuration.connection.selection.id, ordinary); + expect(restored.configuration.connection.trafficMode, TrafficMode.allVpn); + expect((await coordinator.configuration).encode(), stored.encode()); + await coordinator.apply(restored.configuration); + expect((await coordinator.configuration).connection.selection.id, ordinary); + }); + + test('failed prepare or asset commit never saves a provider intent or its new memory', () async { + final coordinator = _coordinator(db); + addTearDown(coordinator.dispose); + await coordinator.initialize(observe: false, registerReferences: false); + final original = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(ordinary), + ), + ); + await db.connectionConfigDao.commit(configurationJson: original.encode()); + final next = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(provider), + ), + ); + await expectLater( + coordinator.apply(next, connect: true), + throwsFormatException, + ); + expect((await coordinator.configuration).encode(), original.encode()); + await expectLater( + coordinator.apply( + next, + writeAssets: () async { + throw StateError('write'); + }, + ), + throwsStateError, + ); + expect((await coordinator.configuration).encode(), original.encode()); + await coordinator.apply(next); + expect( + (await coordinator.configuration) + .connection + .rememberedOrdinarySelection + ?.id, + ordinary, + ); + }); + + test( + 'Raw retains its ordinary selection and existing provider memory', + () async { + final service = OrdinaryPlanService(db); + final normal = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(ordinary), + ), + ); + final raw = await service.retain( + normal, + ConnectionConfiguration( + connection: ConnectionSettings( + expert: true, + rawId: 99, + selection: normal.connection.selection, + ), + ), + ); + final restored = await service.restore(raw); + expect(restored.requiresSelection, isFalse); + expect(restored.configuration.connection.expert, isFalse); + expect(restored.configuration.connection.selection.id, ordinary); + final providerPlan = await service.retain( + raw, + ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(provider), + ), + ), + ); + final providerRaw = await service.retain( + providerPlan, + ConnectionConfiguration( + connection: ConnectionSettings( + expert: true, + rawId: 99, + selection: ServerSelection.server(provider), + ), + ), + ); + expect( + (await service.restore(providerRaw)) + .configuration + .connection + .selection + .id, + ordinary, + ); + }, + ); + + test('legacy providers and stale or incompatible memory return automatic with reselect state', () async { + final service = OrdinaryPlanService(db); + for (final remembered in [ + null, + const ServerSelection.server(999), + const ServerSelection.source(999), + ServerSelection.source(source), + ServerSelection.server(provider), + ]) { + final result = await service.restore( + ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(provider), + rememberedOrdinarySelection: remembered, + ), + ), + ); + expect(result.requiresSelection, isTrue); + expect( + result.configuration.connection.selection.kind, + SelectionKind.automatic, + ); + expect( + result.configuration.connection.rememberedOrdinarySelection, + isNull, + ); + } + final manual = await service.restore( + ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(provider), + rememberedOrdinarySelection: const ServerSelection.source(0), + ), + ), + ); + expect(manual.requiresSelection, isFalse); + expect(manual.configuration.connection.selection.toJson(), { + 'kind': 'source', + 'id': 0, + }); + await db.connectionConfigDao.commit( + configurationJson: manual.configuration.encode(), + ); + await db.connectionConfigDao.reset(); + final fresh = ConnectionConfiguration.fromJson( + jsonDecode((await db.connectionConfigDao.read()).configurationJson) + as Map, + ); + expect(fresh.connection.rememberedOrdinarySelection, isNull); + }); +} + +ConnectionCoordinator _coordinator(AppDatabase db) => ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + prepare: (_, _) async => throw const FormatException('prepare failed'), + start: (_) async => throw StateError('must not start'), + stop: () async => const HostConnection(VpnStatus.disconnected), + disposeStatus: () {}, +); + +CoreConfigCompanion _row(String name, String type, int source) => + CoreConfigCompanion.insert( + name: name, + type: type, + tags: '', + delay: 10000, + subId: source, + data: Value( + base64Encode( + utf8.encode('{"outbounds":[{"tag":"proxy","protocol":"freedom"}]}'), + ), + ), + ); diff --git a/test/service/connect/preparation_test.dart b/test/service/connect/preparation_test.dart index c86d3594a..ae0dc65e9 100644 --- a/test/service/connect/preparation_test.dart +++ b/test/service/connect/preparation_test.dart @@ -12,6 +12,7 @@ import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/core/model/geo_data_type.dart'; import 'package:onexray/core/pigeon/host_api.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/core/pigeon/model.dart'; import 'package:onexray/service/advanced/local_api/service.dart'; import 'package:onexray/service/advanced/local_api/settings.dart'; import 'package:onexray/service/connect/preparation.dart'; @@ -739,6 +740,62 @@ void main() { } } + test('an empty source records the original scope and commits only a prepared automatic target', () async { + final emptySource = await db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: 'Empty source', + url: 'https://example.com/empty', + timestamp: DateTime(2026), + ), + ); + final node = await db.coreConfigDao.insertRow( + CoreConfigCompanion.insert( + name: 'Unmeasured ordinary', + type: 'outbound', + tags: '', + subId: 0, + delay: PingDelayConstants.unknown, + data: Value( + base64Encode(utf8.encode('{"outbounds":[{"protocol":"freedom"}]}')), + ), + ), + ); + final input = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.source(emptySource), + trafficMode: TrafficMode.allVpn, + ), + ); + final runtime = await ConnectionPreparation( + db: db, + getFreePorts: (count, {excludePorts}) async => [18002, 18003], + requireDependencies: (_) async {}, + ).prepare(input); + expect(runtime.entries.single.id, node); + expect( + runtime.configuration.connection.selection.kind, + SelectionKind.automatic, + ); + expect(runtime.selectionFallback?.originalSelection.id, emptySource); + expect(runtime.selectionFallback?.sourceName, 'Empty source'); + expect(runtime.selectionFallback?.reason, 'selectionUnavailable'); + final restored = ConnectionRuntime.fromRequest(runtime.request); + expect( + restored.selectionFallback?.toJson(), + runtime.selectionFallback?.toJson(), + ); + final oldMetadata = + jsonDecode(runtime.request.metadataJson!) as Map; + oldMetadata.remove('selectionFallback'); + final oldRequest = runtime.request.toJson() + ..['metadataJson'] = jsonEncode(oldMetadata); + expect( + ConnectionRuntime.fromRequest(StartVpnRequest.fromJson(oldRequest)) + .selectionFallback, + isNull, + ); + }); + test('automatic selection ignores inactive provider and ordinary caches', () async { final appSourceId = await db.subscriptionDao.insertRow( SubscriptionCompanion.insert( @@ -921,7 +978,7 @@ void main() { for (final healthyAlternative in [false, true]) { test( - 'fixed ordinary retry stays ordinary with alternative $healthyAlternative', + 'fixed failed ordinary remains fixed with alternative $healthyAlternative', () async { Future addOrdinary(String name, int delay) => db.coreConfigDao.insertRow( @@ -964,27 +1021,20 @@ void main() { ), ), ); - if (healthy == null) { - await expectLater( - preparing, - throwsA( - isA().having( - (error) => error.reason, - 'reason', - ConnectionResolutionFailure.insufficientHealthyServers, - ), - ), - ); - } else { - final runtime = await preparing; - expect(runtime.profile, isNull); - expect(runtime.entries.map((entry) => entry.id), [healthy]); + final runtime = await preparing; + expect(runtime.profile, isNull); + expect(runtime.entries.map((entry) => entry.id), [failed]); + expect(runtime.configuration.connection.selection.id, failed); + expect( + runtime.configuration.connection.selection.kind, + SelectionKind.server, + ); + expect(runtime.notice, isNull); + if (healthy != null) expect( - runtime.configuration.connection.selection.kind, - SelectionKind.automatic, + runtime.entries.any((entry) => entry.id == healthy), + isFalse, ); - expect(runtime.notice, 'selectionReset'); - } }, ); } @@ -1137,8 +1187,6 @@ final _throwsApiConflict = throwsA( ConnectionResolver _unavailableResolver() => ConnectionResolver( rows: () => Stream.error(StateError('Provider selection must bypass ordinary nodes')), - probe: (_) async => - throw StateError('Provider configuration must not be probed'), ); ConnectionConfiguration _providerInput( diff --git a/test/service/connect/resolver_test.dart b/test/service/connect/resolver_test.dart index 6b8264de7..b37d63b81 100644 --- a/test/service/connect/resolver_test.dart +++ b/test/service/connect/resolver_test.dart @@ -11,15 +11,11 @@ import 'package:onexray/service/connect/routing/custom/state.dart'; void main() { test( - 'unknown selection kinds use the default while known fields are checked', + 'unknown selection kinds default while known IDs retain their type checks', () { - for (final kind in ['region', 'futureSelection']) { - final selection = ServerSelection.fromJson({ - 'kind': kind, - 'region': 'SG', - }); - expect(selection.toJson(), {'kind': 'automatic'}); - } + expect(ServerSelection.fromJson({'kind': 'future'}).toJson(), { + 'kind': 'automatic', + }); expect(ServerSelection.fromJson({'kind': 'server', 'id': 7}).id, 7); expect( () => ServerSelection.fromJson({'kind': 'server', 'id': '7'}), @@ -27,75 +23,101 @@ void main() { ); }, ); - test( - 'automatic, source and fixed selections retain distinct same-name IDs', - () async { - final rows = [ - _row(1, delay: 60, source: 1), - _row(2, delay: 20, source: 2), - _row(3, delay: 0, source: 0), - _row(4, delay: 1, type: 'raw'), - ]; - final resolver = _oneShot(rows); - Future> selected(ServerSelection selection, int count) async => - (await resolver.resolve( - ConnectionSettings( - selection: selection, - smart: SmartRoutingSettings(entryCount: count), - ), - )).map((node) => node.id).toList(); - expect(await selected(const ServerSelection.automatic(), 2), [3, 2]); - expect(await selected(const ServerSelection.source(1), 1), [1]); - expect(await selected(const ServerSelection.source(0), 1), [3]); - expect(await selected(const ServerSelection.server(2), 3), [2]); - }, - ); - test( - 'All VPN is one node and Custom uses slots without inheriting Smart exit', - () async { - final resolver = _oneShot([ - _row(1, delay: 30), - _row(2, delay: 10), - _row(3, delay: 20), - ]); - final smart = SmartRoutingSettings(entryCount: 3, finalExitId: 2); - final allVpn = await resolver.resolve( + test('successful cached delays rank first, then all non-success delays share an ID bucket', () async { + final rows = [ + _row(8, delay: 0), + _row(7, delay: 20), + _row(6, delay: 20), + _row(4, delay: PingDelayConstants.unknown), + _row(1, delay: PingDelayConstants.error), + _row(2, delay: PingDelayConstants.timeout), + _row(3, delay: -1), + ]; + final input = ConnectionSettings( + smart: SmartRoutingSettings(entryCount: 3), + ); + expect((await _oneShot(rows).resolve(input)).map((e) => e.id), [8, 6, 7]); + expect( + (await _oneShot(rows.skip(3).toList()).resolve(input)).map((e) => e.id), + [1, 2, 3], + ); + expect( + (await _oneShot([rows[1], ...rows.skip(3)]).resolve(input)) + .map((e) => e.id), + [7, 1, 2], + ); + expect( + (await _oneShot([rows[3]]).resolve(ConnectionSettings())).single.id, + 4, + ); + }); + + test('automatic, source, manual and fixed use distinct IDs and no delay admission', () async { + final resolver = _oneShot([ + _row(1, delay: 60, source: 1), + _row(2, delay: 20, source: 2), + _row(3, delay: PingDelayConstants.unknown, source: 0), + _row(4, type: 'raw'), + ]); + Future> select(ServerSelection selection, int count) async => + (await resolver.resolve( + ConnectionSettings( + selection: selection, + smart: SmartRoutingSettings(entryCount: count), + ), + )).map((e) => e.id).toList(); + expect(await select(const ServerSelection.automatic(), 3), [2, 1, 3]); + expect(await select(const ServerSelection.source(1), 1), [1]); + expect(await select(const ServerSelection.source(0), 1), [3]); + expect(await select(const ServerSelection.server(3), 3), [3]); + }); + + test('All uses one, Custom uses slots, fixed uses one, Raw bypasses ordinary resolution', () async { + final resolver = _oneShot([ + _row(1, delay: 30), + _row(2, delay: 10), + _row(3, delay: 20), + ]); + final smart = SmartRoutingSettings(entryCount: 3, finalExitId: 2); + expect( + (await resolver.resolve( ConnectionSettings(trafficMode: TrafficMode.allVpn, smart: smart), - ); - expect(allVpn.map((node) => node.id), [2]); - final custom = await resolver.resolve( + )).map((e) => e.id), + [2], + ); + expect( + (await resolver.resolve( ConnectionSettings(trafficMode: TrafficMode.custom, smart: smart), custom: _custom(2), - ); - expect(custom.map((node) => node.id), [2, 3]); - final fixed = await resolver.resolve( + )).map((e) => e.id), + [2, 3], + ); + expect( + (await resolver.resolve( ConnectionSettings( trafficMode: TrafficMode.custom, selection: const ServerSelection.server(1), smart: smart, ), custom: _custom(3), - ); - expect(fixed.map((node) => node.id), [1]); - expect(await resolver.resolve(ConnectionSettings(expert: true)), isEmpty); - }, - ); + )).map((e) => e.id), + [1], + ); + expect(await resolver.resolve(ConnectionSettings(expert: true)), isEmpty); + }); test( - 'Smart excludes the final exit, rejects a self-loop and missing exit', + 'Smart validates and excludes the final exit independently of delay', () async { final resolver = _oneShot([ - _row(1, delay: 20), - _row(2, delay: 1), - _row(3, delay: 10), + _row(1, delay: PingDelayConstants.error), + _row(2, delay: PingDelayConstants.unknown), ]); final result = await resolver.resolve( - ConnectionSettings( - smart: SmartRoutingSettings(entryCount: 2, finalExitId: 2), - ), + ConnectionSettings(smart: SmartRoutingSettings(finalExitId: 2)), ); - expect(result.map((node) => node.id), [3, 1]); + expect(result.single.id, 1); await expectLater( resolver.resolve( ConnectionSettings( @@ -114,369 +136,188 @@ void main() { }, ); - test('missing scope and too few candidates fail without changing selection or probing', () async { - final selection = const ServerSelection.source(99); - final resolver = _oneShot([_row(1)]); - await expectLater( - resolver.resolve(ConnectionSettings(selection: selection)), - _fails(ConnectionResolutionFailure.selectionUnavailable), - ); - expect(selection.id, 99); - await expectLater( - resolver.resolve( - ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), - ), - _fails(ConnectionResolutionFailure.insufficientCandidates), - ); - await expectLater( - _oneShot([]).resolve(ConnectionSettings()), - _fails(ConnectionResolutionFailure.selectionUnavailable), - ); - await expectLater( - resolver.resolve(ConnectionSettings(trafficMode: TrafficMode.custom)), - _fails(ConnectionResolutionFailure.invalidSettings), - ); - }); - test( - 'only successful delays qualify; unknown candidates are probed once', + 'bad JSON, invalid IDs and duplicates cannot satisfy structural count', () async { - var rows = [ - _row(1, delay: PingDelayConstants.unknown), - _row(2, delay: PingDelayConstants.unknown), - _row(3, delay: PingDelayConstants.error), - _row(4, delay: PingDelayConstants.timeout), - _row(5, delay: -1), - ]; - final probes = >[]; - final resolver = ConnectionResolver( - rows: () => Stream.value(rows), - probe: (ids) async { - probes.add(ids); - rows = [ - _row(1, delay: 50), - _row(2, delay: PingDelayConstants.error), - ...rows.skip(2), - ]; - }, - ); - final result = await resolver.resolve(ConnectionSettings()); - expect(probes, [ - [1, 2], + final resolver = _oneShot([ + _row(0), + _row(-1), + _row(1).copyWith(data: const Value('bad base64')), + _row(1), + _row(2, delay: PingDelayConstants.timeout), ]); - expect(result.single.id, 1); - }, - ); - - test( - 'fully failed or unrepairable candidates do not trigger an endless retry', - () async { + expect((await resolver.resolve(ConnectionSettings())).single.id, 2); await expectLater( - _oneShot([ - _row(1, delay: PingDelayConstants.error), - _row(2, delay: PingDelayConstants.timeout), - ]).resolve(ConnectionSettings()), - _fails(ConnectionResolutionFailure.insufficientHealthyServers), - ); - var probes = 0; - final resolver = ConnectionResolver( - rows: () => Stream.value([_row(1, delay: PingDelayConstants.unknown)]), - probe: (_) async { - probes++; - }, + resolver.resolve( + ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), + ), + _fails(ConnectionResolutionFailure.insufficientCandidates), ); await expectLater( - resolver.resolve(ConnectionSettings()), - _fails(ConnectionResolutionFailure.insufficientHealthyServers), + resolver.resolve( + ConnectionSettings(selection: const ServerSelection.source(99)), + ), + _fails(ConnectionResolutionFailure.selectionUnavailable), ); - expect(probes, 1); await expectLater( - _oneShot([_row(1).copyWith(data: const Value('not base64'))]) - .resolve(ConnectionSettings()), - _fails(ConnectionResolutionFailure.selectionUnavailable), + resolver.resolve(ConnectionSettings(trafficMode: TrafficMode.custom)), + _fails(ConnectionResolutionFailure.invalidSettings), ); }, ); - test('a watched batch resolves early and later updates cannot mutate the snapshot', () async { + test('first rows are a snapshot; later measurements and row names do not replace it', () async { final rows = _Rows([ _row(1, delay: PingDelayConstants.unknown), _row(2, delay: PingDelayConstants.unknown), ]); addTearDown(rows.close); - final started = Completer(); - final remaining = Completer(); - addTearDown(() { - if (!remaining.isCompleted) remaining.complete(); - }); - final resolver = ConnectionResolver( - rows: rows.watch, - probe: (_) { - started.complete(); - return remaining.future; - }, - ); - final resolving = resolver.resolve(ConnectionSettings()); - await started.future; - rows.update([ - _row(1, delay: 30), - _row(2, delay: PingDelayConstants.unknown), - ]); - final snapshots = await resolving; - expect(remaining.isCompleted, isFalse); + final resolved = await ConnectionResolver(rows: rows.watch) + .resolve(ConnectionSettings()); expect(rows.listeners, 0); rows.update([_row(1, delay: 80, name: 'Changed'), _row(2, delay: 1)]); - remaining.complete(); - expect(snapshots.single.id, 1); - expect(snapshots.single.name, 'Same name'); - snapshots.single.outbound['tag'] = 'Changed copy'; - expect(snapshots.single.name, 'Same name'); - expect(() => snapshots.clear(), throwsUnsupportedError); + expect(resolved.single.id, 1); + expect(resolved.single.name, 'Same name'); + expect(() => resolved.clear(), throwsUnsupportedError); }); test( - 'cancellation releases the watch without cancelling background probing', + 'cancellation closes a pending read and cannot become provider fallback', () async { - final rows = _Rows([_row(1, delay: PingDelayConstants.unknown)]); - addTearDown(rows.close); - final started = Completer(); - final remaining = Completer(); - addTearDown(() { - if (!remaining.isCompleted) remaining.complete(); - }); - final cancel = Completer(); - final resolver = ConnectionResolver( - rows: rows.watch, - probe: (_) { - started.complete(); - return remaining.future; + final subscribed = Completer(), cancel = Completer(); + var closed = false; + final rows = StreamController>( + onListen: subscribed.complete, + onCancel: () { + closed = true; }, ); - final resolving = resolver.resolve( - ConnectionSettings(), - cancelled: cancel.future, - ); - await started.future; + addTearDown(rows.close); + final resolving = ConnectionResolver(rows: () => rows.stream) + .resolveTarget(ConnectionSettings(), cancelled: cancel.future); + await subscribed.future; final expected = expectLater( resolving, _fails(ConnectionResolutionFailure.cancelled), ); cancel.complete(); await expected; - expect(rows.listeners, 0); - expect(remaining.isCompleted, isFalse); - remaining.complete(); + expect(closed, isTrue); }, ); - test( - 'read and probe failures are structured and close their watches', - () async { + test('read exceptions and empty streams report readFailed', () async { + for (final stream in [ + Stream>.error(StateError('read')), + const Stream>.empty(), + ]) { await expectLater( - ConnectionResolver(rows: () => Stream.error(StateError('read'))) - .resolve(ConnectionSettings()), + ConnectionResolver(rows: () => stream).resolve(ConnectionSettings()), _fails(ConnectionResolutionFailure.readFailed), ); - final rows = _Rows([_row(1, delay: PingDelayConstants.unknown)]); - addTearDown(rows.close); - await expectLater( - ConnectionResolver( - rows: rows.watch, - probe: (_) => Future.error(StateError('probe')), - ).resolve(ConnectionSettings()), - _fails(ConnectionResolutionFailure.probeFailed), - ); - expect(rows.listeners, 0); - }, - ); + } + await expectLater( + ConnectionResolver(rows: () => throw StateError('read')) + .resolve(ConnectionSettings()), + _fails(ConnectionResolutionFailure.readFailed), + ); + }); - group('complete provider fallback', () { - test('sufficient healthy ordinary entries always win', () async { + test( + 'sufficient unmeasured or failed ordinary nodes win over a provider', + () async { final target = - await _oneShot([_profile(1), _row(5, delay: 30), _row(3, delay: 10)]) - .resolveTarget( - ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), - ); - expect(target.entries.map((entry) => entry.id), [3, 5]); + await _oneShot([ + _profile(1), + _row(2, delay: PingDelayConstants.unknown), + _row(3, delay: PingDelayConstants.error), + ]).resolveTarget( + ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), + ); + expect(target.entries.map((e) => e.id), [2, 3]); expect(target.profile, isNull); - expect(target.profileRow, isNull); - }); + }, + ); - for (final ordinary in [ - [], - [_row(20)], - [ + test( + 'only a structural shortage uses the first readable same-scope provider', + () async { + final first = _profile(7); + final resolver = _oneShot([ + _profile(2).copyWith(data: const Value('bad')), + _profile(9), + first, _row(20, delay: PingDelayConstants.error), - _row(21, delay: PingDelayConstants.timeout), - ], - ]) { - test( - 'entry shortage ${ordinary.length} selects the first readable profile by ID', - () async { - final unreadable = _profile(2) - .copyWith(data: const Value('not base64')); - final first = _profile(7); - final target = - await _oneShot([ - _profile(9), - ...ordinary, - unreadable, - first, - ]).resolveTarget( - ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), - ); - expect(target.entries, isEmpty); - expect(target.profile?.id, 7); - expect(target.profileRow, first); - expect(target.profile?.config['outbounds'], hasLength(1)); - }, + ]); + final target = await resolver.resolveTarget( + ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), ); - } - - for (final succeeds in [true, false]) { - test( - 'ordinary probes finish before provider fallback: $succeeds', - () async { - var rows = [_profile(1), _row(2, delay: PingDelayConstants.unknown)]; - final probes = >[]; - final resolver = ConnectionResolver( - rows: () => Stream.value(rows), - probe: (ids) async { - probes.add(ids); - rows = [ - _profile(1), - _row(2, delay: succeeds ? 12 : PingDelayConstants.error), - ]; - }, - ); - final target = await resolver.resolveTarget(ConnectionSettings()); - expect(probes, [ - [2], - ]); - expect(target.profile?.id, succeeds ? null : 1); - expect(target.entries.map((entry) => entry.id), succeeds ? [2] : []); - }, + expect(target.entries, isEmpty); + expect(target.profileRow, first); + await expectLater( + resolver.resolveTarget( + ConnectionSettings( + selection: const ServerSelection.source(0), + smart: SmartRoutingSettings(entryCount: 2), + ), + ), + _fails(ConnectionResolutionFailure.insufficientCandidates), ); - } - - test( - 'source fallback stays within its source and excludes manual scope', - () async { - final resolver = _oneShot([ - _profile(1, source: 4), - _profile(3, source: 7), - _profile(2, source: 7), - ]); - final target = await resolver.resolveTarget( + expect( + (await resolver.resolveTarget( ConnectionSettings(selection: const ServerSelection.source(7)), - ); - expect(target.profile?.id, 2); - await expectLater( - resolver.resolveTarget( - ConnectionSettings(selection: const ServerSelection.source(0)), - ), - _fails(ConnectionResolutionFailure.selectionUnavailable), - ); - await expectLater( - resolver.resolveTarget( - ConnectionSettings(selection: const ServerSelection.source(8)), - ), - _fails(ConnectionResolutionFailure.selectionUnavailable), - ); - }, - ); - - test( - 'fixed and expert selections never acquire a fallback profile', - () async { - final resolver = _oneShot([ - _profile(1), - _row(2, delay: PingDelayConstants.error), - ]); - await expectLater( - resolver.resolveTarget( - ConnectionSettings(selection: const ServerSelection.server(2)), - ), - _fails(ConnectionResolutionFailure.insufficientHealthyServers), - ); - final raw = await resolver.resolveTarget( - ConnectionSettings(expert: true), - ); - expect(raw.entries, isEmpty); - expect(raw.profile, isNull); - }, - ); - - test( - 'without a readable profile the ordinary failure is preserved', - () async { - await expectLater( - _oneShot([_profile(1).copyWith(data: const Value('bad')), _row(2)]) - .resolveTarget( - ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), - ), - _fails(ConnectionResolutionFailure.insufficientCandidates), - ); - }, - ); + )).profile?.id, + 7, + ); + }, + ); - test('configuration and probe failures do not select a provider', () async { - final rows = [_profile(1), _row(2, delay: PingDelayConstants.unknown)]; - await expectLater( - _oneShot(rows) - .resolveTarget(ConnectionSettings(trafficMode: TrafficMode.custom)), - _fails(ConnectionResolutionFailure.invalidSettings), + test( + 'fixed failed nodes remain fixed; fixed and Raw never acquire a provider', + () async { + final resolver = _oneShot([ + _profile(1), + _row(2, delay: PingDelayConstants.error), + ]); + final fixed = await resolver.resolveTarget( + ConnectionSettings(selection: const ServerSelection.server(2)), ); + expect(fixed.entries.single.id, 2); + expect(fixed.profile, isNull); await expectLater( - _oneShot([_profile(1), _row(2)]).resolveTarget( - ConnectionSettings(smart: SmartRoutingSettings(finalExitId: 99)), + resolver.resolveTarget( + ConnectionSettings(selection: const ServerSelection.server(99)), ), - _fails(ConnectionResolutionFailure.finalExitUnavailable), - ); - await expectLater( - ConnectionResolver( - rows: () => Stream.value(rows), - probe: (_) async => throw StateError('probe'), - ).resolveTarget(ConnectionSettings()), - _fails(ConnectionResolutionFailure.probeFailed), + _fails(ConnectionResolutionFailure.selectionUnavailable), ); - await expectLater( - ConnectionResolver(rows: () => Stream.error(StateError('read'))) - .resolveTarget(ConnectionSettings()), - _fails(ConnectionResolutionFailure.readFailed), + final raw = await resolver.resolveTarget( + ConnectionSettings(expert: true), ); - }); + expect(raw.entries, isEmpty); + expect(raw.profile, isNull); + }, + ); - test('cancellation never becomes provider fallback', () async { - final started = Completer(); - final remaining = Completer(); - final cancelled = Completer(); - addTearDown(() { - if (!remaining.isCompleted) remaining.complete(); - }); - final resolver = ConnectionResolver( - rows: () => Stream.value([ - _profile(1), - _row(2, delay: PingDelayConstants.unknown), - ]), - probe: (_) { - started.complete(); - return remaining.future; - }, - ); - final resolving = resolver.resolveTarget( - ConnectionSettings(), - cancelled: cancelled.future, - ); - await started.future; - final expected = expectLater( - resolving, - _fails(ConnectionResolutionFailure.cancelled), - ); - cancelled.complete(); - await expected; - expect(remaining.isCompleted, isFalse); - }); + test('configuration, exit and read errors never retry a provider', () async { + final rows = [_profile(1), _row(2, delay: PingDelayConstants.unknown)]; + await expectLater( + _oneShot(rows) + .resolveTarget(ConnectionSettings(trafficMode: TrafficMode.custom)), + _fails(ConnectionResolutionFailure.invalidSettings), + ); + await expectLater( + _oneShot(rows).resolveTarget( + ConnectionSettings(smart: SmartRoutingSettings(finalExitId: 99)), + ), + _fails(ConnectionResolutionFailure.finalExitUnavailable), + ); + await expectLater( + _oneShot([_profile(1).copyWith(data: const Value('bad')), _row(2)]) + .resolveTarget( + ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), + ), + _fails(ConnectionResolutionFailure.insufficientCandidates), + ); }); } @@ -487,12 +328,8 @@ CoreConfigData _profile(int id, {int source = 7}) => _row( delay: PingDelayConstants.unknown, ); -ConnectionResolver _oneShot(List rows) => ConnectionResolver( - rows: () => Stream.value(rows), - probe: (_) async { - fail('This selection must not probe'); - }, -); +ConnectionResolver _oneShot(List rows) => + ConnectionResolver(rows: () => Stream.value(rows)); Matcher _fails(ConnectionResolutionFailure reason) => throwsA( isA().having( diff --git a/test/service/servers/catalog_test.dart b/test/service/servers/catalog_test.dart index d77439805..d38f1834b 100644 --- a/test/service/servers/catalog_test.dart +++ b/test/service/servers/catalog_test.dart @@ -147,7 +147,7 @@ void main() { final catalog = ServerCatalog(servers: [ordinary, provider]); expect(catalog.servers, [ordinary]); expect(catalog.outboundEligible(ordinary), isTrue); - expect(catalog.selectable(ordinary), isFalse); + expect(catalog.selectable(ordinary), isTrue); expect(catalog.selectable(provider), isFalse); final updated = catalog.copyWith(sources: [_source()]); expect(updated.servers, [ordinary, provider]); diff --git a/test/service/servers/import_selection_flow_test.dart b/test/service/servers/import_selection_flow_test.dart index a1e1c8778..5ac9c837f 100644 --- a/test/service/servers/import_selection_flow_test.dart +++ b/test/service/servers/import_selection_flow_test.dart @@ -100,10 +100,7 @@ void main() { final settings = ConnectionSettings( smart: SmartRoutingSettings(entryCount: 2), ); - final resolver = ConnectionResolver( - rows: db.coreConfigDao.watchOutbounds, - probe: ping.pingConfigIds, - ); + final resolver = ConnectionResolver(rows: db.coreConfigDao.watchOutbounds); final beforeRetest = await resolver.resolve(settings); expect(beforeRetest.map((node) => node.name), ['Fast', 'Middle']); diff --git a/test/service/servers/manual_group_integration_test.dart b/test/service/servers/manual_group_integration_test.dart index ec83ce372..11b31a775 100644 --- a/test/service/servers/manual_group_integration_test.dart +++ b/test/service/servers/manual_group_integration_test.dart @@ -75,12 +75,7 @@ void main() { throw StateError('Catalog closed before the committed order appeared'); } - final resolver = ConnectionResolver( - rows: db.coreConfigDao.watchOutbounds, - probe: (_) async => fail( - 'All candidates are measured; organization must not request probes', - ), - ); + final resolver = ConnectionResolver(rows: db.coreConfigDao.watchOutbounds); Future unchangedSelections({int fastest = -1}) async { final manual = await resolver.resolve( ConnectionSettings(selection: const ServerSelection.source(0)), diff --git a/test/service/settings/backup/assets_test.dart b/test/service/settings/backup/assets_test.dart index 14ee53660..24c4d9803 100644 --- a/test/service/settings/backup/assets_test.dart +++ b/test/service/settings/backup/assets_test.dart @@ -281,6 +281,7 @@ void main() { connection: ConnectionSettings( expert: true, rawId: 1, + rememberedOrdinarySelection: const ServerSelection.source(0), smart: SmartRoutingSettings( finalExitId: 2, fragment: true, @@ -316,6 +317,10 @@ void main() { configurationJson: jsonEncode(legacySettings), ); final document = await assets.capture(); + expect( + jsonEncode(document.toJson()), + isNot(contains('rememberedOrdinarySelection')), + ); validateBackupAssets(document); expect(document.version, 1); expect(document.coreConfigs.length, 5); @@ -394,6 +399,7 @@ void main() { as Map, ); expect(connection.connection.expert, false); + expect(connection.connection.rememberedOrdinarySelection, isNull); expect(connection.connection.smart.finalExitId, null); expect(connection.connection.smart.fragment, true); expect( diff --git a/test/service/shared/menu/tray/connection_failure_test.dart b/test/service/shared/menu/tray/connection_failure_test.dart index 8440fb110..b3280a1f6 100644 --- a/test/service/shared/menu/tray/connection_failure_test.dart +++ b/test/service/shared/menu/tray/connection_failure_test.dart @@ -77,7 +77,7 @@ void main() { for (final reason in [ ConnectionResolutionFailure.insufficientCandidates, - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, ]) { test( 'tray notifies counts without taking focus for ${reason.name}', diff --git a/test/service/shared/notification/entry_behavior_test.dart b/test/service/shared/notification/entry_behavior_test.dart index 0f369bb1e..1dd0e49c6 100644 --- a/test/service/shared/notification/entry_behavior_test.dart +++ b/test/service/shared/notification/entry_behavior_test.dart @@ -85,7 +85,7 @@ void main() { var shown = 0; final tray = TrayService.forTesting( connect: () async => throw const ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ), @@ -179,7 +179,7 @@ final class _FailingCoordinator extends ConnectionCoordinator { @override Future connect() async => throw const ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ); diff --git a/test/service/shared/ping/service_test.dart b/test/service/shared/ping/service_test.dart index b056a4e0c..216e91fae 100644 --- a/test/service/shared/ping/service_test.dart +++ b/test/service/shared/ping/service_test.dart @@ -291,7 +291,7 @@ void main() { }, ); - test('five-node batch commits let selection finish while the remaining two continue', () async { + test('selection does not start probing or wait for an independent five-node batch', () async { final ids = []; for (var index = 0; index < 7; index++) { ids.add(await db.coreConfigDao.insertRow(_node('Node $index'))); @@ -317,13 +317,18 @@ void main() { ); final resolver = ConnectionResolver( rows: () => db.select(db.coreConfig).watch(), - probe: (ids) => probing = service.pingConfigIds(ids), ); - final resolving = resolver.resolve( - ConnectionSettings(smart: SmartRoutingSettings(entryCount: 2)), + final settings = ConnectionSettings( + smart: SmartRoutingSettings(entryCount: 2), ); + final unmeasured = await resolver.resolve(settings); + expect(unmeasured.map((node) => node.id), ids.take(2)); + expect(service.isPinging, isFalse); + expect(sizes, isEmpty); + probing = service.pingConfigIds(ids); await secondStarted.future.timeout(const Duration(seconds: 5)); + final resolving = resolver.resolve(settings); final selected = await resolving.timeout(const Duration(seconds: 5)); expect(sizes, [5, 2]); expect(releaseSecond.isCompleted, isFalse); From 716a79f02db59e8ba804597794c4fb417869309a Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 18:44:23 +0800 Subject: [PATCH 05/60] refactor(connect): adopt single-column plans and guarded JSON editors --- lib/l10n/app_en.arb | 26 +- lib/l10n/app_fa.arb | 26 +- lib/l10n/app_ru.arb | 26 +- lib/l10n/app_zh.arb | 26 +- lib/l10n/app_zh_Hant.arb | 26 +- lib/pages/connect/controller.dart | 270 ++-- lib/pages/connect/json_editor/controller.dart | 100 +- lib/pages/connect/json_editor/page.dart | 391 +++--- lib/pages/connect/page.dart | 2 + lib/pages/connect/target_picker.dart | 298 +++++ lib/pages/connect/view.dart | 1133 ++++------------- .../shared/widgets/page_empty_state.dart | 2 +- lib/service/connect/ordinary_plan.dart | 2 +- lib/service/connect/preparation.dart | 19 +- lib/service/connect/raw/db.dart | 15 + lib/service/connect/raw/editor.dart | 7 + lib/service/connect/runtime.dart | 9 + .../connect/configuration_sync_test.dart | 93 +- test/pages/connect/controller_test.dart | 298 +++-- test/pages/connect/raw_editor_test.dart | 217 +++- test/pages/connect/target_picker_test.dart | 389 ++++++ test/pages/connect/view_test.dart | 325 +++-- test/pages/shared/connection_action_test.dart | 2 +- test/service/connect/preparation_test.dart | 41 +- 24 files changed, 2350 insertions(+), 1393 deletions(-) create mode 100644 lib/pages/connect/target_picker.dart create mode 100644 test/pages/connect/target_picker_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1f5434b12..165047c14 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -682,7 +682,7 @@ } } }, - "prototypeChooseBySpeedAvailability": "Choose by speed and availability", + "prototypeChooseBySpeedAvailability": "Prefer cached successful delays; unmeasured and failed tests remain eligible.", "@prototypeChooseBySpeedAvailability": { "description": "Approved prototype copy: Choose by speed and availability" }, @@ -1309,7 +1309,7 @@ } } }, - "prototypeMultipleEntriesReason": "OneXray selected {count} entry servers with the best speed and availability. New connections are distributed between them.", + "prototypeMultipleEntriesReason": "OneXray selected {count} readable entry nodes. Cached successful delays only affect their order.", "@prototypeMultipleEntriesReason": { "description": "Approved prototype copy: OneXray selected {count} entry servers with the best speed and availability. New connections are distributed between them.", "placeholders": { @@ -1327,7 +1327,7 @@ } } }, - "prototypeAutomaticEntryReason": "{server} had the best recent speed and availability among eligible servers.", + "prototypeAutomaticEntryReason": "Selected {server} using cached successful delays and a stable order. No test is required before connecting.", "@prototypeAutomaticEntryReason": { "description": "Approved prototype copy: {server} had the best recent speed and availability among eligible servers.", "placeholders": { @@ -1510,7 +1510,7 @@ "@prototypeAutomaticEntryServers": { "description": "Approved prototype copy: Automatic entry servers" }, - "prototypeAutomaticEntryServersHint": "Use the best available servers and distribute new connections when more than one is selected.", + "prototypeAutomaticEntryServersHint": "Cached successful delays rank first; unmeasured and failed tests remain eligible.", "@prototypeAutomaticEntryServersHint": { "description": "Approved prototype copy: Use the best available servers and distribute new connections when more than one is selected." }, @@ -3179,5 +3179,21 @@ "xrayDiagnosticsHint": "Review connection errors, runtime configuration and logs.", "xrayRefreshStatus": "Refresh status", "xrayNoConnectionError": "There is no current connection error.", - "xrayNoRuntimeConfiguration": "No runtime configuration yet. Connect to view the actual startup configuration." + "xrayNoRuntimeConfiguration": "No runtime configuration yet. Connect to view the actual startup configuration.", + "clearAllAppData": "Clear all app data", + "logsUnavailableSystemExtension": "Logs are unavailable in the System Extension edition.", + "connectionScopeFallback": "“{origin}” has no readable candidates. Selected “{target}” from all servers. Future connections use Automatic selection.", + "@connectionScopeFallback": { + "placeholders": { + "origin": { + "type": "String" + }, + "target": { + "type": "String" + } + } + }, + "serverNoMatchingResources": "No matching resources", + "connectionRuntimeTitle": "Active connection", + "serverLatencyFailed": "Test failed" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 9e2986c2b..3c2df6674 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -364,7 +364,7 @@ "prototypeAutomaticSelection": "انتخاب خودکار", "prototypeAutomaticOneEntry": "انتخاب خودکار · ۱ گره ورودی", "prototypeAutomaticEntries": "انتخاب خودکار · {count} گره ورودی", - "prototypeChooseBySpeedAvailability": "انتخاب بر اساس سرعت و دسترس‌پذیری", + "prototypeChooseBySpeedAvailability": "تأخیرهای موفق ذخیره‌شده اولویت دارند؛ گره‌های آزمایش‌نشده یا دارای خطای آزمایش نیز قابل انتخاب‌اند.", "prototypeFastLatency": "سریع · {latency} میلی‌ثانیه", "prototypeAvailableLatency": "در دسترس · {latency} میلی‌ثانیه", "prototypeSlowLatency": "کند · {latency} میلی‌ثانیه", @@ -494,9 +494,9 @@ "prototypeCustomConnectionReason": "قوانین سفارشی شما تعیین می‌کنند کدام ترافیک از VPN عبور کند، مستقیم متصل شود یا مسدود شود. انتخاب سرور در قوانین ذخیره نمی‌شود.", "prototypeNamedCustomConnectionReason": "{name} تعیین می‌کند کدام ترافیک از VPN عبور کند، مستقیم متصل شود یا مسدود شود. انتخاب سرور در قوانین ذخیره نمی‌شود.", "prototypeRunningEntriesReason": "برای این اتصال، {entries} انتخاب شد. آزمایش دوباره، اتصال فعلی را تغییر نمی‌دهد.", - "prototypeMultipleEntriesReason": "OneXray تعداد {count} سرور ورودی با بهترین سرعت و دسترس‌پذیری را انتخاب کرد. اتصال‌های جدید میان آن‌ها توزیع می‌شوند.", + "prototypeMultipleEntriesReason": "OneXray تعداد {count} گرهٔ ورودی قابل‌خواندن را انتخاب کرد. تأخیرهای موفق ذخیره‌شده فقط ترتیب را تغییر می‌دهند.", "prototypeFixedEntryReason": "شما {server} را مستقیماً انتخاب کردید.", - "prototypeAutomaticEntryReason": "{server} در میان سرورهای واجد شرایط، بهترین سرعت و دسترس‌پذیری اخیر را داشت.", + "prototypeAutomaticEntryReason": "{server} با تأخیرهای موفق ذخیره‌شده و ترتیب ثابت انتخاب شد. پیش از اتصال نیازی به آزمایش نیست.", "prototypeNoAnalyticsLocalLogs": "OneXray داده‌های تحلیلی را جمع‌آوری یا بارگذاری نمی‌کند. گزارش‌های اختیاری روی همین دستگاه می‌مانند.", "prototypeEditSubscription": "ویرایش اشتراک", "prototypeEditServer": "ویرایش سرور", @@ -535,7 +535,7 @@ "prototypeNoDirectRegions": "بدون منطقهٔ اتصال مستقیم", "prototypeMoreRegions": "{name} و {count} مورد دیگر", "prototypeAutomaticEntryServers": "انتخاب خودکار سرورهای ورودی", - "prototypeAutomaticEntryServersHint": "از بهترین سرورهای در دسترس استفاده شود و اگر بیش از یک سرور انتخاب شده باشد، اتصال‌های جدید میان آن‌ها توزیع شوند.", + "prototypeAutomaticEntryServersHint": "تأخیرهای موفق ذخیره‌شده اولویت دارند؛ گره‌های آزمایش‌نشده یا دارای خطای آزمایش نیز قابل انتخاب‌اند.", "prototypeVpnFinalExit": "خروجی نهایی VPN", "prototypeVpnFinalExitHint": "اختیاری. ترافیک VPN از طریق این سرور ثابت به اینترنت می‌رسد.", "prototypeNotSet": "تنظیم نشده", @@ -1001,5 +1001,21 @@ "xrayDiagnosticsHint": "خطاهای اتصال، پیکربندی اجرا و گزارش‌ها را ببینید.", "xrayRefreshStatus": "تازه‌سازی وضعیت", "xrayNoConnectionError": "اکنون خطایی در اتصال وجود ندارد.", - "xrayNoRuntimeConfiguration": "هنوز پیکربندی اجرایی موجود نیست. برای مشاهدهٔ ورودی واقعی راه‌اندازی متصل شوید." + "xrayNoRuntimeConfiguration": "هنوز پیکربندی اجرایی موجود نیست. برای مشاهدهٔ ورودی واقعی راه‌اندازی متصل شوید.", + "clearAllAppData": "پاک کردن همهٔ داده‌های برنامه", + "logsUnavailableSystemExtension": "گزارش‌ها در نسخهٔ System Extension در دسترس نیستند.", + "connectionScopeFallback": "«{origin}» نامزد قابل‌خواندن ندارد. «{target}» از همهٔ سرورها انتخاب شد. اتصال‌های بعدی از انتخاب خودکار استفاده می‌کنند.", + "@connectionScopeFallback": { + "placeholders": { + "origin": { + "type": "String" + }, + "target": { + "type": "String" + } + } + }, + "serverNoMatchingResources": "منبع مطابقی یافت نشد", + "connectionRuntimeTitle": "اتصال فعال", + "serverLatencyFailed": "آزمایش ناموفق" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index d5620008e..153abfac1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -364,7 +364,7 @@ "prototypeAutomaticSelection": "Автовыбор", "prototypeAutomaticOneEntry": "Автовыбор · 1 входной узел", "prototypeAutomaticEntries": "Автовыбор · входных узлов: {count}", - "prototypeChooseBySpeedAvailability": "Выбирать по скорости и доступности", + "prototypeChooseBySpeedAvailability": "Сначала учитываются сохранённые успешные задержки; узлы без измерения и с ошибкой теста тоже доступны.", "prototypeFastLatency": "Быстрый · {latency} мс", "prototypeAvailableLatency": "Доступен · {latency} мс", "prototypeSlowLatency": "Медленный · {latency} мс", @@ -494,9 +494,9 @@ "prototypeCustomConnectionReason": "Ваши правила определяют, какой трафик идёт через VPN, напрямую или блокируется. Выбор серверов в правилах не сохраняется.", "prototypeNamedCustomConnectionReason": "{name} определяет, какой трафик идёт через VPN, напрямую или блокируется. Правила не сохраняют выбор серверов.", "prototypeRunningEntriesReason": "Для этого подключения выбраны {entries}. Повторная проверка не меняет текущее подключение.", - "prototypeMultipleEntriesReason": "OneXray выбрал входные узлы с лучшей скоростью и доступностью: {count}. Новые соединения распределяются между ними.", + "prototypeMultipleEntriesReason": "OneXray выбрал {count} доступных для чтения входных узлов. Сохранённые успешные задержки влияют только на порядок.", "prototypeFixedEntryReason": "Вы выбрали сервер {server} вручную.", - "prototypeAutomaticEntryReason": "По последней проверке {server} показал лучшую скорость и доступность среди подходящих серверов.", + "prototypeAutomaticEntryReason": "{server} выбран по сохранённым успешным задержкам и стабильному порядку. Проверка перед подключением не требуется.", "prototypeNoAnalyticsLocalLogs": "OneXray не собирает и не передаёт аналитику. При включении журналы хранятся на этом устройстве.", "prototypeEditSubscription": "Изменить подписку", "prototypeEditServer": "Изменить сервер", @@ -535,7 +535,7 @@ "prototypeNoDirectRegions": "Нет регионов прямого доступа", "prototypeMoreRegions": "{name} и ещё {count}", "prototypeAutomaticEntryServers": "Входные узлы при автовыборе", - "prototypeAutomaticEntryServersHint": "Использовать лучшие доступные серверы. При выборе нескольких распределять между ними новые соединения.", + "prototypeAutomaticEntryServersHint": "Сначала учитываются сохранённые успешные задержки; узлы без измерения и с ошибкой теста тоже доступны.", "prototypeVpnFinalExit": "Конечный выход VPN", "prototypeVpnFinalExitHint": "Необязательно. Трафик VPN выходит в интернет через этот постоянный сервер.", "prototypeNotSet": "Не задано", @@ -1001,5 +1001,21 @@ "xrayDiagnosticsHint": "Просмотр ошибок подключения, конфигурации и журналов.", "xrayRefreshStatus": "Обновить статус", "xrayNoConnectionError": "Текущих ошибок подключения нет.", - "xrayNoRuntimeConfiguration": "Рабочая конфигурация пока отсутствует. Подключитесь, чтобы просмотреть параметры запуска." + "xrayNoRuntimeConfiguration": "Рабочая конфигурация пока отсутствует. Подключитесь, чтобы просмотреть параметры запуска.", + "clearAllAppData": "Удалить все данные приложения", + "logsUnavailableSystemExtension": "В версии System Extension журналы недоступны.", + "connectionScopeFallback": "Для «{origin}» нет доступных для чтения кандидатов. Выбран «{target}» из всех серверов. Следующие подключения используют автовыбор.", + "@connectionScopeFallback": { + "placeholders": { + "origin": { + "type": "String" + }, + "target": { + "type": "String" + } + } + }, + "serverNoMatchingResources": "Нет подходящих ресурсов", + "connectionRuntimeTitle": "Активное подключение", + "serverLatencyFailed": "Ошибка теста" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index cb6b88dcb..2d03caaf5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -364,7 +364,7 @@ "prototypeAutomaticSelection": "自动选择", "prototypeAutomaticOneEntry": "自动选择 · 1 个接入节点", "prototypeAutomaticEntries": "自动选择 · {count} 个接入节点", - "prototypeChooseBySpeedAvailability": "根据速度和可用性自动选择", + "prototypeChooseBySpeedAvailability": "已有成功延迟优先;未测或测速失败的节点仍可选择。", "prototypeFastLatency": "快 · {latency} ms", "prototypeAvailableLatency": "可用 · {latency} ms", "prototypeSlowLatency": "慢 · {latency} ms", @@ -494,9 +494,9 @@ "prototypeCustomConnectionReason": "自定义规则决定哪些流量使用 VPN、保持直连或被阻止;规则不保存服务器选择。", "prototypeNamedCustomConnectionReason": "{name}决定哪些流量使用 VPN、保持直连或被阻止;规则不保存服务器选择。", "prototypeRunningEntriesReason": "本次连接选用了 {entries}。重新测速不会改变当前连接。", - "prototypeMultipleEntriesReason": "OneXray 选择了速度与可用性最佳的 {count} 个接入节点,新连接会在它们之间分配。", + "prototypeMultipleEntriesReason": "OneXray 选择了 {count} 个可读取的接入节点,已有成功延迟仅用于排序。", "prototypeFixedEntryReason": "你直接选择了 {server}。", - "prototypeAutomaticEntryReason": "{server} 是符合条件的服务器中近期速度与可用性最佳的选择。", + "prototypeAutomaticEntryReason": "按已有成功延迟和稳定顺序选择了 {server},启动前不要求测速。", "prototypeNoAnalyticsLocalLogs": "OneXray 不收集或上传分析数据;可选日志只保留在本设备。", "prototypeEditSubscription": "编辑订阅", "prototypeEditServer": "编辑节点", @@ -535,7 +535,7 @@ "prototypeNoDirectRegions": "未选择直连地区", "prototypeMoreRegions": "{name}等 {count} 个地区", "prototypeAutomaticEntryServers": "自动接入节点", - "prototypeAutomaticEntryServersHint": "选择速度与可用性最佳的节点;选择多个时,新连接会自动分配。", + "prototypeAutomaticEntryServersHint": "已有成功延迟优先;未测或测速失败的节点仍可选择。", "prototypeVpnFinalExit": "VPN 最终出口", "prototypeVpnFinalExitHint": "可选。VPN 流量最终通过这台固定服务器访问互联网。", "prototypeNotSet": "未设置", @@ -1001,5 +1001,21 @@ "xrayDiagnosticsHint": "查看连接错误、运行配置与日志。", "xrayRefreshStatus": "刷新状态", "xrayNoConnectionError": "当前没有连接错误。", - "xrayNoRuntimeConfiguration": "尚无运行配置。建立连接后可查看实际启动输入。" + "xrayNoRuntimeConfiguration": "尚无运行配置。建立连接后可查看实际启动输入。", + "clearAllAppData": "清除全部 App 数据", + "logsUnavailableSystemExtension": "System Extension 版本不提供日志。", + "connectionScopeFallback": "“{origin}”没有可读取的候选节点,已从全部服务器选择“{target}”。后续连接将使用自动选择。", + "@connectionScopeFallback": { + "placeholders": { + "origin": { + "type": "String" + }, + "target": { + "type": "String" + } + } + }, + "serverNoMatchingResources": "没有匹配的资源", + "connectionRuntimeTitle": "正在运行", + "serverLatencyFailed": "测速失败" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 53e300a5a..0fb0010f0 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -364,7 +364,7 @@ "prototypeAutomaticSelection": "自動選擇", "prototypeAutomaticOneEntry": "自動選擇 · 1 個接入節點", "prototypeAutomaticEntries": "自動選擇 · {count} 個接入節點", - "prototypeChooseBySpeedAvailability": "根據速度和可用性自動選擇", + "prototypeChooseBySpeedAvailability": "已有成功延遲優先;未測或測速失敗的節點仍可選擇。", "prototypeFastLatency": "快 · {latency} ms", "prototypeAvailableLatency": "可用 · {latency} ms", "prototypeSlowLatency": "慢 · {latency} ms", @@ -494,9 +494,9 @@ "prototypeCustomConnectionReason": "自訂規則決定哪些流量使用 VPN、保持直連或被封鎖;規則不儲存伺服器選擇。", "prototypeNamedCustomConnectionReason": "{name} 決定哪些流量使用 VPN、保持直連或遭到封鎖;規則不儲存伺服器選擇。", "prototypeRunningEntriesReason": "本次連線選用了 {entries}。重新測速不會改變目前連線。", - "prototypeMultipleEntriesReason": "OneXray 選擇了速度與可用性最佳的 {count} 個接入節點,新連線會在它們之間分配。", + "prototypeMultipleEntriesReason": "OneXray 選擇了 {count} 個可讀取的接入節點,已有成功延遲僅用於排序。", "prototypeFixedEntryReason": "你直接選擇了 {server}。", - "prototypeAutomaticEntryReason": "{server} 是符合條件的伺服器中近期速度與可用性最佳的選擇。", + "prototypeAutomaticEntryReason": "依已有成功延遲和穩定順序選擇了 {server},啟動前不要求測速。", "prototypeNoAnalyticsLocalLogs": "OneXray 不收集或上傳分析資料;選用記錄只保留在本裝置。", "prototypeEditSubscription": "編輯訂閱", "prototypeEditServer": "編輯節點", @@ -535,7 +535,7 @@ "prototypeNoDirectRegions": "未選擇直連地區", "prototypeMoreRegions": "{name}及其他 {count} 個地區", "prototypeAutomaticEntryServers": "自動接入節點", - "prototypeAutomaticEntryServersHint": "選擇速度與可用性最佳的節點;選擇多個時,新連線會自動分配。", + "prototypeAutomaticEntryServersHint": "已有成功延遲優先;未測或測速失敗的節點仍可選擇。", "prototypeVpnFinalExit": "VPN 最終出口", "prototypeVpnFinalExitHint": "非必要。VPN 流量會經由這台固定伺服器存取網際網路。", "prototypeNotSet": "未設定", @@ -1001,5 +1001,21 @@ "xrayDiagnosticsHint": "查看連線錯誤、執行設定與日誌。", "xrayRefreshStatus": "重新整理狀態", "xrayNoConnectionError": "目前沒有連線錯誤。", - "xrayNoRuntimeConfiguration": "尚無執行設定。建立連線後可查看實際啟動輸入。" + "xrayNoRuntimeConfiguration": "尚無執行設定。建立連線後可查看實際啟動輸入。", + "clearAllAppData": "清除全部 App 資料", + "logsUnavailableSystemExtension": "System Extension 版本不提供日誌。", + "connectionScopeFallback": "「{origin}」沒有可讀取的候選節點,已從全部伺服器選擇「{target}」。後續連線將使用自動選擇。", + "@connectionScopeFallback": { + "placeholders": { + "origin": { + "type": "String" + }, + "target": { + "type": "String" + } + } + }, + "serverNoMatchingResources": "沒有符合的資源", + "connectionRuntimeTitle": "正在執行", + "serverLatencyFailed": "測速失敗" } diff --git a/lib/pages/connect/controller.dart b/lib/pages/connect/controller.dart index 6172d7d9e..17c7c7eee 100644 --- a/lib/pages/connect/controller.dart +++ b/lib/pages/connect/controller.dart @@ -15,6 +15,8 @@ import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/connect/dialogs.dart'; +import 'package:onexray/pages/connect/target_picker.dart'; +import 'package:onexray/service/connect/resolver.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/theme.dart'; @@ -27,6 +29,7 @@ import 'package:onexray/service/connect/routing/custom/service.dart'; import 'package:onexray/service/connect/routing/custom/configuration.dart'; import 'package:onexray/service/servers/catalog.dart'; +import 'package:onexray/service/servers/server.dart'; import 'package:onexray/pages/servers/catalog.dart'; import 'package:onexray/pages/shared/connection_action.dart'; @@ -247,17 +250,10 @@ class ConnectController extends PageCubit with ServerLabels { } String selectionTitle(AppLocalizations l10n) { - final providerName = runningProfile?.name ?? selectedProvider?.name; - if (providerName != null) return providerName; final settings = configuration.connection; - if (settings.trafficMode != TrafficMode.allVpn && - settings.selection.kind != SelectionKind.server) { - final count = runningRoute?.entryCount ?? _configuredEntryCount(settings); - if (count != null) { - return count == 1 - ? l10n.prototypeAutomaticOneEntry - : l10n.prototypeAutomaticEntries(count); - } + if (settings.expert) { + return raws.where((r) => r.id == settings.rawId).firstOrNull?.name ?? + l10n.prototypeNotSelected; } return _selectionName(l10n, settings.selection); } @@ -296,12 +292,52 @@ class ConnectController extends PageCubit with ServerLabels { bool get providerConfiguration => runningProfile != null || selectedProvider != null; - String? selectionDetail(AppLocalizations l10n) => providerConfiguration - ? l10n.providerRoutingNotice - : runningRoute?.path ?? - (configuration.connection.selection.kind == SelectionKind.server - ? null - : l10n.prototypeChooseBySpeedAvailability); + String selectionKindLabel(AppLocalizations l) => + configuration.connection.expert || selectedProvider != null + ? l.serverCompleteConfigurationsTab + : configuration.connection.selection.kind == SelectionKind.server + ? l.serverSelectionFixedNode + : l.serverSelectionAutomaticScope; + + int candidateCount(ServerSelection selection) { + final settings = _ordinarySettings(configuration.connection); + final seen = {}; + return servers + .where( + (row) => + row.id > 0 && + seen.add(row.id) && + row.id != settings.finalExitId && + catalog.outboundEligible(row) && + switch (selection.kind) { + SelectionKind.automatic => true, + SelectionKind.source => row.subId == selection.id, + SelectionKind.server => row.id == selection.id, + }, + ) + .length; + } + + String? fallbackMessage(AppLocalizations l) { + final runtime = connectionView.runtime; + final fallback = runtime?.selectionFallback; + if (runtime == null || + fallback == null || + connectionView.phase != ConnectionPhase.connected) { + return null; + } + return l.connectionScopeFallback( + fallback.sourceName ?? _selectionName(l, fallback.originalSelection), + runtime.profile?.name ?? runtime.entries.map((e) => e.name).join(' + '), + ); + } + + String? selectionDetail(AppLocalizations l10n) { + final settings = configuration.connection; + if (settings.expert) return l10n.prototypeCompleteXrayConfiguration; + if (selectedProvider != null) return l10n.providerRoutingNotice; + return '${l10n.connectionCandidates(candidateCount(settings.selection))} · ${l10n.connectionRequiredEntries(_configuredEntryCount(settings) ?? 1)}'; + } // The runtime chooses the node identity; the badge shows that node's latest // successful probe, not a measurement of this session or a new selection. @@ -436,10 +472,13 @@ class ConnectController extends PageCubit with ServerLabels { BuildContext context, Map values, { Future Function()? writeAssets, + Future Function()? validateAssets, String? label, }) async { if (connectionView.busy || pendingChange != null) return false; if (providerConfiguration && + !values.containsKey('selection') && + !values.containsKey('expert') && (values.containsKey('trafficMode') || values.containsKey('customId'))) { return false; } @@ -451,17 +490,48 @@ class ConnectController extends PageCubit with ServerLabels { ? 'method' : 'expert'; try { + if (validateAssets != null && + !await runConnectionAction(context, coordinator, validateAssets)) { + return false; + } + if (!context.mounted || !isPageActive) return false; + final current = await coordinator.configuration; + if (!context.mounted || !isPageActive) return false; + final next = ConnectionConfiguration( + connection: ConnectionSettings.fromJson({ + ...current.connection.toJson(), + ...values, + }), + policy: current.policy, + ); + if (next.encode() == current.encode() && writeAssets == null) { + configuration = current; + return true; + } final saved = await applyConnectionChange( context, coordinator, values, - writeAssets: writeAssets, + validateAssets: validateAssets, + writeAssets: validateAssets == null + ? writeAssets + : () async { + await validateAssets(); + await writeAssets?.call(); + }, label: (next) => label ?? _changeLabel(AppLocalizations.of(context)!, values, next), ); if (saved == null) return false; configuration = saved; return true; + } catch (error, stackTrace) { + if (context.mounted && isPageActive) { + await runConnectionAction(context, coordinator, () async { + Error.throwWithStackTrace(error, stackTrace); + }); + } + return false; } finally { pendingChange = null; } @@ -492,7 +562,41 @@ class ConnectController extends PageCubit with ServerLabels { } Future selectRaw(BuildContext context, int id) async { - await change(context, {'expert': true, 'rawId': id}); + await change( + context, + {'expert': true, 'rawId': id}, + validateAssets: () async { + await RawEditorService( + database: db, + coordinator: coordinator, + ).validateSelection(id); + }, + ); + } + + Future _validateSelection(ServerSelection selection) async { + await ServerAssetService( + database: db, + coordinator: coordinator, + ).validateSelection(selection); + if (selection.kind == SelectionKind.source && selection.id! > 0) { + final source = await db.subscriptionDao.searchRow(selection.id!); + if (source?.mode != SubscriptionMode.appManaged.name) { + throw const FormatException('The selected subscription mode changed'); + } + } + if (selection.kind == SelectionKind.server) { + final settings = _ordinarySettings( + (await coordinator.configuration).connection, + ); + if (selection.id == settings.finalExitId && + (await db.coreConfigDao.searchActiveServerRow(selection.id!))?.type == + CoreConfigType.outbound.name) { + throw const ConnectionResolutionException( + ConnectionResolutionFailure.selfReference, + ); + } + } } Future deleteRaw(BuildContext context, CoreConfigData row) async { @@ -544,8 +648,50 @@ class ConnectController extends PageCubit with ServerLabels { context.pushScoped(AppPageDestination.serversImport); Future editRaw(BuildContext context, [int? id]) => context.pushScoped(AppPageDestination.rawEditor, extra: id); - Future chooseServer(BuildContext context) => - context.pushScoped(AppPageDestination.servers); + Future chooseServer(BuildContext context) async { + if (connectionView.busy || pendingChange != null) return; + final l = AppLocalizations.of(context)!; + final choice = await showConnectDialog( + context, + (_) => ConnectionTargetPicker( + settings: configuration.connection, + catalog: catalog, + raws: raws, + candidateCount: candidateCount, + selectionName: (s) => _selectionName(l, s), + ), + ); + if (choice == null || !context.mounted) return; + if (choice.manage) { + await context.pushScoped(AppPageDestination.servers); + } else if (choice.restore) { + OrdinaryPlanRestore? restored; + if (!await runConnectionAction(context, coordinator, () async { + restored = await coordinator.restoreOrdinaryPlan(); + })) { + return; + } + final plan = restored; + if (!context.mounted || plan == null) return; + final selection = plan.configuration.connection.selection; + if (await change(context, { + 'expert': false, + 'selection': selection.toJson(), + }, validateAssets: () => _validateSelection(selection)) && + plan.requiresSelection && + context.mounted) { + ContextAlert.showToast(context, l.connectionRememberedPlanUnavailable); + } + } else if (choice.rawId != null) { + await selectRaw(context, choice.rawId!); + } else if (choice.selection != null) { + final selection = choice.selection!; + await change(context, { + 'expert': false, + 'selection': selection.toJson(), + }, validateAssets: () => _validateSelection(selection)); + } + } Future showRawActions(BuildContext context, CoreConfigData row) async { if (deletingRawIds.contains(row.id)) return; @@ -605,47 +751,8 @@ class ConnectController extends PageCubit with ServerLabels { } } - Future chooseTrafficMethod(BuildContext context) async { - if (providerConfiguration) return; - final selected = await showConnectDialog( - context, - (context) => ConnectTrafficMethodDialog( - current: configuration.connection, - customRoutes: [ - for (final profile in customRoutes) - ( - id: profile.id!, - name: profile.name, - ruleCount: _ruleCount(profile), - advanced: profile.advanced, - ), - ], - ), - ); - if (selected != null && context.mounted) { - if (selected.edit) { - var destination = AppPageDestination.smartRouting; - if (selected.mode == TrafficMode.custom) { - final advanced = selected.id == null - ? await showCustomRoutingModeDialog(context) - : customRoutes - .firstWhere((profile) => profile.id == selected.id) - .advanced; - if (advanced == null || !context.mounted) return; - destination = advanced - ? AppPageDestination.advancedRouting - : AppPageDestination.customRouting; - } - if (!context.mounted) return; - await context.pushScoped(destination, extra: selected.id); - return; - } - await change(context, { - 'trafficMode': selected.mode.name, - 'customId': selected.id, - }); - } - } + Future chooseTrafficMethod(BuildContext context) => + context.pushScoped(AppPageDestination.routing); Future showWhy(BuildContext context) async { final l = AppLocalizations.of(context)!; @@ -755,30 +862,29 @@ class ConnectController extends PageCubit with ServerLabels { ); } - /// Explain existing successful measurements without probing or selecting a + ConnectionSettings _ordinarySettings(ConnectionSettings settings) => + ConnectionSettings.fromJson({...settings.toJson(), 'expert': false}); + + /// Explain the cached candidate order without probing or selecting a /// runtime input. The actual connection still resolves through the coordinator. List _previewEntries(ConnectionSettings settings) { + settings = _ordinarySettings(settings); final count = _configuredEntryCount(settings); - if (count == null || settings.expert) return []; - final rows = - servers.where((row) { - if (!catalog.outboundEligible(row) || - row.id <= 0 || - row.id == settings.finalExitId || - !PingDelayConstants.isSuccessful(row.delay)) { - return false; - } - final matches = switch (settings.selection.kind) { - SelectionKind.automatic => true, - SelectionKind.source => row.subId == settings.selection.id, - SelectionKind.server => row.id == settings.selection.id, - }; - if (!matches) return false; - return catalog.display(row).readable; - }).toList()..sort((a, b) { - final delay = a.delay.compareTo(b.delay); - return delay == 0 ? a.id.compareTo(b.id) : delay; - }); + if (count == null) return []; + final rows = servers.where((row) { + if (!catalog.outboundEligible(row) || + row.id <= 0 || + row.id == settings.finalExitId) { + return false; + } + final matches = switch (settings.selection.kind) { + SelectionKind.automatic => true, + SelectionKind.source => row.subId == settings.selection.id, + SelectionKind.server => row.id == settings.selection.id, + }; + if (!matches) return false; + return catalog.display(row).readable; + }).toList()..sort(ConnectionResolver.compareCandidates); return rows.length < count ? [] : rows.take(count).toList(); } diff --git a/lib/pages/connect/json_editor/controller.dart b/lib/pages/connect/json_editor/controller.dart index c8ab3e376..bfa9cac2c 100644 --- a/lib/pages/connect/json_editor/controller.dart +++ b/lib/pages/connect/json_editor/controller.dart @@ -8,6 +8,7 @@ import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/service/connect/raw/editor.dart'; import 'package:onexray/service/connect/routing/custom/advanced.dart'; import 'package:onexray/service/connect/routing/custom/editor.dart'; @@ -31,6 +32,10 @@ class JsonConfigurationEditorState { final int? sharingDataCount; final String name; final String text; + final String savedName; + final String savedText; + final bool confirmingClose; + final bool closeAllowed; final ConfigurationTransferState transfer; const JsonConfigurationEditorState({ @@ -44,9 +49,18 @@ class JsonConfigurationEditorState { this.sharingDataCount, this.name = '', this.text = '', + this.savedName = '', + this.savedText = '', + this.confirmingClose = false, + this.closeAllowed = false, this.transfer = const ConfigurationTransferState(), }); + bool get dirty => name != savedName || text != savedText; + bool get closeBlocked => (loaded && busy) || transfer.busy; + bool get canPop => + closeAllowed || (!closeBlocked && !confirmingClose && !dirty); + JsonConfigurationEditorState copyWith({ bool? loaded, bool? busy, @@ -58,6 +72,10 @@ class JsonConfigurationEditorState { Object? sharingDataCount = _unchanged, String? name, String? text, + String? savedName, + String? savedText, + bool? confirmingClose, + bool? closeAllowed, ConfigurationTransferState? transfer, }) => JsonConfigurationEditorState( loaded: loaded ?? this.loaded, @@ -74,6 +92,10 @@ class JsonConfigurationEditorState { : sharingDataCount as int?, name: name ?? this.name, text: text ?? this.text, + savedName: savedName ?? this.savedName, + savedText: savedText ?? this.savedText, + confirmingClose: confirmingClose ?? this.confirmingClose, + closeAllowed: closeAllowed ?? this.closeAllowed, transfer: transfer ?? this.transfer, ); } @@ -101,10 +123,12 @@ class JsonConfigurationEditorController JsonConfigurationEditorController({ required this.configurationId, RawEditorService? service, - this._customService, - this._transferService, + CustomRoutingEditorService? customService, + ConfigurationTransferService? transferService, this.kind = ConfigurationKind.raw, }) : _rawService = service, + _customService = customService, + _transferService = transferService, super(const JsonConfigurationEditorState()) { text.addListener(_textChanged); name.addListener(_nameChanged); @@ -116,7 +140,12 @@ class JsonConfigurationEditorController RawEditorDraft? _draft; CustomRoutingEditorDraft? _routingDraft; bool get advanced => kind == ConfigurationKind.customAdvanced; - bool get canDelete => advanced && _routingDraft?.original != null && !working; + bool get canDelete => + advanced && + _routingDraft?.original != null && + !working && + !state.confirmingClose && + !state.closeAllowed; bool _saving = false; int _textRevision = 0; int _draftRevision = 0; @@ -143,6 +172,8 @@ class JsonConfigurationEditorController bool get loaded => state.loaded; bool get canSave => !working && + !state.confirmingClose && + !state.closeAllowed && loaded && state.text.trim().isNotEmpty && state.name.trim().isNotEmpty; @@ -183,9 +214,48 @@ class JsonConfigurationEditorController } } - void closePage(BuildContext context) => Navigator.of(context).pop(); + Future closePage(BuildContext context) async { + if (!isPageActive || + !context.mounted || + state.closeBlocked || + state.confirmingClose || + state.closeAllowed || + ModalRoute.of(context)?.isCurrent != true) + return; + if (state.dirty) { + final l = AppLocalizations.of(context)!; + emit(state.copyWith(confirmingClose: true)); + var discard = false; + try { + discard = await AppConfirmationDialog( + title: l.jsonEditorDiscardTitle, + content: l.jsonEditorDiscardMessage, + cancelLabel: l.jsonEditorKeepEditing, + confirmLabel: l.jsonEditorDiscardChanges, + destructive: true, + ).show(context); + } finally { + emit(state.copyWith(confirmingClose: false)); + } + if (!discard || !isPageActive || !context.mounted || state.closeBlocked) + return; + } + _finishClose(context); + } + + void _finishClose(BuildContext context, {int? result}) { + if (!isPageActive || + !context.mounted || + state.closeAllowed || + ModalRoute.of(context)?.isCurrent != true) + return; + emit(state.copyWith(closeAllowed: true)); + Navigator.of(context).pop(result); + } Future load(BuildContext context) async { + final initialName = name.text; + final initialText = text.text; try { if (advanced) { final draft = configurationId == null @@ -200,14 +270,19 @@ class JsonConfigurationEditorController } if (!isPageActive) return; _routingDraft = draft; - name.text = draft.state.name; - text.text = draft.state.encode(); + final loadedText = draft.state.encode(); + emit( + state.copyWith(savedName: draft.state.name, savedText: loadedText), + ); + if (name.text == initialName) name.text = draft.state.name; + if (text.text == initialText) text.text = loadedText; } else { final draft = await service.load(configurationId); if (!isPageActive) return; _draft = draft; - name.text = draft.name; - text.text = draft.text; + emit(state.copyWith(savedName: draft.name, savedText: draft.text)); + if (name.text == initialName) name.text = draft.name; + if (text.text == initialText) text.text = draft.text; } } catch (error) { if (context.mounted) { @@ -294,6 +369,11 @@ class JsonConfigurationEditorController ); } if (id != null) await transfers.completeImport(); + if (id != null && isPageActive) { + emit( + state.copyWith(savedName: submittedName, savedText: submittedText), + ); + } if (id != null && isPageActive && context.mounted && @@ -303,7 +383,7 @@ class JsonConfigurationEditorController context, l10n.prototypeNameSaved(submittedName.trim()), ); - Navigator.of(context).pop(id); + _finishClose(context, result: id); } else if (id != null && isPageActive) { // Keep newer edits visible, but advance the saved baseline so a later // save updates this row instead of duplicating it or reporting conflict. @@ -396,7 +476,7 @@ class JsonConfigurationEditorController : Future.value(false), ); if (deleted && isPageActive && context.mounted) { - Navigator.of(context).pop(); + _finishClose(context); } } catch (error) { emit(state.copyWith(error: appFailureMessage(l10n, error))); diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index c8852b5cd..43f79d6f6 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/core/errors/json_diagnostic.dart'; @@ -23,10 +24,12 @@ import 'package:onexray/service/shared/share/configuration_transfer.dart'; class JsonConfigurationEditorPage extends StatefulWidget { final int? configurationId; final ConfigurationKind kind; + final JsonConfigurationEditorController Function()? createController; const JsonConfigurationEditorPage({ super.key, this.configurationId, this.kind = ConfigurationKind.raw, + this.createController, }); @override State createState() => @@ -34,10 +37,12 @@ class JsonConfigurationEditorPage extends StatefulWidget { } class _JsonConfigurationEditorState extends State { - late final controller = JsonConfigurationEditorController( - configurationId: widget.configurationId, - kind: widget.kind, - ); + late final controller = + widget.createController?.call() ?? + JsonConfigurationEditorController( + configurationId: widget.configurationId, + kind: widget.kind, + ); bool _started = false; @override @@ -68,191 +73,225 @@ class _JsonConfigurationEditorState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); - final gap = mobile ? 16.0 : 20.0; - return Scaffold( - appBar: PageAppBar( - title: Text( - controller.advanced - ? l10n.routingAdvancedJson - : widget.configurationId == null - ? l10n.prototypeAddRawJson - : l10n.prototypeEditRawJson, - ), - leading: BackButton( - onPressed: () => controller.closePage(context), - ), - actions: [ - PageHelpButton( - title: controller.advanced - ? l10n.routingAdvancedJson - : l10n.xrayRawPageTitle, - messages: controller.advanced - ? [l10n.routingAdvancedNotice] - : [ - l10n.prototypeRawManagedSettingsNotice, - l10n.prototypeRawAdditionalSettingsNotice, - ], - ), - ], - ), - body: SafeArea( - bottom: false, - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - mobile ? 14 : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageTop, - mobile ? 14 : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageBottom, - ), - child: ResponsiveContent( - desktopMaxWidth: 900, - child: RoutingCard( - padding: EdgeInsets.symmetric( - horizontal: mobile ? 12 : 20, - vertical: mobile ? 14 : 20, + const gap = 16.0; + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + controller.closePage(context), + }, + child: Focus( + autofocus: true, + child: PopScope( + canPop: state.canPop, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) controller.closePage(context); + }, + child: Scaffold( + appBar: PageAppBar( + title: Text( + controller.advanced + ? l10n.routingAdvancedJson + : widget.configurationId == null + ? l10n.prototypeAddRawJson + : l10n.prototypeEditRawJson, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConfigurationTransferTools( - controller: controller.transfers, - disabled: state.busy || !state.loaded, - ), - if (state.sharingDataCount case final count? - when count > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: _RawNote( - l10n.prototypeSharingDataSourceLinks(count), - ), - ), - SizedBox(height: gap), - Text( - l10n.prototypeConfigurationName, - style: AppTypography.rawField.copyWith( - color: palette.mutedStrong, + leading: BackButton( + onPressed: + state.closeBlocked || + state.confirmingClose || + state.closeAllowed + ? null + : () => controller.closePage(context), + ), + actions: [ + PageHelpButton( + title: controller.advanced + ? l10n.routingAdvancedJson + : l10n.xrayRawPageTitle, + messages: controller.advanced + ? [l10n.routingAdvancedNotice] + : [ + l10n.prototypeRawManagedSettingsNotice, + l10n.prototypeRawAdditionalSettingsNotice, + ], + ), + ], + ), + body: SafeArea( + bottom: false, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + mobile ? 14 : AppSpacing.page, + mobile ? 12 : AppSpacing.desktopPageTop, + mobile ? 14 : AppSpacing.page, + mobile ? 12 : AppSpacing.desktopPageBottom, + ), + child: ResponsiveContent( + desktopMaxWidth: AppLayout.routingMaxWidth, + child: RoutingCard( + padding: EdgeInsets.symmetric( + horizontal: mobile ? 12 : 20, + vertical: mobile ? 14 : 20, ), - ), - const SizedBox(height: 8), - SizedBox( - height: 42, - child: TextField( - controller: controller.name, - maxLength: 32, - enabled: state.loaded, - style: AppTypography.rawField, - decoration: InputDecoration( - hintText: l10n.prototypeConfigurationName, - hintStyle: AppTypography.rawField.copyWith( - color: palette.mutedForeground, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConfigurationTransferTools( + controller: controller.transfers, + disabled: state.busy || !state.loaded, ), - counterText: '', - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, + if (state.sharingDataCount case final count? + when count > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _RawNote( + l10n.prototypeSharingDataSourceLinks( + count, + ), + ), + ), + SizedBox(height: gap), + Text( + l10n.prototypeConfigurationName, + style: AppTypography.rawField.copyWith( + color: palette.mutedStrong, + ), ), - ), - ), - ), - SizedBox(height: gap), - Text( - controller.advanced - ? l10n.routingAdvancedTemplate - : l10n.prototypeCompleteXrayConfiguration, - style: AppTypography.rawField.copyWith( - color: palette.mutedStrong, - ), - ), - const SizedBox(height: 8), - SizedBox( - height: AppJsonEditor.heightForViewport( - context, - mobile ? 392 : 412, - ), - child: AbsorbPointer( - absorbing: !state.loaded, - child: AppJsonEditor( - controller: controller.text, - kind: controller.advanced - ? JsonEditorKind.advancedRouting - : JsonEditorKind.raw, - diagnostic: - state.diagnostic ?? - (state.error == null - ? null - : JsonDiagnostic(state.error!)), - domainSuggestions: state.domainSuggestions, - ipSuggestions: state.ipSuggestions, - ), - ), - ), - if (controller.advanced) - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: () => - controller.openDocumentation(context), - icon: const Icon(LucideIcons.bookOpen), - label: Text(l10n.prototypeDocumentation), - ), - ), - if (!state.loaded && state.busy) - const LinearProgressIndicator(), - if (!state.loaded && state.error != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Semantics( - liveRegion: true, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - MediaQuery.sizeOf(context).height / 4, + const SizedBox(height: 8), + SizedBox( + height: AppLayout.touchExtent(44), + child: TextField( + controller: controller.name, + maxLength: 32, + enabled: state.loaded, + style: AppTypography.rawField, + decoration: InputDecoration( + hintText: l10n.prototypeConfigurationName, + hintStyle: AppTypography.rawField + .copyWith( + color: palette.mutedForeground, + ), + counterText: '', + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, + ), + ), ), - child: SingleChildScrollView( - child: SelectableText( - state.error!, - style: AppTypography.rawNote.copyWith( - color: palette.destructive, - ), + ), + SizedBox(height: gap), + Text( + controller.advanced + ? l10n.routingAdvancedTemplate + : l10n.prototypeCompleteXrayConfiguration, + style: AppTypography.rawField.copyWith( + color: palette.mutedStrong, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: AppJsonEditor.heightForViewport( + context, + mobile ? 392 : 412, + ), + child: AbsorbPointer( + absorbing: !state.loaded, + child: AppJsonEditor( + controller: controller.text, + kind: controller.advanced + ? JsonEditorKind.advancedRouting + : JsonEditorKind.raw, + diagnostic: + state.diagnostic ?? + (state.error == null + ? null + : JsonDiagnostic(state.error!)), + domainSuggestions: + state.domainSuggestions, + ipSuggestions: state.ipSuggestions, ), ), ), - ), + if (controller.advanced) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: () => + controller.openDocumentation(context), + icon: const Icon(LucideIcons.bookOpen), + label: Text(l10n.prototypeDocumentation), + ), + ), + if (!state.loaded && state.busy) + const LinearProgressIndicator(), + if (!state.loaded && state.error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics( + liveRegion: true, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.sizeOf(context) + .height / + 4, + ), + child: SingleChildScrollView( + child: SelectableText( + state.error!, + style: AppTypography.rawNote + .copyWith( + color: palette.destructive, + ), + ), + ), + ), + ), + ), + ], ), - ], - ), - ), - ), - ), - ), - bottomNavigationBar: PageActionBar( - children: [ - if (widget.configurationId != null && controller.advanced) - ShadButton.destructive( - enabled: controller.canDelete, - onPressed: controller.canDelete - ? () => controller.delete(context) - : null, - child: ButtonProgress( - busy: state.deleting, - child: Text(l10n.prototypeDelete), + ), + ), ), ), - if (!mobile) - ShadButton.outline( - onPressed: () => controller.closePage(context), - child: Text(l10n.prototypeCancel), - ), - ShadButton( - enabled: controller.canSave, - onPressed: controller.canSave - ? () => controller.save(context) - : null, - child: ButtonProgress( - busy: state.loaded && state.busy && !state.deleting, - child: Text(l10n.prototypeSave), + bottomNavigationBar: PageActionBar( + children: [ + if (widget.configurationId != null && + controller.advanced) + ShadButton.destructive( + enabled: controller.canDelete, + onPressed: controller.canDelete + ? () => controller.delete(context) + : null, + child: ButtonProgress( + busy: state.deleting, + child: Text(l10n.prototypeDelete), + ), + ), + if (!mobile) + ShadButton.outline( + onPressed: + state.closeBlocked || + state.confirmingClose || + state.closeAllowed + ? null + : () => controller.closePage(context), + child: Text(l10n.prototypeCancel), + ), + ShadButton( + enabled: controller.canSave, + onPressed: controller.canSave + ? () => controller.save(context) + : null, + child: ButtonProgress( + busy: state.loaded && state.busy && !state.deleting, + child: Text(l10n.prototypeSave), + ), + ), + ], ), ), - ], + ), ), ); }, diff --git a/lib/pages/connect/page.dart b/lib/pages/connect/page.dart index effb1ed90..d04b4d1eb 100644 --- a/lib/pages/connect/page.dart +++ b/lib/pages/connect/page.dart @@ -85,7 +85,9 @@ class _ConnectPageState extends State { ? state.configuration.connection.rawId : null, location: controller.selectionTitle(l), + targetKind: controller.selectionKindLabel(l), runningPath: controller.runningRoute?.path, + fallbackMessage: controller.fallbackMessage(l), locationDetail: controller.selectionDetail(l), locationHealth: controller.selectionHealth(l), method: controller.homeMethodTitle(l), diff --git a/lib/pages/connect/target_picker.dart b/lib/pages/connect/target_picker.dart new file mode 100644 index 000000000..35787dda4 --- /dev/null +++ b/lib/pages/connect/target_picker.dart @@ -0,0 +1,298 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/db/database/enum.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/servers/catalog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class ConnectionTargetChoice { + const ConnectionTargetChoice({ + this.selection, + this.rawId, + this.restore = false, + this.manage = false, + }); + final ServerSelection? selection; + final int? rawId; + final bool restore, manage; +} + +/// Tabs only browse. A resource click returns an intent for the coordinator. +class ConnectionTargetPicker extends StatefulWidget { + const ConnectionTargetPicker({ + super.key, + required this.settings, + required this.catalog, + required this.raws, + required this.candidateCount, + required this.selectionName, + }); + final ConnectionSettings settings; + final ServerCatalog catalog; + final List raws; + final int Function(ServerSelection) candidateCount; + final String Function(ServerSelection) selectionName; + @override + State createState() => _ConnectionTargetPickerState(); +} + +class _ConnectionTargetPickerState extends State { + late int tab = + widget.settings.expert || + (widget.settings.selection.kind == SelectionKind.server && + widget.catalog.servers.any( + (r) => + r.id == widget.settings.selection.id && + r.type == CoreConfigType.Remnawave.name, + )) + ? 2 + : widget.settings.selection.kind == SelectionKind.server + ? 1 + : 0; + String query = ''; + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + final settings = widget.settings; + bool selected(ServerSelection s) => + !settings.expert && + s.kind == settings.selection.kind && + s.id == settings.selection.id; + void choose(ConnectionTargetChoice choice) => + Navigator.of(context).pop(choice); + bool matches(String name) => + name.toLowerCase().contains(query.trim().toLowerCase()); + Widget option( + String title, + String detail, + bool active, + VoidCallback onTap, + IconData icon, + ) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: active ? palette.selectedSurface : palette.muted, + borderRadius: BorderRadius.circular(12), + child: Semantics( + selected: active, + button: true, + child: ListTile( + minTileHeight: 64, + leading: Icon(icon, size: 19), + title: Text(title), + subtitle: Text(detail), + trailing: active + ? Icon(LucideIcons.check, color: palette.primary, size: 20) + : null, + onTap: onTap, + ), + ), + ), + ); + final rows = widget.catalog.servers + .where( + (r) => + r.id > 0 && + widget.catalog.selectable(r) && + matches(widget.catalog.display(r).name), + ) + .toList(); + final ordinarySettings = ConnectionSettings.fromJson({ + ...settings.toJson(), + 'expert': false, + }); + final ordinary = rows + .where( + (r) => + r.type == CoreConfigType.outbound.name && + r.id != ordinarySettings.finalExitId, + ) + .toList(); + final profiles = rows + .where((r) => r.type == CoreConfigType.Remnawave.name) + .toList(); + final rawRows = widget.raws.where((r) => matches(r.name)).toList(); + final scopeSelections = [ + const ServerSelection.automatic(), + const ServerSelection.source(0), + for (final source in widget.catalog.sources.where( + (s) => s.mode == SubscriptionMode.appManaged.name, + )) + ServerSelection.source(source.id), + ]; + final entries = + < + ({ + String title, + String detail, + bool active, + ConnectionTargetChoice choice, + IconData icon, + }) + >[ + if (tab == 0) + for (final scope in scopeSelections.where( + (s) => matches(widget.selectionName(s)), + )) + ( + title: widget.selectionName(scope), + detail: l.connectionCandidates(widget.candidateCount(scope)), + active: selected(scope), + choice: ConnectionTargetChoice(selection: scope), + icon: LucideIcons.layers3, + ), + if (tab == 1) + for (final row in ordinary) + ( + title: widget.catalog.display(row).name, + detail: widget.catalog.display(row).protocol, + active: selected(ServerSelection.server(row.id)), + choice: ConnectionTargetChoice( + selection: ServerSelection.server(row.id), + ), + icon: LucideIcons.server, + ), + if (tab == 2) ...[ + for (final row in rawRows) + ( + title: row.name, + detail: l.xrayRawPageTitle, + active: settings.expert && settings.rawId == row.id, + choice: ConnectionTargetChoice(rawId: row.id), + icon: LucideIcons.fileJson, + ), + for (final row in profiles) + ( + title: row.name, + detail: l.providerConfigurationTitle, + active: selected(ServerSelection.server(row.id)), + choice: ConnectionTargetChoice( + selection: ServerSelection.server(row.id), + ), + icon: LucideIcons.fileJson, + ), + ], + ]; + return Material( + color: palette.card, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(20, 12, 12, 8), + child: Row( + children: [ + Expanded( + child: Text( + l.prototypeServers, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + IconButton( + tooltip: l.prototypeClose, + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x, size: 20), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in [ + l.serverSelectionAutomaticScope, + l.serverSelectionFixedNode, + l.serverCompleteConfigurationsTab, + ].indexed) + ChoiceChip( + label: Text(entry.$2), + selected: tab == entry.$1, + onSelected: (_) => setState(() => tab = entry.$1), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + key: const ValueKey('connection-target-search'), + decoration: InputDecoration( + prefixIcon: const Icon(LucideIcons.search, size: 18), + hintText: l.prototypeSearchSubscriptionsServers, + ), + onChanged: (value) => setState(() => query = value), + ), + ), + if (tab == 0 && + (settings.expert || + widget.catalog.servers.any( + (r) => + r.type == CoreConfigType.Remnawave.name && + selected(ServerSelection.server(r.id)), + ))) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextButton( + onPressed: () => + choose(const ConnectionTargetChoice(restore: true)), + child: Text(l.connectionRestoreOrdinary), + ), + ), + Expanded( + child: entries.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + query.trim().isEmpty + ? l.prototypeNoServersYet + : l.serverNoMatchingResources, + ), + ), + ) + : ListView.builder( + key: const ValueKey('connection-target-options'), + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: entries.length, + itemBuilder: (context, index) { + final e = entries[index]; + return option( + e.title, + e.detail, + e.active, + () => choose(e.choice), + e.icon, + ); + }, + ), + ), + PageActionBar( + children: [ + OutlinedButton( + onPressed: () => + choose(const ConnectionTargetChoice(manage: true)), + child: Text(l.prototypeServers), + ), + ShadButton.outline( + onPressed: () => Navigator.of(context).pop(), + child: Text(l.prototypeCancel), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/connect/view.dart b/lib/pages/connect/view.dart index 80461b17d..753c3fc2d 100644 --- a/lib/pages/connect/view.dart +++ b/lib/pages/connect/view.dart @@ -26,7 +26,9 @@ class ConnectView extends StatelessWidget { this.pendingChange, this.deletingRawIds = const {}, required this.location, + this.targetKind, this.runningPath, + this.fallbackMessage, this.locationDetail, this.locationHealth, required this.method, @@ -51,7 +53,9 @@ class ConnectView extends StatelessWidget { final String? pendingChange; final Set deletingRawIds; final String location; + final String? targetKind; final String? runningPath; + final String? fallbackMessage; final String? locationDetail, locationHealth, methodDetail; final String method; final bool methodEnabled; @@ -70,14 +74,70 @@ class ConnectView extends StatelessWidget { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; - final connected = view.phase == ConnectionPhase.connected; - final empty = - !hasServers && - !expert && - activeRawId == null && - !view.canDisconnect && - !_busy; - if (empty) { + final palette = ColorManager.palette(context); + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final raw = raws.where((row) => row.id == activeRawId).firstOrNull; + Widget choice( + IconData icon, + String label, + String value, + String? detail, + VoidCallback? action, + ) => Semantics( + button: action != null, + child: InkWell( + onTap: action, + borderRadius: BorderRadius.circular(AppRadii.control), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3), + child: Icon(icon, size: 19, color: palette.mutedStrong), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: AppTypography.supporting.copyWith( + color: palette.mutedStrong, + ), + ), + const SizedBox(height: 4), + Text(value, style: AppTypography.rowTitle), + if (detail != null) ...[ + const SizedBox(height: 4), + Text( + detail, + style: AppTypography.supporting.copyWith( + color: palette.mutedStrong, + ), + ), + ], + ], + ), + ), + if (action != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Icon( + LucideIcons.chevronRightDir, + size: 18, + color: palette.mutedForeground, + ), + ), + ], + ), + ), + ), + ); + if (!hasServers && raws.isEmpty && !view.canDisconnect && !_busy) { return ResponsiveContent( child: PageEmptyState( title: l.prototypeStartUsingOneXray, @@ -85,431 +145,126 @@ class ConnectView extends StatelessWidget { primaryLabel: l.prototypeAddServers, onPrimary: onAddServers, secondaryLabel: l.prototypeUseCompleteRawJson, - onSecondary: () => onExpert(true), + onSecondary: onRawAdd, ), ); } - if (MediaQuery.sizeOf(context).width > AppLayout.mobileBreakpoint) { - return _desktop(context); - } return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.connectPageHorizontal, - AppSpacing.connectPageTop, - AppSpacing.connectPageHorizontal, - AppSpacing.connectPageBottom, - ), + key: const ValueKey('connection-single-column'), + padding: EdgeInsets.fromLTRB(mobile ? 16 : 28, 12, mobile ? 16 : 28, 28), child: ResponsiveContent( - child: LayoutBuilder( - builder: (context, constraints) { - final main = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _status(context), - const SizedBox(height: 14), - Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.all(1), - child: Column( - children: [ - _expertSwitch(context), - if (expert) - _raws(context) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - children: [ - _choice( - context, - LucideIcons.earth, - l.prototypeServers, - location, - onServer, - detail: locationDetail ?? runningPath, - meta: connected ? locationHealth : null, - ), - _choice( - context, - LucideIcons.shieldCheck, - l.prototypeTrafficMethod, - method, - onMethod, - detail: methodDetail, - enabled: methodEnabled, - busy: pendingChange == 'method', - minHeight: 113, - ), - _why(context), - ], - ), - ), - ], - ), - ), - ), - ], - ); - final traffic = Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.fromLTRB(13, 15, 13, 1), - child: Column( - children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 29), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - l.prototypeTraffic, - style: AppTypography.connectTrafficTitle, - ), - ), - ], - ), - ), - const Divider(), - trafficBuilder?.call(false) ?? TrafficReadout(view: view), - ], - ), - ), - ); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [main, const SizedBox(height: 13), traffic], - ); - }, - ), - ), - ); - } - - Widget _desktop(BuildContext context) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final viewport = MediaQuery.sizeOf(context); - final compact = viewport.width <= AppLayout.compactDesktopBreakpoint; - final connected = view.phase == ConnectionPhase.connected; - final main = Card( - key: const ValueKey('desktop-connection-panel'), - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _status(context, desktop: true), - _expertSwitch(context, desktop: true), - if (expert) - _raws(context, desktop: true) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 29), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _desktopChoice( - context, - LucideIcons.earth, - l.prototypeServers, - location, - onServer, - detail: locationDetail ?? runningPath, - meta: connected ? locationHealth : null, - ), - const SizedBox(height: 25), - _desktopChoice( - context, - LucideIcons.shieldCheck, - l.prototypeTrafficMethod, - method, - onMethod, - detail: methodDetail, - enabled: methodEnabled, - busy: pendingChange == 'method', - ), - const SizedBox(height: 25), - _why(context, desktop: true), - ], - ), - ), - ], - ), - ); - final traffic = Card( - key: const ValueKey('desktop-traffic-panel'), - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 27, 24, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - constraints: const BoxConstraints(minHeight: 48), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - l.prototypeTraffic, - style: AppTypography.connectDesktopTrafficTitle, - ), + _status(context), + if (fallbackMessage != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Semantics( + liveRegion: true, + child: ShadAlert( + icon: const Icon(LucideIcons.info, size: 18), + description: Text(fallbackMessage!), ), - ], - ), - ), - trafficBuilder?.call(true) ?? - TrafficReadout(view: view, desktop: true), - ], - ), - ), - ); - return SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.page, - AppSpacing.desktopPageTop, - AppSpacing.page, - AppSpacing.desktopPageBottom, - ), - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 660), - child: main, ), - const SizedBox(height: 16), - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 560), - child: traffic, - ), - ], - ) - : ConstrainedBox( - constraints: BoxConstraints( - minHeight: - (viewport.height - - AppLayout.connectDesktopPanelViewportInset) - .clamp(0.0, double.infinity), ), - child: IntrinsicHeight( - child: Row( + const SizedBox(height: 18), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(flex: 104, child: main), - const SizedBox(width: 16), - Expanded(flex: 100, child: traffic), + Text(l.connectionPlan, style: AppTypography.panelTitle), + const SizedBox(height: 6), + choice( + expert || !methodEnabled + ? LucideIcons.fileJson + : LucideIcons.server, + targetKind ?? + (expert || !methodEnabled + ? l.serverCompleteConfigurationsTab + : l.prototypeServers), + expert ? (raw?.name ?? l.prototypeNotSelected) : location, + locationDetail, + _busy || pendingChange != null ? null : onServer, + ), + choice( + LucideIcons.route, + l.navigationRouting, + method, + methodDetail, + methodEnabled && !expert && !_busy ? onMethod : null, + ), ], ), ), ), - ); - } - - Widget _desktopChoice( - BuildContext context, - IconData icon, - String label, - String value, - VoidCallback onTap, { - String? detail, - String? meta, - bool busy = false, - bool enabled = true, - }) { - final palette = ColorManager.palette(context); - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsetsDirectional.only(start: 2), - child: Text( - label, - style: AppTypography.connectDesktopChoiceLabel.copyWith( - color: palette.mutedStrong, - ), - ), - ), - const SizedBox(height: 10), - Material( - color: palette.card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.small), - side: BorderSide(color: palette.border), - ), - child: InkWell( - onTap: _busy || !enabled ? null : onTap, - borderRadius: BorderRadius.circular(AppRadii.small), - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: AppLayout.connectDesktopChoiceMinHeight, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - child: Row( - children: [ - if (busy) - const ButtonProgressIndicator() - else - Icon( - icon, - size: 23, - color: Theme.of(context).brightness == Brightness.dark - ? palette.foreground - : palette.scannerBackground, - ), - const SizedBox(width: 13), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectDesktopChoiceTitle, - ), - if (detail != null && detail.isNotEmpty) ...[ - const SizedBox(height: 3), - Text( - detail, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectDesktopChoiceDetail - .copyWith(color: palette.mutedForeground), - ), - ], - ], + if (view.canDisconnect && + (runningPath != null || + view.runtime?.profile != null || + view.runtime?.configuration.connection.expert == true)) ...[ + const SizedBox(height: 18), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.connectionRuntimeTitle, + style: AppTypography.panelTitle, ), - ), - const SizedBox(width: 13), - if (meta != null) ...[ + const SizedBox(height: 10), Text( - meta, - style: AppTypography.connectDesktopChoiceMeta.copyWith( - color: palette.running, + view.runtime?.profile?.name ?? + view.runtime?.rawName ?? + runningPath ?? + l.prototypeCompleteXrayConfiguration, + style: AppTypography.rowTitle, + ), + if (locationHealth != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + locationHealth!, + style: AppTypography.supporting, + ), ), + TextButton.icon( + onPressed: onWhy, + icon: const Icon(LucideIcons.circleHelp, size: 16), + label: Text(l.prototypeWhyThisConnection), ), - const SizedBox(width: 13), ], - if (enabled) - const Icon(LucideIcons.chevronRightDir, size: 19), - if (meta == null) const SizedBox(width: 13), - ], - ), - ), - ), - ), - ), - ], - ); - } - - Widget _expertSwitch(BuildContext context, {bool desktop = false}) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - return Container( - constraints: BoxConstraints( - minHeight: desktop - ? AppLayout.connectDesktopExpertRowMinHeight - : AppLayout.connectExpertRowMinHeight, - ), - padding: EdgeInsets.symmetric(horizontal: desktop ? 18 : 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - l.prototypeExpertMode, - style: desktop - ? AppTypography.connectDesktopCaption - : AppTypography.connectCaption, ), ), - const SizedBox(width: 7), - ExcludeSemantics( - child: Icon( - LucideIcons.info, - size: 15, - color: palette.mutedForeground, - ), + ), + ], + const SizedBox(height: 18), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l.prototypeThisConnection, + style: AppTypography.panelTitle, + ), + const SizedBox(height: 14), + trafficBuilder?.call(false) ?? TrafficReadout(view: view), + ], ), - ], - ), - ), - const SizedBox(width: 16), - if (pendingChange == 'expert') ...[ - const ButtonProgressIndicator(), - const SizedBox(width: 8), - ], - Semantics( - label: l.prototypeExpertMode, - child: ShadSwitch( - value: expert, - enabled: !_busy && pendingChange == null, - onChanged: onExpert, - ), - ), - ], - ), - ); - } - - Widget _why(BuildContext context, {bool desktop = false}) { - final palette = ColorManager.palette(context); - final action = InkWell( - onTap: onWhy, - borderRadius: desktop ? BorderRadius.circular(AppRadii.small) : null, - child: Container( - constraints: BoxConstraints(minHeight: desktop ? 53 : 49), - padding: desktop ? const EdgeInsets.symmetric(horizontal: 14) : null, - child: Row( - children: [ - Icon(LucideIcons.circleHelp, size: 20, color: palette.primary), - const SizedBox(width: 12), - Expanded( - child: Text( - AppLocalizations.of(context)!.prototypeWhyThisConnection, - style: - (desktop - ? AppTypography.connectDesktopWhy - : AppTypography.connectWhy) - .copyWith(color: palette.primary), ), ), - Icon(LucideIcons.chevronRightDir, size: 19, color: palette.primary), ], ), ), ); - if (!desktop) return action; - return Material( - color: palette.card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.small), - side: BorderSide(color: palette.border), - ), - child: action, - ); } Widget _status(BuildContext context, {bool desktop = false}) { @@ -520,7 +275,7 @@ class ConnectView extends StatelessWidget { final presentationPhase = failed ? ConnectionPhase.failed : view.phase; final title = switch (presentationPhase) { ConnectionPhase.disconnected => l.prototypeDisconnected, - ConnectionPhase.preparing || + ConnectionPhase.preparing => l.connectionPreparing, ConnectionPhase.connecting => l.prototypeConnecting, ConnectionPhase.connected => l.prototypeConnected, ConnectionPhase.disconnecting => l.prototypeDisconnecting, @@ -710,529 +465,109 @@ class ConnectView extends StatelessWidget { ), ), ); - if (!desktop) return Card(margin: EdgeInsets.zero, child: content); - return DecoratedBox( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: content, - ); + return Card(margin: EdgeInsets.zero, child: content); } +} - Widget _choice( - BuildContext context, - IconData icon, - String label, - String value, - VoidCallback onTap, { - String? detail, - String? meta, - bool busy = false, - bool enabled = true, - double minHeight = 0, - }) { +class TrafficReadout extends StatelessWidget { + const TrafficReadout({super.key, required this.view, this.desktop = false}); + final ConnectionView view; + final bool desktop; + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - return Container( - constraints: BoxConstraints(minHeight: minHeight), - padding: const EdgeInsets.only(top: 13, bottom: 10), + final live = + view.phase == ConnectionPhase.connected && view.metricsAvailable; + final started = view.phase == ConnectionPhase.connected + ? view.runtime?.startedAt + : null; + final duration = started == null + ? null + : DateTime.now().difference(started); + final elapsed = duration == null + ? '—' + : '${duration.inHours.toString().padLeft(2, '0')}:${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; + Widget metric(String title, String value, String? detail) => Container( + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), + color: palette.muted, + borderRadius: BorderRadius.circular(12), ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, - style: AppTypography.connectChoiceLabel.copyWith( + title, + style: AppTypography.supporting.copyWith( color: palette.mutedStrong, ), ), - const SizedBox(height: 7), - InkWell( - onTap: _busy || !enabled ? null : onTap, - child: Opacity( - opacity: 1, - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: AppLayout.connectChoiceMinHeight, - ), - child: Padding( - padding: const EdgeInsets.only(top: 1, bottom: 3), - child: Row( - children: [ - if (busy) - const ButtonProgressIndicator() - else - Icon( - icon, - size: 23, - color: Theme.of(context).brightness == Brightness.dark - ? palette.foreground - : palette.scannerBackground, - ), - const SizedBox(width: 13), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectChoiceTitle, - ), - if (detail != null && detail.isNotEmpty) ...[ - const SizedBox(height: 3), - Text( - detail, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectChoiceDetail - .copyWith(color: palette.mutedForeground), - ), - ], - ], - ), - ), - const SizedBox(width: 13), - if (meta != null) ...[ - Text( - meta, - style: AppTypography.connectChoiceMeta.copyWith( - color: palette.running, - ), - ), - const SizedBox(width: 13), - ], - if (enabled) - const Icon(LucideIcons.chevronRightDir, size: 19), - if (meta == null) const SizedBox(width: 13), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _raws(BuildContext context, {bool desktop = false}) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - return Padding( - padding: desktop - ? const EdgeInsets.fromLTRB(18, 0, 18, 14) - : const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConstrainedBox( - constraints: BoxConstraints(minHeight: desktop ? 52 : 47), - child: Row( - children: [ - Expanded( - child: Text( - 'Raw JSON', - style: desktop - ? AppTypography.connectDesktopRawTitle - : AppTypography.connectRawTitle, - ), - ), - Text( - '${raws.length} / 3', - textDirection: TextDirection.ltr, - style: AppTypography.connectRawCount.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ), + const SizedBox(height: 8), + Text( + value, + textDirection: TextDirection.ltr, + style: AppTypography.metric, ), - if (view.phase == ConnectionPhase.connected && activeRawId == null) + if (detail != null) Padding( - padding: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.only(top: 4), child: Text( - l.prototypeOrdinaryConnectionRunning, + detail, + textDirection: TextDirection.ltr, style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, - ), - ), - ), - if (raws.isEmpty) - Card( - margin: EdgeInsets.zero, - shape: AppDashedBorder( - side: BorderSide(color: palette.borderStrong), - borderRadius: BorderRadius.circular(AppRadii.card), - ), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: desktop - ? AppLayout.connectDesktopRawEmptyMinHeight - : 160, - ), - child: Padding( - padding: EdgeInsets.all(desktop ? 24 : 19), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: desktop - ? MainAxisAlignment.center - : MainAxisAlignment.start, - children: [ - Icon( - LucideIcons.filePlus, - size: desktop ? 32 : 27, - color: palette.mutedStrong, - ), - const SizedBox(height: 11), - Text( - l.prototypeNoRawJson, - textAlign: TextAlign.center, - style: desktop - ? AppTypography.connectDesktopRawEmptyTitle - : AppTypography.connectRawEmptyTitle, - ), - const SizedBox(height: 9), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: desktop ? 330 : 270, - ), - child: Text( - l.prototypeAddRawJsonHint, - textAlign: TextAlign.center, - style: - (desktop - ? AppTypography - .connectDesktopRawEmptyDetail - : AppTypography.connectRawEmptyDetail) - .copyWith(color: palette.mutedForeground), - ), - ), - const SizedBox(height: 14), - FilledButton.icon( - onPressed: onRawAdd, - icon: const Icon(LucideIcons.plus, size: 17), - label: Text(l.prototypeAddRawJson), - ), - ], - ), - ), - ), - ), - if (raws.isNotEmpty) - Card( - margin: EdgeInsets.zero, - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for (final row in raws) ...[ - if (row != raws.first) const Divider(), - _rawRow(context, row, desktop: desktop), - ], - ], - ), - ), - if (raws.isNotEmpty && raws.length < 3) - Padding( - padding: const EdgeInsets.only(top: 11), - child: OutlinedButton.icon( - onPressed: onRawAdd, - style: OutlinedButton.styleFrom( - foregroundColor: palette.primary, - shape: AppDashedBorder( - borderRadius: BorderRadius.circular(AppRadii.control), - ), + color: palette.mutedStrong, ), - icon: const Icon(LucideIcons.plus, size: 17), - label: Text(l.prototypeAddRawJson), ), ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(LucideIcons.info, size: 16, color: palette.primary), - const SizedBox(width: 8), - Expanded( - child: Text( - l.prototypeRawRuntimeOverrideNotice, - style: AppTypography.connectRawNotice.copyWith( - color: palette.mutedForeground, - ), - ), - ), - ], - ), ], ), ); - } - - Widget _rawRow( - BuildContext context, - CoreConfigData row, { - bool desktop = false, - }) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final active = activeRawId == row.id; - return ColoredBox( - color: active - ? Color.lerp(palette.card, palette.selectedSurface, .6)! - : palette.card, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: desktop ? 66 : 62), - child: Row( - children: [ - Expanded( - child: InkWell( - onTap: - _busy || - pendingChange != null || - deletingRawIds.contains(row.id) - ? null - : () => onRawSelect(row), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: desktop ? 13 : 11, - vertical: 9, - ), - child: Row( - children: [ - if (pendingChange == 'raw:${row.id}') - const ButtonProgressIndicator() - else - Icon( - LucideIcons.fileJson, - size: 21, - color: palette.primary, - ), - const SizedBox(width: 11), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectRawRowTitle, - ), - const SizedBox(height: 3), - Text( - active - ? l.prototypeActiveConfiguration - : l.prototypeCompleteXrayConfiguration, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.connectRawRowDetail.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ), - ), - const SizedBox(width: 11), - Icon( - active ? LucideIcons.check : LucideIcons.circle, - size: active ? 19 : 18, - color: active - ? palette.primary - : palette.mutedForeground, - ), - ], - ), - ), - ), - ), - SizedBox( - width: desktop ? 46 : 42, - child: Center( - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - border: BorderDirectional( - start: BorderSide(color: palette.border), - ), - ), - child: IconButton( - padding: EdgeInsets.zero, - tooltip: '${l.prototypeMoreActions}: ${row.name}', - onPressed: deletingRawIds.contains(row.id) - ? null - : () => onRawActions(row), - icon: deletingRawIds.contains(row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.ellipsis, size: 18), - ), - ), - ), - ), - ], - ), + final items = [ + metric( + l.prototypeDownload, + live ? '${formatTraffic(view.downloadSpeed, connection: true)}/s' : '—', + !live || view.traffic == null + ? '—' + : formatTraffic(view.traffic!.downlink, connection: true), ), - ); - } -} - -class TrafficReadout extends StatelessWidget { - const TrafficReadout({super.key, required this.view, this.desktop = false}); - final ConnectionView view; - final bool desktop; - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - final live = - view.phase == ConnectionPhase.connected && view.metricsAvailable; - final traffic = view.traffic; + metric( + l.prototypeUpload, + live ? '${formatTraffic(view.uploadSpeed, connection: true)}/s' : '—', + !live || view.traffic == null + ? '—' + : formatTraffic(view.traffic!.uplink, connection: true), + ), + metric(l.prototypeUptime, elapsed, null), + ]; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _group( - context, - l.prototypeCurrentSpeed, - live - ? '${formatTraffic(view.downloadSpeed, connection: true)}/s' - : '—', - live ? '${formatTraffic(view.uploadSpeed, connection: true)}/s' : '—', - ), - _group( - context, - l.prototypeThisConnection, - formatTraffic(traffic?.downlink ?? 0, connection: true), - formatTraffic(traffic?.uplink ?? 0, connection: true), - divider: false, + LayoutBuilder( + builder: (context, constraints) { + final large = MediaQuery.textScalerOf(context).scale(14) > 18; + if (constraints.maxWidth < 300 || large) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: items, + ); + } + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final item in items.indexed) ...[ + if (item.$1 > 0) const SizedBox(width: 8), + Expanded(child: item.$2), + ], + ], + ), + ); + }, ), RuntimeObservatoryReadout(view: view, desktop: desktop), ], ); } - - Widget _group( - BuildContext context, - String title, - String download, - String upload, { - bool divider = true, - }) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - return Container( - constraints: BoxConstraints( - minHeight: desktop - ? AppLayout.connectDesktopTrafficGroupMinHeight - : AppLayout.connectTrafficGroupMinHeight, - ), - padding: EdgeInsets.symmetric(vertical: desktop ? 22 : 10.5), - decoration: BoxDecoration( - border: divider - ? Border(bottom: BorderSide(color: palette.border)) - : null, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: - (desktop - ? AppTypography.connectDesktopTrafficGroupTitle - : AppTypography.connectTrafficGroupTitle) - .copyWith(color: palette.mutedStrong), - ), - SizedBox(height: desktop ? 24 : 10), - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _metric( - context, - LucideIcons.arrowDown, - l.prototypeDownload, - download, - palette.primary, - ), - ), - Expanded( - child: DecoratedBox( - decoration: BoxDecoration( - border: BorderDirectional( - start: BorderSide(color: palette.border), - ), - ), - child: Padding( - padding: const EdgeInsetsDirectional.only(start: 1), - child: _metric( - context, - LucideIcons.arrowUp, - l.prototypeUpload, - upload, - palette.running, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _metric( - BuildContext context, - IconData icon, - String label, - String value, - Color color, - ) => Semantics( - label: '$label $value', - excludeSemantics: true, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.controlHorizontal, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: desktop ? 24 : 19, color: color), - const SizedBox(width: AppSpacing.actionRunGap), - Expanded( - child: Text( - label, - style: - (desktop - ? AppTypography.connectDesktopTrafficLabel - : AppTypography.connectTrafficLabel) - .copyWith( - color: ColorManager.palette(context).mutedStrong, - ), - ), - ), - ], - ), - SizedBox(height: desktop ? 10 : 5), - Text( - value, - textDirection: TextDirection.ltr, - style: desktop - ? AppTypography.metric - : AppTypography.connectTrafficValue, - ), - ], - ), - ), - ); } diff --git a/lib/pages/shared/widgets/page_empty_state.dart b/lib/pages/shared/widgets/page_empty_state.dart index f15ab988f..f566f929b 100644 --- a/lib/pages/shared/widgets/page_empty_state.dart +++ b/lib/pages/shared/widgets/page_empty_state.dart @@ -43,7 +43,7 @@ class PageEmptyState extends StatelessWidget { padding: padding, child: ConstrainedBox( constraints: BoxConstraints( - minHeight: mobile + minHeight: mobile && constraints.hasBoundedHeight ? (constraints.maxHeight - padding.vertical).clamp( 0.0, double.infinity, diff --git a/lib/service/connect/ordinary_plan.dart b/lib/service/connect/ordinary_plan.dart index b473d875b..b27c7002e 100644 --- a/lib/service/connect/ordinary_plan.dart +++ b/lib/service/connect/ordinary_plan.dart @@ -76,7 +76,7 @@ class OrdinaryPlanService { ? current.connection.rememberedOrdinarySelection : selection; final valid = candidate != null && await _ordinary(candidate); - final restored = valid ? candidate! : const ServerSelection.automatic(); + final restored = valid ? candidate : const ServerSelection.automatic(); return OrdinaryPlanRestore( ConnectionConfiguration( connection: ConnectionSettings.fromJson({ diff --git a/lib/service/connect/preparation.dart b/lib/service/connect/preparation.dart index 7c207d2c5..d39e4928f 100644 --- a/lib/service/connect/preparation.dart +++ b/lib/service/connect/preparation.dart @@ -199,18 +199,24 @@ class ConnectionPreparation { .ensureOutboundInterface(policy.xrayOutboundInterfaceName); final tun = policy.toTun(platform); String? raw = rawDraft; + String? rawName; RoutingConfiguration? custom = customDraft; - if (settings.expert && raw == null) { + if (settings.expert) { final row = settings.rawId == null ? null : await db.coreConfigDao.searchRow(settings.rawId!); - if (row == null || row.type != 'raw') { - throw const FormatException('Raw configuration is unavailable'); + if (row != null && row.type == 'raw') { + rawName = row.name; } - if (row.data == null) { - throw const FormatException('Raw configuration is empty'); + if (raw == null) { + if (row == null || row.type != 'raw') { + throw const FormatException('Raw configuration is unavailable'); + } + if (row.data == null) { + throw const FormatException('Raw configuration is empty'); + } + raw = utf8.decode(base64Decode(row.data!)); } - raw = utf8.decode(base64Decode(row.data!)); } else if (profile == null && !settings.expert && settings.trafficMode == TrafficMode.custom && @@ -432,6 +438,7 @@ class ConnectionPreparation { platform: platform, request: request, notice: notice, + rawName: rawName, selectionFallback: selectionFallback, ); } diff --git a/lib/service/connect/raw/db.dart b/lib/service/connect/raw/db.dart index 0980d48c3..243ab7cff 100644 --- a/lib/service/connect/raw/db.dart +++ b/lib/service/connect/raw/db.dart @@ -7,6 +7,21 @@ import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/core/tools/empty.dart'; class XrayRawDb { + /// Selection reads the stored asset strictly, independently of editor recovery. + /// This checks readability only; Core validation remains the save/start path. + static String readSelectionFromDbData(CoreConfigData config) { + if (config.type != CoreConfigType.raw.name || + config.data == null || + config.data!.isEmpty) { + throw const FormatException('Raw configuration is unavailable'); + } + final text = utf8.decode(base64Decode(config.data!)); + if (jsonDecode(text) is! Map) { + throw const FormatException('Raw configuration must be a JSON object'); + } + return text; + } + static String readFromDbData(CoreConfigData config) { if (EmptyTool.checkString(config.data)) { final bytes = base64Decode(config.data!); diff --git a/lib/service/connect/raw/editor.dart b/lib/service/connect/raw/editor.dart index d17b6508e..cb8865d0b 100644 --- a/lib/service/connect/raw/editor.dart +++ b/lib/service/connect/raw/editor.dart @@ -68,6 +68,13 @@ class RawEditorService { ); } + /// Called before selection confirmation and again inside its asset commit. + Future validateSelection(int id) async { + final row = await db.coreConfigDao.searchRow(id); + if (row == null) throw const RawEditorException('missing'); + XrayRawDb.readSelectionFromDbData(row); + } + /// Null means the user declined reconnection. No asset is written before it. Future save( RawEditorDraft draft, { diff --git a/lib/service/connect/runtime.dart b/lib/service/connect/runtime.dart index 61351c3bd..98e169c66 100644 --- a/lib/service/connect/runtime.dart +++ b/lib/service/connect/runtime.dart @@ -150,6 +150,9 @@ class ConnectionRuntime { final List entries; final RuntimeNode? finalExit; final RuntimeProfile? profile; + + /// Personal Raw identity at preparation time, independent of later row names. + final String? rawName; final String? notice; final RuntimeSelectionFallback? selectionFallback; final DateTime startedAt; @@ -170,6 +173,7 @@ class ConnectionRuntime { required this.entries, required this.finalExit, this.profile, + this.rawName, required this.startedAt, this.notice, this.selectionFallback, @@ -181,6 +185,7 @@ class ConnectionRuntime { required ConnectionPlatform platform, required StartVpnRequest request, String? notice, + String? rawName, RuntimeSelectionFallback? selectionFallback, DateTime? startedAt, }) { @@ -202,6 +207,7 @@ class ConnectionRuntime { 'entries': [for (final entry in entries) entry.toJson()], 'finalExit': finalExit?.toJson(), if (profile != null) 'profile': profile.toJson(), + 'rawName': ?rawName, if (selectionFallback != null) 'selectionFallback': selectionFallback.toJson(), }); @@ -221,6 +227,7 @@ class ConnectionRuntime { entries: List.unmodifiable(entries), finalExit: finalExit, profile: profile, + rawName: rawName, notice: notice, selectionFallback: selectionFallback, startedAt: startedAt, @@ -249,6 +256,7 @@ class ConnectionRuntime { metadata['finalExit'] is! Map) || (metadata['profile'] != null && metadata['profile'] is! Map) || + (metadata['rawName'] != null && metadata['rawName'] is! String) || (metadata['selectionFallback'] != null && metadata['selectionFallback'] is! Map)) { throw const FormatException('Invalid runtime metadata'); @@ -268,6 +276,7 @@ class ConnectionRuntime { metadata['startedAt'] as int, ), request: request, + rawName: metadata['rawName'] as String?, selectionFallback: metadata['selectionFallback'] == null ? null : RuntimeSelectionFallback.fromJson( diff --git a/test/pages/connect/configuration_sync_test.dart b/test/pages/connect/configuration_sync_test.dart index f6401b29f..8ccd216ad 100644 --- a/test/pages/connect/configuration_sync_test.dart +++ b/test/pages/connect/configuration_sync_test.dart @@ -13,6 +13,7 @@ import 'package:onexray/core/pigeon/messages.g.dart' show VpnStatus; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/controller.dart'; import 'package:onexray/pages/connect/view.dart'; +import 'package:onexray/pages/connect/target_picker.dart'; import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/compiler.dart'; @@ -142,8 +143,8 @@ class _Fixture { ..servers = await db.coreConfigDao.allActiveServerRows() ..ready = true; await connect.initialize(); - // Let the initial persisted configuration stream arrive before opening a - // local Raw browsing draft in the widget's zone. + // Let the initial persisted configuration stream arrive before opening the + // target picker's browsing state in the widget's zone. await db.connectionConfigDao.watch().first; if (!connect.servers.any((row) => row.id == 65) || connect.raws.isEmpty) { await connect.stream @@ -196,7 +197,7 @@ Widget _screen(ConnectController controller) => MaterialApp( onConnection: () => controller.connectionAction(context), onExpert: (value) => controller.toggleExpert(context, value), onAddServers: () {}, - onServer: () {}, + onServer: () => controller.chooseServer(context), onMethod: () {}, onWhy: () {}, onRawAdd: () {}, @@ -211,7 +212,7 @@ Widget _screen(ConnectController controller) => MaterialApp( void main() { for (final initiallyExpert in [true, false]) { testWidgets( - 'external provider selection leaves the Raw view and starts the chosen profile (persisted expert: $initiallyExpert)', + 'external provider selection replaces the planned target and starts its profile (Raw initially selected: $initiallyExpert)', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 844)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -225,20 +226,16 @@ void main() { await tester.pumpAndSettle(); final context = tester.element(find.byType(ConnectView)); final l = AppLocalizations.of(context)!; - if (!initiallyExpert) { - await tester.runAsync(() async { - final opened = fixture.connect.stream.firstWhere( - (state) => state.expertView, - ); - await fixture.connect.toggleExpert(context, true); - await opened.timeout(const Duration(seconds: 5)); - }); - } - await tester.pumpAndSettle(); - expect(fixture.connect.expertView, isTrue); expect( - tester.widget(find.byType(ShadSwitch)).value, - isTrue, + fixture.connect.configuration.connection.expert, + initiallyExpert, + ); + expect(find.byType(ShadSwitch), findsNothing); + expect( + find.text( + initiallyExpert ? 'Personal Raw' : l.prototypeAutomaticSelection, + ), + findsOneWidget, ); await tester.runAsync(() async { await fixture.servers.choose( @@ -267,10 +264,7 @@ void main() { }); await tester.pumpAndSettle(); expect(fixture.connect.expertView, isFalse); - expect( - tester.widget(find.byType(ShadSwitch)).value, - isFalse, - ); + expect(find.byType(ShadSwitch), findsNothing); expect(find.text('Provider profile'), findsOneWidget); await tester.runAsync(() async { final connected = fixture.connect.stream.firstWhere( @@ -300,7 +294,7 @@ void main() { } testWidgets( - 'unrelated persisted settings keep the uncommitted Raw browsing draft', + 'unrelated persisted settings preserve picker browsing; cancel does not commit its target', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 844)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -312,15 +306,21 @@ void main() { addTearDown(() => tester.pumpWidget(const SizedBox.shrink())); await tester.pumpWidget(_screen(fixture.connect)); await tester.pumpAndSettle(); - final context = tester.element(find.byType(ConnectView)); - await tester.runAsync(() async { - final opened = fixture.connect.stream.firstWhere( - (state) => state.expertView, - ); - await fixture.connect.toggleExpert(context, true); - await opened.timeout(const Duration(seconds: 5)); - }); + final l = AppLocalizations.of(tester.element(find.byType(ConnectView)))!; + await tester.tap(find.text(l.prototypeAutomaticSelection)); + await tester.pumpAndSettle(); + expect(find.byType(ConnectionTargetPicker), findsOneWidget); + await tester.tap( + find.widgetWithText(ChoiceChip, l.serverCompleteConfigurationsTab), + ); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('connection-target-search')), + 'Personal', + ); await tester.pumpAndSettle(); + expect(find.text('Personal Raw'), findsOneWidget); + expect(fixture.connect.configuration.connection.expert, isFalse); await tester.runAsync(() async { final before = await fixture.coordinator.configuration; expect(before.connection.expert, isFalse); @@ -349,8 +349,37 @@ void main() { expect(saved.connection.selection.kind, SelectionKind.automatic); }); await tester.pumpAndSettle(); - expect(fixture.connect.expertView, isTrue); - expect(tester.widget(find.byType(ShadSwitch)).value, isTrue); + expect(find.byType(ConnectionTargetPicker), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('connection-target-search')), + ) + .controller + ?.text ?? + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('connection-target-search')), + matching: find.byType(EditableText), + ), + ) + .controller + .text, + 'Personal', + ); + expect(find.text('Personal Raw'), findsOneWidget); + await tester.tap(find.widgetWithText(ShadButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(find.byType(ConnectionTargetPicker), findsNothing); + expect(fixture.connect.expertView, isFalse); + expect(fixture.connect.configuration.connection.expert, isFalse); + expect( + fixture.connect.configuration.connection.selection.kind, + SelectionKind.automatic, + ); + expect(fixture.connect.configuration.policy.ipv6Enabled, isFalse); + expect(fixture.connect.configuration.connection.smart.blockAds, isTrue); expect(fixture.started, isEmpty); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox.shrink()); diff --git a/test/pages/connect/controller_test.dart b/test/pages/connect/controller_test.dart index 9813dccff..3345bec75 100644 --- a/test/pages/connect/controller_test.dart +++ b/test/pages/connect/controller_test.dart @@ -19,6 +19,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/connect/controller.dart'; import 'package:onexray/pages/connect/view.dart'; +import 'package:onexray/pages/connect/target_picker.dart'; import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/pages/connect/routing/smart/exit_picker_controller.dart'; @@ -101,10 +102,6 @@ void main() { builder: (context) => Scaffold( body: Column( children: [ - TextButton( - onPressed: () => controller.chooseTrafficMethod(context), - child: const Text('methods'), - ), TextButton( onPressed: () => controller.showWhy(context), child: const Text('why'), @@ -130,9 +127,6 @@ void main() { expect(controller.methodDescription(l), l.providerRoutingNotice); expect(controller.selectionHealth(l), isNull); expect(controller.runningRoute, isNull); - await tester.tap(find.text('methods')); - await tester.pumpAndSettle(); - expect(find.text(l.prototypeChooseTrafficMethod), findsNothing); await tester.tap(find.text('change-method')); await tester.pumpAndSettle(); expect(changed, isFalse); @@ -149,7 +143,8 @@ void main() { ); controller.sources = await db.subscriptionDao.allRows; controller.servers = []; - expect(controller.selectionTitle(l), 'Provider profile'); + expect(controller.selectionTitle(l), l.prototypeTemporarilyUnavailable); + expect(controller.runningProfile?.name, 'Provider profile'); expect(controller.providerConfiguration, isTrue); expect(controller.runningRoute, isNull); await tester.tap(find.text('why')); @@ -178,8 +173,17 @@ void main() { ); expect(controller.selectedProvider, isNull); expect(controller.providerConfiguration, isTrue); - expect(controller.selectionTitle(l), row.name); - expect(controller.selectionDetail(l), l.providerRoutingNotice); + expect( + controller.selectionTitle(l), + selection.kind == SelectionKind.automatic + ? l.prototypeAutomaticSelection + : 'Provider source', + ); + expect( + controller.selectionDetail(l), + '${l.connectionCandidates(0)} · ${l.connectionRequiredEntries(3)}', + ); + expect(controller.runningProfile?.name, row.name); expect(controller.homeMethodTitle(l), l.providerConfigurationTitle); expect(controller.selectionHealth(l), isNull); expect(controller.runningRoute, isNull); @@ -196,9 +200,9 @@ void main() { test('updated failure counts rebuild the connection status', () { final before = ConnectPageState( connectionView: const ConnectionView( - issue: 'insufficientHealthyServers', + issue: 'insufficientCandidates', error: ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 0, ), @@ -206,9 +210,9 @@ void main() { ); final after = before.copyWith( connectionView: const ConnectionView( - issue: 'insufficientHealthyServers', + issue: 'insufficientCandidates', error: ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ), @@ -217,6 +221,56 @@ void main() { expect(before.sameContentAs(after), isFalse); }); + testWidgets( + 'scope fallback uses frozen source and runtime identities only after connection', + (tester) async { + final coordinator = _Coordinator(); + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = ConnectionConfiguration(); + addTearDown(controller.close); + addTearDown(coordinator.dispose); + addTearDown(coordinator.db.close); + await tester.pumpWidget(_testApp(const Scaffold(body: SizedBox()))); + final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; + final runtime = _runtime( + selectionFallback: const RuntimeSelectionFallback( + originalSelection: ServerSelection.source(7), + reason: 'selectionUnavailable', + sourceName: 'Source when connected', + ), + ); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.preparing, + runtime: runtime, + ); + expect(controller.fallbackMessage(l), isNull); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: runtime, + ); + controller.sources = const []; + expect( + controller.fallbackMessage(l), + l.connectionScopeFallback( + 'Source when connected', + 'Singapore 03 + Japan 02', + ), + ); + expect( + controller.configuration.connection.selection.kind, + SelectionKind.automatic, + ); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.disconnected, + runtime: runtime, + ); + expect(controller.fallbackMessage(l), isNull); + expect(coordinator.applyCount, 0); + }, + ); + test('metrics updates leave non-traffic connection content unchanged', () { final before = ConnectPageState( connectionView: const ConnectionView(phase: ConnectionPhase.connected), @@ -437,11 +491,11 @@ void main() { await tester.pumpWidget(_testApp(const Scaffold(body: SizedBox()))); final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; coordinator.state.value = const ConnectionView(); + expect(controller.selectionTitle(l), l.prototypeAutomaticSelection); expect( - controller.selectionTitle(l), - 'Automatic selection · 3 entry nodes', + controller.selectionDetail(l), + '${l.connectionCandidates(0)} · ${l.connectionRequiredEntries(3)}', ); - expect(controller.selectionDetail(l), 'Choose by speed and availability'); expect(controller.homeMethodTitle(l), 'Smart Routing (recommended)'); expect(controller.selectionHealth(l), isNull); coordinator.state.value = ConnectionView( @@ -458,6 +512,11 @@ void main() { delay: 42, subId: 0, favorite: false, + data: base64Encode( + utf8.encode( + '{"outbounds":[{"protocol":"socks","settings":{"servers":[{"address":"one.example","port":1080}]}}]}', + ), + ), ), CoreConfigData( manualSortOrder: 0, @@ -468,12 +527,14 @@ void main() { delay: 1, subId: 0, favorite: false, + data: base64Encode( + utf8.encode( + '{"outbounds":[{"protocol":"socks","settings":{"servers":[{"address":"two.example","port":1080}]}}]}', + ), + ), ), ]; - expect( - controller.selectionTitle(l), - 'Automatic selection · 2 entry nodes', - ); + expect(controller.selectionTitle(l), l.prototypeAutomaticSelection); expect(controller.selectionHealth(l), 'Fast · 42 ms'); final running = controller.servers.first; for (final (delay, label) in [ @@ -494,25 +555,29 @@ void main() { expect(controller.selectionHealth(l), label); } expect( - controller.selectionDetail(l), + controller.runningRoute?.path, 'Singapore 03 + Japan 02 → United States 01', ); + expect( + controller.selectionDetail(l), + '${l.connectionCandidates(2)} · ${l.connectionRequiredEntries(3)}', + ); controller.servers = controller.servers.sublist(1); expect(controller.selectionHealth(l), isNull); }, ); testWidgets( - 'connect actions keep editors, server browsing and groups in the source tab', + 'routing uses its page and target picker cancellation does not commit; management stays scoped', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 844)); addTearDown(() => tester.binding.setSurfaceSize(null)); final coordinator = _Coordinator()..saved = ConnectionConfiguration(); coordinator.state.value = const ConnectionView(); - final controller = - ConnectController(database: coordinator.db, coordinator: coordinator) - ..configuration = coordinator.saved - ..expertView = true; + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = coordinator.saved; final servers = ServersController( database: coordinator.db, coordinator: coordinator, @@ -526,10 +591,6 @@ void main() { body: Column( children: [ const Text('connection-home'), - TextButton( - onPressed: () => controller.connectionAction(context), - child: const Text('connect-action'), - ), TextButton( onPressed: () => controller.chooseTrafficMethod(context), child: const Text('methods-action'), @@ -543,8 +604,19 @@ void main() { ), routes: [ GoRoute( - path: 'raw-editor', - builder: (_, _) => const Scaffold(body: Text('raw-editor')), + path: 'routing', + builder: (context, _) => Scaffold( + body: Column( + children: [ + const Text('routing-home'), + TextButton( + onPressed: () => + context.pushScoped(AppPageDestination.smartRouting), + child: const Text('edit-smart'), + ), + ], + ), + ), ), GoRoute( path: 'smart-routing', @@ -595,30 +667,35 @@ void main() { ), ); await tester.pumpAndSettle(); - await tester.tap(find.text('connect-action')); - await tester.pumpAndSettle(); - expect(find.text('raw-editor'), findsOneWidget); - router.pop(); - await tester.pumpAndSettle(); - controller.expertView = false; await tester.tap(find.text('methods-action')); await tester.pumpAndSettle(); - expect(find.text('Choose a traffic method'), findsOneWidget); - await tester.tap(find.text('Edit')); + expect(find.text('routing-home'), findsOneWidget); + expect( + GoRouterState.of(tester.element(find.text('routing-home'))).uri.path, + '/connect/routing', + ); + await tester.tap(find.text('edit-smart')); await tester.pumpAndSettle(); expect(find.text('smart-editor'), findsOneWidget); router.pop(); await tester.pumpAndSettle(); + expect(find.text('routing-home'), findsOneWidget); + router.pop(); + await tester.pumpAndSettle(); expect(find.text('connection-home'), findsOneWidget); - expect(find.text('Choose a traffic method'), findsNothing); - // The backdrop remains a dismiss target outside the compact dialog. - await tester.tap(find.text('methods-action')); + final before = coordinator.saved.encode(); + await tester.tap(find.text('location-action')); await tester.pumpAndSettle(); - await tester.tapAt(const Offset(8, 8)); + expect(find.byType(ConnectionTargetPicker), findsOneWidget); + await tester.tap(find.widgetWithText(ShadButton, 'Cancel')); await tester.pumpAndSettle(); - expect(find.text('Choose a traffic method'), findsNothing); + expect(coordinator.saved.encode(), before); + expect(coordinator.applyCount, 0); + expect(router.canPop(), isFalse); await tester.tap(find.text('location-action')); await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(OutlinedButton, 'Servers')); + await tester.pumpAndSettle(); expect( GoRouterState.of(tester.element(find.text('servers-browser'))).uri.path, '/connect/servers', @@ -646,48 +723,107 @@ void main() { ); for (final fail in [false, true]) { - testWidgets('leaving Raw follows the committed result; failure=$fail', ( - tester, - ) async { - final coordinator = _Coordinator(fail: fail); - final controller = - ConnectController(database: coordinator.db, coordinator: coordinator) - ..expertView = true - ..configuration = coordinator.saved; + testWidgets( + 'choosing ordinary scope from Raw follows the committed result; failure=$fail', + (tester) async { + final coordinator = _Coordinator(fail: fail); + final controller = + ConnectController( + database: coordinator.db, + coordinator: coordinator, + ) + ..expertView = true + ..configuration = coordinator.saved; + addTearDown(controller.close); + addTearDown(coordinator.dispose); + addTearDown(coordinator.db.close); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + home: const Scaffold(body: SizedBox()), + ), + ); + final context = tester.element(find.byType(Scaffold)); + final changing = controller.chooseServer(context); + await tester.pumpAndSettle(); + final l = AppLocalizations.of(context)!; + await tester.tap( + find.widgetWithText(ChoiceChip, l.serverSelectionAutomaticScope), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(ListTile, l.prototypeManualAdditions), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Apply and reconnect')); + await tester.pumpAndSettle(); + await changing; + expect(controller.expertView, fail); + expect(coordinator.saved.connection.expert, fail); + if (!fail) { + expect(controller.configuration.encode(), coordinator.saved.encode()); + expect( + controller.configuration.connection.selection.kind, + SelectionKind.automatic, + ); + expect(coordinator.state.value.issue, 'selectionReset'); + } + }, + ); + } + + testWidgets( + 'cancelling target reconnection keeps the saved plan and running snapshot', + (tester) async { + final coordinator = _Coordinator(); + final runtime = _runtime(expert: true); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: runtime, + ); + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = coordinator.saved; addTearDown(controller.close); addTearDown(coordinator.dispose); addTearDown(coordinator.db.close); await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light, - locale: const Locale('en'), - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - builder: (_, child) => ShadTheme( - data: AppTheme.shad(Brightness.light), - child: ShadToaster(child: child!), - ), - home: const Scaffold(body: SizedBox()), - ), + _testApp(ShadToaster(child: const Scaffold(body: SizedBox()))), ); final context = tester.element(find.byType(Scaffold)); - final changing = controller.toggleExpert(context, false); + final l = AppLocalizations.of(context)!; + final before = coordinator.saved.encode(); + final choosing = controller.chooseServer(context); await tester.pumpAndSettle(); - await tester.tap(find.text('Apply and reconnect')); + await tester.tap( + find.widgetWithText(ChoiceChip, l.serverSelectionAutomaticScope), + ); await tester.pumpAndSettle(); - await changing; - expect(controller.expertView, fail); - expect(coordinator.saved.connection.expert, fail); - if (!fail) { - expect(controller.configuration.encode(), coordinator.saved.encode()); - expect( - controller.configuration.connection.selection.kind, - SelectionKind.automatic, - ); - expect(coordinator.state.value.issue, 'selectionReset'); - } - }); - } + await tester.tap( + find.widgetWithText(ListTile, l.prototypeManualAdditions), + ); + await tester.pumpAndSettle(); + expect(coordinator.applyCount, 0); + await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + await choosing; + expect(coordinator.saved.encode(), before); + expect(controller.configuration.encode(), before); + expect(coordinator.state.value.phase, ConnectionPhase.connected); + expect(coordinator.state.value.runtime, same(runtime)); + expect(controller.pendingChange, isNull); + expect(coordinator.applyCount, 0); + expect(tester.takeException(), isNull); + }, + ); test('running path keeps all frozen names and is absent for Raw or stopped sessions', () async { final coordinator = _Coordinator(); @@ -1171,6 +1307,7 @@ ConnectionRuntime _runtime({ bool expert = false, ResolvedProfile? profile, ServerSelection? selection, + RuntimeSelectionFallback? selectionFallback, }) { final configuration = ConnectionConfiguration( connection: ConnectionSettings( @@ -1193,6 +1330,7 @@ ConnectionRuntime _runtime({ ); return ConnectionRuntime.create( configuration: configuration, + selectionFallback: selectionFallback, compiled: CompiledConnection( xrayJson: '{}', entries: profile == null diff --git a/test/pages/connect/raw_editor_test.dart b/test/pages/connect/raw_editor_test.dart index 83099ddb3..dc038c1bf 100644 --- a/test/pages/connect/raw_editor_test.dart +++ b/test/pages/connect/raw_editor_test.dart @@ -11,6 +11,8 @@ import 'package:onexray/core/errors/failure.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/connect/json_editor/controller.dart'; +import 'package:onexray/pages/connect/json_editor/page.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/json_editor.dart'; import 'package:onexray/service/connect/raw/editor.dart'; @@ -200,6 +202,142 @@ void main() { for (final advanced in [false, true]) { final label = advanced ? 'advanced' : 'Raw'; + for (final restored in [false, true]) { + testWidgets( + '$label clean or manually restored document exits without confirmation: $restored', + (tester) async { + final fixture = _DiagnosticFixture(advanced); + addTearDown(fixture.dispose); + final session = await _mountFullEditor(tester, fixture); + final controller = fixture.controller; + final initialName = controller.name.text; + final initialText = controller.text.text; + expect(controller.state.dirty, isFalse); + controller.text.selection = const CodeLineSelection.collapsed( + index: 0, + offset: 0, + ); + expect(controller.state.dirty, isFalse); + if (restored) { + controller.name.text = 'Temporary'; + controller.text.text = '$initialText\n'; + expect(controller.state.dirty, isTrue); + controller.name.text = initialName; + controller.text.text = initialText; + expect(controller.state.dirty, isFalse); + } + await tester.pumpAndSettle(); + if (restored) { + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + } else { + await tester.tap(find.byType(BackButton)); + } + await tester.pumpAndSettle(); + expect(find.byType(AppConfirmationDialog), findsNothing); + expect(find.byType(JsonConfigurationEditorPage), findsNothing); + expect(await session.result.future, isNull); + expect(fixture.raw.submitted, isEmpty); + expect(fixture.custom.submitted, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final exit in ['close', 'cancel', 'back', 'escape']) { + testWidgets( + '$label modified document shares its discard guard through $exit', + (tester) async { + final fixture = _DiagnosticFixture(advanced); + addTearDown(fixture.dispose); + final session = await _mountFullEditor(tester, fixture); + final controller = fixture.controller; + final l = AppLocalizations.of(session.context)!; + controller.name.text = 'Unsaved'; + controller.text.text = '{"outbounds":[{}]}'; + await tester.pumpAndSettle(); + final text = controller.text.text; + await _requestFullEditorExit(tester, exit); + expect(find.byType(AppConfirmationDialog), findsOneWidget); + expect(controller.state.confirmingClose, isTrue); + await controller.closePage(session.context); + await tester.pump(); + expect(find.byType(AppConfirmationDialog), findsOneWidget); + await tester.tap(find.text(l.jsonEditorKeepEditing)); + await tester.pumpAndSettle(); + expect(find.byType(JsonConfigurationEditorPage), findsOneWidget); + expect(controller.name.text, 'Unsaved'); + expect(controller.text.text, text); + expect(controller.state.dirty, isTrue); + expect(session.result.isCompleted, isFalse); + await _requestFullEditorExit(tester, exit); + await tester.tap(find.text(l.jsonEditorDiscardChanges)); + await tester.pumpAndSettle(); + expect(await session.result.future, isNull); + expect(find.byType(JsonConfigurationEditorPage), findsNothing); + expect(fixture.raw.submitted, isEmpty); + expect(fixture.custom.submitted, isEmpty); + expect(await fixture.db.coreConfigDao.allRawRowsWithData, isEmpty); + expect(await fixture.db.routingProfileDao.allRows, isEmpty); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + '$label saving blocks all exits and a failed save retains its dirty draft', + (tester) async { + final fixture = _DiagnosticFixture(advanced); + addTearDown(fixture.dispose); + final session = await _mountFullEditor(tester, fixture); + final controller = fixture.controller; + final l = AppLocalizations.of(session.context)!; + controller.name.text = 'Submitted'; + controller.text.text = '{"outbounds":[{}]}'; + final completion = Completer(); + fixture.onSave(() => completion.future); + final saving = controller.save(session.context); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.state.canPop, isFalse); + for (final exit in ['close', 'cancel', 'back', 'escape']) { + await _requestFullEditorExit(tester, exit, settle: false); + expect(find.byType(AppConfirmationDialog), findsNothing); + expect(find.byType(JsonConfigurationEditorPage), findsOneWidget); + expect(session.result.isCompleted, isFalse); + } + completion.completeError( + const FormatException('Configuration rejected'), + ); + await saving; + await tester.pumpAndSettle(); + expect(controller.error, contains('Configuration rejected')); + expect(controller.state.dirty, isTrue); + expect(controller.state.closeAllowed, isFalse); + await _requestFullEditorExit(tester, 'back'); + await tester.tap(find.text(l.jsonEditorDiscardChanges)); + await tester.pumpAndSettle(); + expect(await session.result.future, isNull); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + '$label successful save returns directly without asking to discard', + (tester) async { + final fixture = _DiagnosticFixture(advanced); + addTearDown(fixture.dispose); + final session = await _mountFullEditor(tester, fixture); + fixture.controller.name.text = 'Submitted'; + fixture.controller.text.text = '{"outbounds":[{}]}'; + fixture.onSave(() async => 7); + await fixture.controller.save(session.context); + await tester.pumpAndSettle(); + expect(await session.result.future, 7); + expect(find.byType(JsonConfigurationEditorPage), findsNothing); + expect(find.byType(AppConfirmationDialog), findsNothing); + expect(fixture.controller.state.dirty, isFalse); + expect(tester.takeException(), isNull); + }, + ); testWidgets( '$label releases committed Geodata imports before saving newer edits', (tester) async { @@ -443,6 +581,9 @@ void main() { expect(find.text('Diagnostic draft'), findsOneWidget); expect(controller.text.text, newer); expect(controller.state.text, newer); + expect(controller.state.savedText, submitted); + expect(controller.state.savedName, 'Submitted'); + expect(controller.state.dirty, isTrue); expect(controller.error, isNull); expect(controller.canSave, true); @@ -469,7 +610,7 @@ void main() { } class _DiagnosticFixture { - _DiagnosticFixture(bool advanced, {ConfigurationTransferService? transfer}) { + _DiagnosticFixture(this.advanced, {ConfigurationTransferService? transfer}) { coordinator = ConnectionCoordinator(database: db); raw = _DiagnosticRawSave(database: db, coordinator: coordinator); custom = _DiagnosticAdvancedSave(database: db, coordinator: coordinator); @@ -482,6 +623,7 @@ class _DiagnosticFixture { ); } final db = AppDatabase.forTesting(NativeDatabase.memory()); + final bool advanced; late final ConnectionCoordinator coordinator; late final _DiagnosticRawSave raw; late final _DiagnosticAdvancedSave custom; @@ -503,6 +645,79 @@ class _DiagnosticFixture { } } +Future<({BuildContext context, Completer result})> _mountFullEditor( + WidgetTester tester, + _DiagnosticFixture fixture, +) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 1000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final result = Completer(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async => result.complete( + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => JsonConfigurationEditorPage( + kind: fixture.advanced + ? ConfigurationKind.customAdvanced + : ConfigurationKind.raw, + createController: () => fixture.controller, + ), + ), + ), + ), + child: const Text('Open full editor'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open full editor')); + await tester.pumpAndSettle(); + return ( + context: tester.element(find.byType(JsonConfigurationEditorPage)), + result: result, + ); +} + +Future _requestFullEditorExit( + WidgetTester tester, + String exit, { + bool settle = true, +}) async { + final l = AppLocalizations.of( + tester.element(find.byType(JsonConfigurationEditorPage)), + )!; + switch (exit) { + case 'close': + await tester.tap(find.byType(BackButton)); + case 'cancel': + await tester.tap(find.widgetWithText(ShadButton, l.prototypeCancel)); + case 'back': + await tester.binding.handlePopRoute(); + case 'escape': + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + } + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(const Duration(milliseconds: 100)); + } +} + class _DiagnosticRawSave extends RawEditorService { _DiagnosticRawSave({required super.database, required super.coordinator}); RawEditorDraft current = const RawEditorDraft(name: '', text: '{}'); diff --git a/test/pages/connect/target_picker_test.dart b/test/pages/connect/target_picker_test.dart new file mode 100644 index 000000000..16489ed22 --- /dev/null +++ b/test/pages/connect/target_picker_test.dart @@ -0,0 +1,389 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/constants.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/l10n/localizations/app_localizations_en.dart'; +import 'package:onexray/pages/connect/target_picker.dart'; +import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/servers/catalog.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadButton, ShadTheme; + +void main() { + final l = AppLocalizationsEn(); + final sources = [ + _source(7, 'Duplicate'), + _source(11, 'Provider source', provider: true), + ]; + final rows = [ + _node(1, 'Measured', 30, sourceId: 7), + _node(2, 'Unmeasured', PingDelayConstants.unknown), + _node(3, 'Failed probe', PingDelayConstants.error), + _node(4, 'Timed out', PingDelayConstants.timeout), + _node(6, 'Final exit', 1), + _node( + 7, + 'Duplicate', + PingDelayConstants.unknown, + sourceId: 11, + type: 'Remnawave', + ), + _node(8, 'Inactive provider cache', 1, sourceId: 7, type: 'Remnawave'), + _node(9, 'Inactive ordinary cache', 1, sourceId: 11), + CoreConfigData( + id: 10, + name: 'Unreadable', + type: 'outbound', + tags: '', + delay: 1, + subId: 0, + manualSortOrder: 0, + favorite: false, + ), + ]; + final catalog = ServerCatalog(servers: rows, sources: sources); + final raws = List.generate( + 4, + (i) => _node( + 40 + i, + i == 0 ? 'Duplicate' : 'Legacy Raw ${i + 1}', + PingDelayConstants.unknown, + type: 'raw', + ), + ); + ConnectionTargetChoice? choice; + var completions = 0; + + String scopeName(ServerSelection scope) => switch (scope.kind) { + SelectionKind.automatic => l.prototypeAutomaticSelection, + SelectionKind.source => + scope.id == 0 ? l.prototypeManualAdditions : 'Duplicate', + SelectionKind.server => 'Server ${scope.id}', + }; + + Future open(WidgetTester tester, ConnectionSettings settings) async { + await tester.binding.setSurfaceSize(const Size(960, 1100)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + choice = null; + completions = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (context, child) => + ShadTheme(data: AppTheme.shad(Brightness.light), child: child!), + home: Builder( + builder: (context) => Scaffold( + body: FilledButton( + onPressed: () async { + choice = await showAppDialog( + context, + (_) => ConnectionTargetPicker( + settings: settings, + catalog: catalog, + raws: raws, + candidateCount: (scope) => catalog.servers + .where( + (row) => + row.id != settings.finalExitId && + catalog.outboundEligible(row) && + (scope.kind == SelectionKind.automatic || + row.subId == scope.id), + ) + .length, + selectionName: scopeName, + ), + ); + completions++; + }, + child: const Text('Choose target'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Choose target')); + await tester.pumpAndSettle(); + } + + Finder tile(String title, {String? subtitle}) => find.byWidgetPredicate( + (widget) => + widget is ListTile && + widget.title is Text && + (widget.title! as Text).data == title && + (subtitle == null || + widget.subtitle is Text && + (widget.subtitle! as Text).data == subtitle), + ); + + bool isSelected(WidgetTester tester, Finder row) => + tester + .widget( + find.ancestor(of: row, matching: find.byType(Semantics)).first, + ) + .properties + .selected == + true; + + Future tab(WidgetTester tester, String title) async { + await tester.tap(find.widgetWithText(ChoiceChip, title)); + await tester.pumpAndSettle(); + } + + Future reveal(WidgetTester tester, Finder row) async { + final lists = find.byKey(const ValueKey('connection-target-options')); + if (lists.evaluate().isEmpty) { + await tester.ensureVisible(row); + } else { + final scrollable = find + .descendant(of: lists.first, matching: find.byType(Scrollable)) + .first; + tester.state(scrollable).position.jumpTo(0); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + row, + 80, + scrollable: scrollable, + maxScrolls: 30, + ); + } + await tester.pumpAndSettle(); + } + + testWidgets( + 'source ID collisions keep automatic scope selected by kind and ID', + (tester) async { + final settings = ConnectionSettings( + selection: const ServerSelection.source(7), + smart: SmartRoutingSettings(finalExitId: 6), + ); + final before = settings.toJson(); + await open(tester, settings); + expect( + tester + .widget( + find.widgetWithText(ChoiceChip, l.serverSelectionAutomaticScope), + ) + .selected, + isTrue, + ); + expect(isSelected(tester, tile('Duplicate')), isTrue); + expect(find.text(l.connectionCandidates(4)), findsOneWidget); + expect(find.text(l.connectionCandidates(3)), findsOneWidget); + expect(find.text(l.connectionCandidates(1)), findsOneWidget); + await tab(tester, l.serverCompleteConfigurationsTab); + await reveal( + tester, + tile('Duplicate', subtitle: l.providerConfigurationTitle), + ); + expect( + isSelected( + tester, + tile('Duplicate', subtitle: l.providerConfigurationTitle), + ), + isFalse, + ); + await reveal(tester, tile('Duplicate', subtitle: l.xrayRawPageTitle)); + expect( + isSelected(tester, tile('Duplicate', subtitle: l.xrayRawPageTitle)), + isFalse, + ); + expect(completions, 0); + expect(settings.toJson(), before); + await tester.tap(find.widgetWithText(ShadButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(choice, isNull); + expect(completions, 1); + expect(settings.toJson(), before); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'fixed choices include failed or unmeasured nodes, excluding final exit and inactive caches', + (tester) async { + final settings = ConnectionSettings( + selection: const ServerSelection.server(3), + smart: SmartRoutingSettings(finalExitId: 6), + ); + await open(tester, settings); + for (final title in [ + 'Measured', + 'Unmeasured', + 'Failed probe', + 'Timed out', + ]) { + await reveal(tester, tile(title)); + expect(tile(title), findsOneWidget); + } + for (final title in [ + 'Final exit', + 'Inactive provider cache', + 'Inactive ordinary cache', + 'Unreadable', + ]) { + await tester.enterText( + find.byKey(const ValueKey('connection-target-search')), + title, + ); + await tester.pumpAndSettle(); + expect(tile(title), findsNothing); + expect(find.text(l.serverNoMatchingResources), findsOneWidget); + } + await tester.enterText( + find.byKey(const ValueKey('connection-target-search')), + '', + ); + await tester.pumpAndSettle(); + await reveal(tester, tile('Failed probe')); + expect(isSelected(tester, tile('Failed probe')), isTrue); + expect(isSelected(tester, tile('Unmeasured')), isFalse); + await reveal(tester, tile('Unmeasured')); + await tester.tap(tile('Unmeasured')); + await tester.pumpAndSettle(); + expect(choice?.selection?.kind, SelectionKind.server); + expect(choice?.selection?.id, 2); + expect(choice?.rawId, isNull); + expect(settings.selection.id, 3); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Raw and provider choices with equal names retain separate resource identities', + (tester) async { + final settings = ConnectionSettings( + expert: true, + rawId: 40, + selection: const ServerSelection.server(7), + ); + await open(tester, settings); + final raw = tile('Duplicate', subtitle: l.xrayRawPageTitle); + final provider = tile( + 'Duplicate', + subtitle: l.providerConfigurationTitle, + ); + await reveal(tester, raw); + expect(isSelected(tester, raw), isTrue); + await reveal(tester, provider); + expect(isSelected(tester, provider), isFalse); + for (final row in raws) { + await reveal(tester, tile(row.name, subtitle: l.xrayRawPageTitle)); + expect(tile(row.name, subtitle: l.xrayRawPageTitle), findsOneWidget); + } + await reveal(tester, provider); + await tester.tap(provider); + await tester.pumpAndSettle(); + expect(choice?.selection?.kind, SelectionKind.server); + expect(choice?.selection?.id, 7); + expect(choice?.rawId, isNull); + expect(settings.expert, isTrue); + expect(settings.rawId, 40); + + await open( + tester, + ConnectionSettings(selection: const ServerSelection.server(7)), + ); + await reveal( + tester, + tile('Duplicate', subtitle: l.providerConfigurationTitle), + ); + expect( + isSelected( + tester, + tile('Duplicate', subtitle: l.providerConfigurationTitle), + ), + isTrue, + ); + await reveal(tester, tile('Duplicate', subtitle: l.xrayRawPageTitle)); + expect( + isSelected(tester, tile('Duplicate', subtitle: l.xrayRawPageTitle)), + isFalse, + ); + await tester.tap(tile('Duplicate', subtitle: l.xrayRawPageTitle)); + await tester.pumpAndSettle(); + expect(choice?.rawId, 40); + expect(choice?.selection, isNull); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'search and tab changes stay local; restore and management are explicit intents', + (tester) async { + final settings = ConnectionSettings(expert: true, rawId: 40); + final before = settings.toJson(); + await open(tester, settings); + await tester.enterText( + find.byKey(const ValueKey('connection-target-search')), + 'legacy raw 3', + ); + await tester.pumpAndSettle(); + expect(tile('Legacy Raw 3'), findsOneWidget); + expect(tile('Duplicate', subtitle: l.xrayRawPageTitle), findsNothing); + expect(completions, 0); + await tester.enterText( + find.byKey(const ValueKey('connection-target-search')), + '', + ); + await tab(tester, l.serverSelectionAutomaticScope); + await tester.tap(find.text(l.connectionRestoreOrdinary)); + await tester.pumpAndSettle(); + expect(choice?.restore, isTrue); + expect(choice?.selection, isNull); + expect(settings.toJson(), before); + await open(tester, settings); + await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeServers)); + await tester.pumpAndSettle(); + expect(choice?.manage, isTrue); + expect(choice?.rawId, isNull); + expect(choice?.selection, isNull); + expect(settings.toJson(), before); + expect(tester.takeException(), isNull); + }, + ); +} + +CoreConfigData _node( + int id, + String name, + int delay, { + int sourceId = 0, + String type = 'outbound', +}) => CoreConfigData( + id: id, + name: name, + type: type, + tags: '', + delay: delay, + subId: sourceId, + manualSortOrder: 0, + favorite: false, + data: base64Encode( + utf8.encode( + jsonEncode({ + 'outbounds': [ + {'tag': name, 'protocol': type == 'outbound' ? 'socks' : 'freedom'}, + ], + }), + ), + ), +); + +SubscriptionData _source(int id, String name, {bool provider = false}) => + SubscriptionData( + id: id, + name: name, + url: 'https://source.example/$id', + panel: 'generic', + mode: provider ? 'providerManaged' : 'appManaged', + hwidEnabled: false, + timestamp: DateTime(2026, 11, 3), + ); diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index 1c150d977..8d4f21e98 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -34,31 +34,36 @@ void main() { bool hasServers = true, bool expert = false, String? runningPath, + String? fallbackMessage, bool methodEnabled = true, VoidCallback? onMethod, String method = 'Smart Routing', String? methodDetail, List raws = const [], + int? activeRawId, VoidCallback? onAddServers, VoidCallback? onExpert, + VoidCallback? onRawAdd, + VoidCallback? onServer, }) => ConnectView( view: view, hasServers: hasServers, expert: expert, raws: raws, - activeRawId: null, + activeRawId: activeRawId, location: 'Automatic selection', runningPath: runningPath, + fallbackMessage: fallbackMessage, method: method, methodDetail: methodDetail, methodEnabled: methodEnabled, onConnection: () {}, onAddServers: onAddServers ?? () {}, onExpert: (_) => onExpert?.call(), - onServer: () {}, + onServer: onServer ?? () {}, onMethod: onMethod ?? () {}, onWhy: () {}, - onRawAdd: () {}, + onRawAdd: onRawAdd ?? () {}, onRawSelect: (_) {}, onRawActions: (_) {}, ); @@ -164,7 +169,7 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(ConnectView)), )!; - final title = find.text(l.prototypeTraffic); + final title = find.text(l.prototypeThisConnection); expect(title, findsOneWidget); expect( find.ancestor(of: title, matching: find.byType(InkWell)), @@ -201,77 +206,206 @@ void main() { }, ); + for (final phase in [ + ConnectionPhase.disconnected, + ConnectionPhase.connected, + ]) { + testWidgets( + 'unavailable session metrics do not become zero or stale rates: $phase', + (tester) async { + await tester.pumpWidget( + app( + screen( + view: ConnectionView( + phase: phase, + metricsAvailable: false, + downloadSpeed: 1024, + uploadSpeed: 2048, + ), + ), + ), + ); + await tester.pump(); + final readout = find.byType(TrafficReadout); + expect( + find.descendant(of: readout, matching: find.text('—')), + findsNWidgets(5), + ); + for (final stale in ['1 KB/s', '2 KB/s', '0 B/s', '00:00:00']) { + expect( + find.descendant(of: readout, matching: find.text(stale)), + findsNothing, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + + for (final phase in ConnectionPhase.values) { + testWidgets( + 'connection preserves its actual phase and primary action: $phase', + (tester) async { + await tester.pumpWidget( + app(screen(view: ConnectionView(phase: phase))), + ); + await tester.pump(); + final l = AppLocalizations.of( + tester.element(find.byType(ConnectView)), + )!; + final title = switch (phase) { + ConnectionPhase.disconnected => l.prototypeDisconnected, + ConnectionPhase.preparing => l.connectionPreparing, + ConnectionPhase.connecting => l.prototypeConnecting, + ConnectionPhase.connected => l.prototypeConnected, + ConnectionPhase.disconnecting => l.prototypeDisconnecting, + ConnectionPhase.failed => l.prototypeConnectionFailed, + }; + final action = switch (phase) { + ConnectionPhase.disconnected => l.prototypeConnect, + ConnectionPhase.preparing || + ConnectionPhase.connecting => l.prototypeCancel, + ConnectionPhase.connected => l.prototypeDisconnect, + ConnectionPhase.disconnecting => l.prototypePleaseWait, + ConnectionPhase.failed => l.prototypeTryAgain, + }; + expect(find.text(title), findsOneWidget); + expect( + tester + .widget(find.widgetWithText(FilledButton, action)) + .onPressed, + phase == ConnectionPhase.disconnecting ? isNull : isNotNull, + ); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('scope fallback remains visible beside the actual running path', ( + tester, + ) async { + const message = 'Source scope → Automatic selection'; + await tester.pumpWidget( + app( + screen( + view: const ConnectionView(phase: ConnectionPhase.connected), + runningPath: 'Frozen entry → Frozen final exit', + fallbackMessage: message, + ), + ), + ); + await tester.pump(); + expect(find.text(message), findsOneWidget); + expect(find.text('Frozen entry → Frozen final exit'), findsOneWidget); + expect(find.text('Automatic selection'), findsOneWidget); + expect(find.text('Connection failed'), findsNothing); + expect(tester.takeException(), isNull); + }); + for (final expert in [false, true]) { - testWidgets('desktop connection shares one panel, expert=$expert', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(1160, 800)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(app(screen(expert: expert))); + testWidgets( + 'connection stacks state, plan, runtime and session; raw=$expert', + (tester) async { + await tester.binding.setSurfaceSize(const Size(1160, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + app( + screen( + expert: expert, + view: const ConnectionView(phase: ConnectionPhase.connected), + runningPath: 'Frozen entry → Frozen exit', + ), + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(ConnectView)), + )!; + final cards = + [ + l.prototypeConnected, + l.connectionPlan, + l.connectionRuntimeTitle, + l.prototypeThisConnection, + ] + .map( + (title) => tester.getRect( + find + .ancestor( + of: find.text(title), + matching: find.byType(Card), + ) + .first, + ), + ) + .toList(); + for (var index = 0; index < cards.length; index++) { + expect(cards[index].left, closeTo(cards.first.left, .01)); + expect(cards[index].width, closeTo(cards.first.width, .01)); + if (index > 0) { + expect(cards[index].top, greaterThan(cards[index - 1].bottom)); + } + } + expect( + find.byKey(const ValueKey('connection-single-column')), + findsOneWidget, + ); + expect(find.byType(ShadSwitch), findsNothing); + final connectButton = find.widgetWithText(FilledButton, 'Disconnect'); + expect( + tester.getSize(connectButton).width, + lessThanOrEqualTo(cards.first.width), + ); + expect( + tester.getSize(connectButton).height, + greaterThanOrEqualTo(AppLayout.buttonMinHeight), + ); + expect( + tester.widget(find.byType(TrafficReadout)).desktop, + isFalse, + ); + expect(find.text('Why this connection?'), findsOneWidget); + expect(find.text('Add Raw JSON'), findsNothing); + expect(find.text('Frozen entry → Frozen exit'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('phone, tablet and desktop use the same single content column', ( + tester, + ) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final width in [390.0, 768.0, 1160.0]) { + await tester.binding.setSurfaceSize(Size(width, 900)); + await tester.pumpWidget(app(screen())); await tester.pumpAndSettle(); - final panel = find.byKey(const ValueKey('desktop-connection-panel')); - final traffic = find.byKey(const ValueKey('desktop-traffic-panel')); - final panelRect = tester.getRect(panel); - final trafficRect = tester.getRect(traffic); - expect(panelRect.top, AppSpacing.desktopPageTop); - expect(trafficRect.top, panelRect.top); - expect(trafficRect.left - panelRect.right, 16); - expect(panelRect.width / trafficRect.width, closeTo(1.04, .001)); - expect(panelRect.height, closeTo(trafficRect.height, .001)); - expect( - find.descendant(of: panel, matching: find.text('Disconnected')), - findsOneWidget, - ); - expect( - find.descendant(of: panel, matching: find.text('Expert mode')), - findsOneWidget, + final l = AppLocalizations.of(tester.element(find.byType(ConnectView)))!; + final plan = tester.getRect( + find + .ancestor( + of: find.text(l.connectionPlan), + matching: find.byType(Card), + ) + .first, ); - final connectButton = find.widgetWithText(FilledButton, 'Connect'); - expect( - tester.getSize(connectButton).width, - AppLayout.connectDesktopButtonWidth, - ); - expect( - tester.getSize(connectButton).height, - AppLayout.connectDesktopButtonMinHeight, + final session = tester.getRect( + find + .ancestor( + of: find.text(l.prototypeThisConnection), + matching: find.byType(Card), + ) + .first, ); + expect(session.top, greaterThan(plan.bottom)); + expect(session.left, closeTo(plan.left, .01)); + expect(session.width, closeTo(plan.width, .01)); expect( tester.widget(find.byType(TrafficReadout)).desktop, - isTrue, - ); - expect( - find.text('Why this connection?'), - expert ? findsNothing : findsOneWidget, + isFalse, ); - expect(find.text('Add Raw JSON'), expert ? findsOneWidget : findsNothing); expect(tester.takeException(), isNull); - }); - } - - testWidgets('compact desktop stacks panels while mobile stays unchanged', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(850, 900)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget(app(screen())); - await tester.pumpAndSettle(); - final panel = find.byKey(const ValueKey('desktop-connection-panel')); - final traffic = find.byKey(const ValueKey('desktop-traffic-panel')); - expect(tester.getRect(traffic).top - tester.getRect(panel).bottom, 16); - expect(tester.getSize(traffic).width, tester.getSize(panel).width); - await tester.binding.setSurfaceSize(const Size(390, 900)); - await tester.pumpAndSettle(); - expect(panel, findsNothing); - expect(traffic, findsNothing); - expect( - tester.widget(find.byType(TrafficReadout)).desktop, - isFalse, - ); - expect( - tester.getSize(find.widgetWithText(FilledButton, 'Connect')).height, - AppLayout.connectButtonMinHeight, - ); - expect(tester.takeException(), isNull); + } }); for (final locale in [ @@ -294,7 +428,8 @@ void main() { expect(tester.takeException(), isNull); final context = tester.element(find.byType(ConnectView)); final l = AppLocalizations.of(context)!; - expect(find.text(l.prototypeCurrentSpeed), findsOneWidget); + expect(find.text(l.prototypeThisConnection), findsOneWidget); + expect(find.text(l.connectionPlan), findsOneWidget); expect(find.text(l.prototypeServers), findsOneWidget); expect( Directionality.of(context), @@ -333,12 +468,12 @@ void main() { ); } - testWidgets('empty normal state still offers expert configuration', ( + testWidgets('empty connection offers complete configuration import', ( tester, ) async { var switched = false; await tester.pumpWidget( - app(screen(hasServers: false, onExpert: () => switched = true)), + app(screen(hasServers: false, onRawAdd: () => switched = true)), ); await tester.pumpAndSettle(); await tester.tap(find.text('Use a complete Raw JSON configuration')); @@ -383,12 +518,31 @@ void main() { final card = tester.getRect(emptyCard); final bodyTop = tester.getBottomLeft(find.byType(AppBar)).dy; expect(card.center.dx, closeTo(size.width / 2, .5)); - expect(card.top, bodyTop + AppSpacing.desktopPageTop); + expect(find.text(l.connectionPlan), findsNothing); + expect(card.top, closeTo(bodyTop + AppSpacing.desktopPageTop, .01)); expect( card.width, size.width.clamp(0.0, AppLayout.standardMaxWidth) - AppSpacing.page * 2, ); - expect(card.height, AppLayout.emptyStateDesktopMinHeight); + expect( + card.height, + greaterThanOrEqualTo(AppLayout.emptyStateDesktopMinHeight), + ); + final add = find.widgetWithText(FilledButton, l.prototypeAddServers); + final complete = find.widgetWithText( + TextButton, + l.prototypeUseCompleteRawJson, + ); + expect(add.hitTestable(), findsOneWidget); + expect(complete.hitTestable(), findsOneWidget); + if (BackupService.supported) { + expect( + find + .widgetWithText(OutlinedButton, l.backupRestoreFrom) + .hitTestable(), + findsOneWidget, + ); + } expect(tester.takeException(), isNull); } }); @@ -406,7 +560,7 @@ void main() { screen( hasServers: false, onAddServers: () => added = true, - onExpert: () => switched = true, + onRawAdd: () => switched = true, ), scale: 1.5, ), @@ -479,9 +633,9 @@ void main() { app( screen( view: const ConnectionView( - issue: 'insufficientHealthyServers', + issue: 'insufficientCandidates', error: ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ), @@ -497,7 +651,7 @@ void main() { ); testWidgets( - 'all legacy Raw configurations remain visible; add hidden at 3+', + 'connection plan shows the selected complete configuration and opens its picker', (tester) async { final raws = List.generate( 4, @@ -512,13 +666,28 @@ void main() { favorite: false, ), ); - await tester.pumpWidget(app(screen(expert: true, raws: raws))); + var opened = false; + await tester.pumpWidget( + app( + screen( + expert: true, + raws: raws, + activeRawId: 3, + onServer: () => opened = true, + ), + ), + ); await tester.pumpAndSettle(); for (final row in raws) { - expect(find.text(row.name), findsOneWidget); + expect( + find.text(row.name), + row.id == 3 ? findsOneWidget : findsNothing, + ); } expect(find.text('Add Raw JSON'), findsNothing); expect(find.text('Why this connection?'), findsNothing); + await tester.tap(find.text('Legacy 3')); + expect(opened, isTrue); }, ); diff --git a/test/pages/shared/connection_action_test.dart b/test/pages/shared/connection_action_test.dart index c27d41d94..b3b861aea 100644 --- a/test/pages/shared/connection_action_test.dart +++ b/test/pages/shared/connection_action_test.dart @@ -87,7 +87,7 @@ void main() { ), ( const ConnectionResolutionException( - ConnectionResolutionFailure.insufficientHealthyServers, + ConnectionResolutionFailure.insufficientCandidates, requiredCount: 3, availableCount: 1, ), diff --git a/test/service/connect/preparation_test.dart b/test/service/connect/preparation_test.dart index ae0dc65e9..bae515a78 100644 --- a/test/service/connect/preparation_test.dart +++ b/test/service/connect/preparation_test.dart @@ -640,6 +640,44 @@ void main() { ); }); + test('personal Raw runtime retains its prepared name after rename and accepts older metadata', () async { + final id = await db.coreConfigDao.insertRow( + CoreConfigCompanion.insert( + name: 'Original personal Raw', + type: 'raw', + tags: '', + delay: PingDelayConstants.unknown, + subId: 0, + data: Value(base64Encode(utf8.encode(jsonEncode(_providerConfig)))), + ), + ); + final input = ConnectionConfiguration( + connection: ConnectionSettings(expert: true, rawId: id), + ); + final runtime = await _providerPreparation(db).prepare(input); + final row = (await db.coreConfigDao.searchRow(id))!; + await db.coreConfigDao.updateRow( + row.copyWith(name: 'Renamed personal Raw'), + ); + expect(runtime.rawName, 'Original personal Raw'); + expect(runtime.profile, isNull); + expect(runtime.entries, isEmpty); + expect( + ConnectionRuntime.fromRequest(runtime.request).rawName, + 'Original personal Raw', + ); + final oldMetadata = + jsonDecode(runtime.request.metadataJson!) as Map; + oldMetadata.remove('rawName'); + final oldRequest = runtime.request.toJson() + ..['metadataJson'] = jsonEncode(oldMetadata); + expect( + ConnectionRuntime.fromRequest(StartVpnRequest.fromJson(oldRequest)) + .rawName, + isNull, + ); + }); + for (final mode in [TrafficMode.smart, TrafficMode.custom]) { test( 'fixed provider bypasses $mode routing, probes and final exit', @@ -1030,11 +1068,12 @@ void main() { SelectionKind.server, ); expect(runtime.notice, isNull); - if (healthy != null) + if (healthy != null) { expect( runtime.entries.any((entry) => entry.id == healthy), isFalse, ); + } }, ); } From a590d96ab07e8c17c553fe6d1498626af829c889 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:46:35 +0800 Subject: [PATCH 06/60] fix(connect): keep target choices usable above keyboards and label Raw routing Keep one scroll surface with reachable actions on short and large-text screens. Explain routing owned by complete Raw configurations without changing ordinary routing preferences. Validation: 223 connect, routing and launch tests passed; flutter analyze reports no issues. --- lib/l10n/app_en.arb | 4 +- lib/l10n/app_fa.arb | 4 +- lib/l10n/app_ru.arb | 4 +- lib/l10n/app_zh.arb | 4 +- lib/l10n/app_zh_Hant.arb | 4 +- lib/pages/connect/controller.dart | 18 +- lib/pages/connect/json_editor/controller.dart | 12 +- lib/pages/connect/page.dart | 4 +- lib/pages/connect/target_picker.dart | 284 +++++++++++------- .../connect/configuration_sync_test.dart | 4 +- test/pages/connect/controller_test.dart | 56 ++++ test/pages/connect/target_picker_test.dart | 204 +++++++++++-- 12 files changed, 448 insertions(+), 154 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 165047c14..fd527151c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3195,5 +3195,7 @@ }, "serverNoMatchingResources": "No matching resources", "connectionRuntimeTitle": "Active connection", - "serverLatencyFailed": "Test failed" + "serverLatencyFailed": "Test failed", + "completeConfigurationRoutingTitle": "Configuration routing", + "completeConfigurationRoutingNotice": "Uses routing and DNS from the complete configuration. App routing preferences are kept." } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3c2df6674..e5c469318 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1017,5 +1017,7 @@ }, "serverNoMatchingResources": "منبع مطابقی یافت نشد", "connectionRuntimeTitle": "اتصال فعال", - "serverLatencyFailed": "آزمایش ناموفق" + "serverLatencyFailed": "آزمایش ناموفق", + "completeConfigurationRoutingTitle": "مسیریابی پیکربندی", + "completeConfigurationRoutingNotice": "از مسیریابی و DNS پیکربندی کامل استفاده می‌شود. تنظیمات مسیریابی عادی حفظ می‌شوند." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 153abfac1..ead35c26e 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1017,5 +1017,7 @@ }, "serverNoMatchingResources": "Нет подходящих ресурсов", "connectionRuntimeTitle": "Активное подключение", - "serverLatencyFailed": "Ошибка теста" + "serverLatencyFailed": "Ошибка теста", + "completeConfigurationRoutingTitle": "Маршруты конфигурации", + "completeConfigurationRoutingNotice": "Используются маршруты и DNS полной конфигурации. Настройки обычной маршрутизации сохраняются." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2d03caaf5..3f7f7cfc2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1017,5 +1017,7 @@ }, "serverNoMatchingResources": "没有匹配的资源", "connectionRuntimeTitle": "正在运行", - "serverLatencyFailed": "测速失败" + "serverLatencyFailed": "测速失败", + "completeConfigurationRoutingTitle": "配置自带路由", + "completeConfigurationRoutingNotice": "使用完整配置内的路由与 DNS,普通路由偏好保持不变。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 0fb0010f0..80f5a5281 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1017,5 +1017,7 @@ }, "serverNoMatchingResources": "沒有符合的資源", "connectionRuntimeTitle": "正在執行", - "serverLatencyFailed": "測速失敗" + "serverLatencyFailed": "測速失敗", + "completeConfigurationRoutingTitle": "設定自帶路由", + "completeConfigurationRoutingNotice": "使用完整設定內的路由與 DNS,一般路由偏好保持不變。" } diff --git a/lib/pages/connect/controller.dart b/lib/pages/connect/controller.dart index 17c7c7eee..c171749f8 100644 --- a/lib/pages/connect/controller.dart +++ b/lib/pages/connect/controller.dart @@ -334,8 +334,8 @@ class ConnectController extends PageCubit with ServerLabels { String? selectionDetail(AppLocalizations l10n) { final settings = configuration.connection; - if (settings.expert) return l10n.prototypeCompleteXrayConfiguration; - if (selectedProvider != null) return l10n.providerRoutingNotice; + if (settings.expert) return l10n.xrayRawPageTitle; + if (selectedProvider != null) return l10n.providerConfigurationTitle; return '${l10n.connectionCandidates(candidateCount(settings.selection))} · ${l10n.connectionRequiredEntries(_configuredEntryCount(settings) ?? 1)}'; } @@ -355,7 +355,10 @@ class ConnectController extends PageCubit with ServerLabels { return health(l10n, row); } - String homeMethodTitle(AppLocalizations l10n) => providerConfiguration + String homeMethodTitle(AppLocalizations l10n) => + configuration.connection.expert + ? l10n.completeConfigurationRoutingTitle + : providerConfiguration ? l10n.providerConfigurationTitle : configuration.connection.trafficMode == TrafficMode.smart ? l10n.prototypeSmartRoutingRecommended @@ -364,6 +367,9 @@ class ConnectController extends PageCubit with ServerLabels { int _ruleCount(RoutingConfiguration profile) => profile.ruleCount; String methodDescription(AppLocalizations l10n) { + if (configuration.connection.expert) { + return l10n.completeConfigurationRoutingNotice; + } if (providerConfiguration) return l10n.providerRoutingNotice; switch (configuration.connection.trafficMode) { case TrafficMode.smart: @@ -756,7 +762,7 @@ class ConnectController extends PageCubit with ServerLabels { Future showWhy(BuildContext context) async { final l = AppLocalizations.of(context)!; - if (providerConfiguration) { + if (providerConfiguration || configuration.connection.expert) { await showConnectDialog( context, (dialogContext) => ConnectDialog( @@ -765,7 +771,9 @@ class ConnectController extends PageCubit with ServerLabels { padding: const EdgeInsets.all(22), child: ConnectExplanation( icon: LucideIcons.shieldCheck, - text: l.providerRoutingNotice, + text: configuration.connection.expert + ? l.prototypeRawManagedSettingsNotice + : l.providerRoutingNotice, ), ), actions: [ diff --git a/lib/pages/connect/json_editor/controller.dart b/lib/pages/connect/json_editor/controller.dart index bfa9cac2c..a393dcc11 100644 --- a/lib/pages/connect/json_editor/controller.dart +++ b/lib/pages/connect/json_editor/controller.dart @@ -127,7 +127,10 @@ class JsonConfigurationEditorController ConfigurationTransferService? transferService, this.kind = ConfigurationKind.raw, }) : _rawService = service, + // Keep public injection names separate from the resolved service fields. + // ignore: prefer_initializing_formals _customService = customService, + // ignore: prefer_initializing_formals _transferService = transferService, super(const JsonConfigurationEditorState()) { text.addListener(_textChanged); @@ -220,8 +223,9 @@ class JsonConfigurationEditorController state.closeBlocked || state.confirmingClose || state.closeAllowed || - ModalRoute.of(context)?.isCurrent != true) + ModalRoute.of(context)?.isCurrent != true) { return; + } if (state.dirty) { final l = AppLocalizations.of(context)!; emit(state.copyWith(confirmingClose: true)); @@ -237,8 +241,9 @@ class JsonConfigurationEditorController } finally { emit(state.copyWith(confirmingClose: false)); } - if (!discard || !isPageActive || !context.mounted || state.closeBlocked) + if (!discard || !isPageActive || !context.mounted || state.closeBlocked) { return; + } } _finishClose(context); } @@ -247,8 +252,9 @@ class JsonConfigurationEditorController if (!isPageActive || !context.mounted || state.closeAllowed || - ModalRoute.of(context)?.isCurrent != true) + ModalRoute.of(context)?.isCurrent != true) { return; + } emit(state.copyWith(closeAllowed: true)); Navigator.of(context).pop(result); } diff --git a/lib/pages/connect/page.dart b/lib/pages/connect/page.dart index d04b4d1eb..fe84de03c 100644 --- a/lib/pages/connect/page.dart +++ b/lib/pages/connect/page.dart @@ -92,7 +92,9 @@ class _ConnectPageState extends State { locationHealth: controller.selectionHealth(l), method: controller.homeMethodTitle(l), methodDetail: controller.methodDescription(l), - methodEnabled: !controller.providerConfiguration, + methodEnabled: + !state.configuration.connection.expert && + !controller.providerConfiguration, onConnection: () => controller.connectionAction(context), onAddServers: () => controller.addServers(context), onExpert: (value) => controller.toggleExpert(context, value), diff --git a/lib/pages/connect/target_picker.dart b/lib/pages/connect/target_picker.dart index 35787dda4..08940fda4 100644 --- a/lib/pages/connect/target_picker.dart +++ b/lib/pages/connect/target_picker.dart @@ -2,8 +2,8 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -177,122 +177,192 @@ class _ConnectionTargetPickerState extends State { ), ], ]; - return Material( - color: palette.card, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: SafeArea( - top: false, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsetsDirectional.fromSTEB(20, 12, 12, 8), - child: Row( - children: [ - Expanded( - child: Text( - l.prototypeServers, - style: Theme.of(context).textTheme.titleLarge, - ), - ), - IconButton( - tooltip: l.prototypeClose, - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(LucideIcons.x, size: 20), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in [ - l.serverSelectionAutomaticScope, - l.serverSelectionFixedNode, - l.serverCompleteConfigurationsTab, - ].indexed) - ChoiceChip( - label: Text(entry.$2), - selected: tab == entry.$1, - onSelected: (_) => setState(() => tab = entry.$1), - ), - ], + final footer = Padding( + padding: const EdgeInsets.all(16), + child: LayoutBuilder( + builder: (context, constraints) { + final theme = Theme.of(context); + final cancelStyle = + ShadTheme.of(context).outlineButtonTheme.textStyle ?? + theme.textTheme.labelLarge!; + final manageStyle = + theme.outlinedButtonTheme.style?.textStyle?.resolve({}) ?? + theme.textTheme.labelLarge!; + double labelWidth(String label, TextStyle style) { + final painter = TextPainter( + text: TextSpan(text: label, style: style), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final width = painter.width; + painter.dispose(); + return width; + } + + final halfWidth = (constraints.maxWidth - 12) / 2; + final stacked = + labelWidth(l.prototypeServers, manageStyle) + 48 > halfWidth || + labelWidth(l.prototypeCancel, cancelStyle) + 48 > halfWidth; + final manage = OutlinedButton( + onPressed: () => choose(const ConnectionTargetChoice(manage: true)), + child: Text(l.prototypeServers), + ); + final cancel = ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + AppLayout.buttonMinHeight, + platform: theme.platform, ), ), - Padding( - padding: const EdgeInsets.all(16), - child: TextField( - key: const ValueKey('connection-target-search'), - decoration: InputDecoration( - prefixIcon: const Icon(LucideIcons.search, size: 18), - hintText: l.prototypeSearchSubscriptionsServers, - ), - onChanged: (value) => setState(() => query = value), - ), + child: ShadButton.outline( + expands: true, + height: 0, + onPressed: () => Navigator.of(context).pop(), + child: Text(l.prototypeCancel), ), - if (tab == 0 && - (settings.expert || - widget.catalog.servers.any( - (r) => - r.type == CoreConfigType.Remnawave.name && - selected(ServerSelection.server(r.id)), - ))) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: TextButton( - onPressed: () => - choose(const ConnectionTargetChoice(restore: true)), - child: Text(l.connectionRestoreOrdinary), - ), - ), - Expanded( - child: entries.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(16), + ); + return stacked + ? Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [manage, const SizedBox(height: 8), cancel], + ) + : Row( + children: [ + Expanded(child: manage), + const SizedBox(width: 12), + Expanded(child: cancel), + ], + ); + }, + ), + ); + return LayoutBuilder( + builder: (context, constraints) { + final short = constraints.maxHeight < 380; + return Material( + color: palette.card, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(20, 8, 12, 4), + child: Row( + children: [ + Expanded( child: Text( - query.trim().isEmpty - ? l.prototypeNoServersYet - : l.serverNoMatchingResources, + l.prototypeServers, + style: Theme.of(context).textTheme.titleLarge, ), ), - ) - : ListView.builder( - key: const ValueKey('connection-target-options'), - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: entries.length, - itemBuilder: (context, index) { - final e = entries[index]; - return option( - e.title, - e.detail, - e.active, - () => choose(e.choice), - e.icon, - ); - }, - ), - ), - PageActionBar( - children: [ - OutlinedButton( - onPressed: () => - choose(const ConnectionTargetChoice(manage: true)), - child: Text(l.prototypeServers), + IconButton( + tooltip: l.prototypeClose, + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(LucideIcons.x, size: 20), + ), + ], + ), ), - ShadButton.outline( - onPressed: () => Navigator.of(context).pop(), - child: Text(l.prototypeCancel), + Expanded( + child: CustomScrollView( + key: const ValueKey('connection-target-options'), + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in [ + l.serverSelectionAutomaticScope, + l.serverSelectionFixedNode, + l.serverCompleteConfigurationsTab, + ].indexed) + ChoiceChip( + label: Text(entry.$2), + selected: tab == entry.$1, + onSelected: (_) => + setState(() => tab = entry.$1), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + key: const ValueKey('connection-target-search'), + decoration: InputDecoration( + prefixIcon: const Icon( + LucideIcons.search, + size: 18, + ), + hintText: l.prototypeSearchSubscriptionsServers, + ), + onChanged: (value) => setState(() => query = value), + ), + ), + ), + if (tab == 0 && + (settings.expert || + widget.catalog.servers.any( + (r) => + r.type == CoreConfigType.Remnawave.name && + selected(ServerSelection.server(r.id)), + ))) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TextButton( + onPressed: () => choose( + const ConnectionTargetChoice(restore: true), + ), + child: Text(l.connectionRestoreOrdinary), + ), + ), + ), + if (entries.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + query.trim().isEmpty + ? l.prototypeNoServersYet + : l.serverNoMatchingResources, + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final e = entries[index]; + return option( + e.title, + e.detail, + e.active, + () => choose(e.choice), + e.icon, + ); + }, + ), + ), + if (short) SliverToBoxAdapter(child: footer), + ], + ), ), + if (!short) footer, ], ), - ], - ), - ), + ), + ); + }, ); } } diff --git a/test/pages/connect/configuration_sync_test.dart b/test/pages/connect/configuration_sync_test.dart index 8ccd216ad..440ec5ab0 100644 --- a/test/pages/connect/configuration_sync_test.dart +++ b/test/pages/connect/configuration_sync_test.dart @@ -193,7 +193,9 @@ Widget _screen(ConnectController controller) => MaterialApp( locationDetail: controller.selectionDetail(l), method: controller.homeMethodTitle(l), methodDetail: controller.methodDescription(l), - methodEnabled: !controller.providerConfiguration, + methodEnabled: + !state.configuration.connection.expert && + !controller.providerConfiguration, onConnection: () => controller.connectionAction(context), onExpert: (value) => controller.toggleExpert(context, value), onAddServers: () {}, diff --git a/test/pages/connect/controller_test.dart b/test/pages/connect/controller_test.dart index 3345bec75..e6aa28999 100644 --- a/test/pages/connect/controller_test.dart +++ b/test/pages/connect/controller_test.dart @@ -221,6 +221,62 @@ void main() { expect(before.sameContentAs(after), isFalse); }); + testWidgets( + 'Raw plan explains its own routing without changing ordinary preferences', + (tester) async { + final coordinator = _Coordinator(); + coordinator.state.value = const ConnectionView(); + final configuration = ConnectionConfiguration( + connection: ConnectionSettings( + expert: true, + rawId: 9, + trafficMode: TrafficMode.allVpn, + selection: const ServerSelection.source(17), + smart: SmartRoutingSettings(entryCount: 3, finalExitId: 900), + ), + ); + coordinator.saved = configuration; + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = configuration; + addTearDown(controller.close); + addTearDown(coordinator.dispose); + addTearDown(coordinator.db.close); + await tester.pumpWidget( + _testApp( + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => controller.showWhy(context), + child: const Text('why'), + ), + ), + ), + ), + ); + final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; + final before = configuration.encode(); + expect( + controller.homeMethodTitle(l), + l.completeConfigurationRoutingTitle, + ); + expect( + controller.methodDescription(l), + l.completeConfigurationRoutingNotice, + ); + expect(controller.selectionDetail(l), l.xrayRawPageTitle); + await tester.tap(find.text('why')); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeRawManagedSettingsNotice), findsOneWidget); + expect(find.text(l.prototypeAllViaVpnDescription), findsNothing); + expect(controller.configuration.encode(), before); + expect(coordinator.saved.encode(), before); + expect(coordinator.applyCount, 0); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'scope fallback uses frozen source and runtime identities only after connection', (tester) async { diff --git a/test/pages/connect/target_picker_test.dart b/test/pages/connect/target_picker_test.dart index 16489ed22..89219675d 100644 --- a/test/pages/connect/target_picker_test.dart +++ b/test/pages/connect/target_picker_test.dart @@ -66,8 +66,15 @@ void main() { SelectionKind.server => 'Server ${scope.id}', }; - Future open(WidgetTester tester, ConnectionSettings settings) async { - await tester.binding.setSurfaceSize(const Size(960, 1100)); + Future open( + WidgetTester tester, + ConnectionSettings settings, { + Size size = const Size(960, 1100), + EdgeInsets viewInsets = EdgeInsets.zero, + double textScale = 1, + ServerCatalog? catalogOverride, + }) async { + await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); choice = null; completions = 0; @@ -77,8 +84,16 @@ void main() { locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalePolicy.localizationsDelegates, - builder: (context, child) => - ShadTheme(data: AppTheme.shad(Brightness.light), child: child!), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + viewInsets: viewInsets, + textScaler: TextScaler.linear(textScale), + ), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: child!, + ), + ), home: Builder( builder: (context) => Scaffold( body: FilledButton( @@ -87,13 +102,16 @@ void main() { context, (_) => ConnectionTargetPicker( settings: settings, - catalog: catalog, + catalog: catalogOverride ?? catalog, raws: raws, - candidateCount: (scope) => catalog.servers + candidateCount: (scope) => (catalogOverride ?? catalog) + .servers .where( (row) => row.id != settings.finalExitId && - catalog.outboundEligible(row) && + (catalogOverride ?? catalog).outboundEligible( + row, + ) && (scope.kind == SelectionKind.automatic || row.subId == scope.id), ) @@ -132,11 +150,6 @@ void main() { .selected == true; - Future tab(WidgetTester tester, String title) async { - await tester.tap(find.widgetWithText(ChoiceChip, title)); - await tester.pumpAndSettle(); - } - Future reveal(WidgetTester tester, Finder row) async { final lists = find.byKey(const ValueKey('connection-target-options')); if (lists.evaluate().isEmpty) { @@ -151,12 +164,26 @@ void main() { row, 80, scrollable: scrollable, - maxScrolls: 30, + maxScrolls: 180, ); } await tester.pumpAndSettle(); } + Future tab(WidgetTester tester, String title) async { + final chip = find.widgetWithText(ChoiceChip, title); + await reveal(tester, chip); + await tester.tap(chip); + await tester.pumpAndSettle(); + } + + Future search(WidgetTester tester, String text) async { + final input = find.byKey(const ValueKey('connection-target-search')); + await reveal(tester, input); + await tester.enterText(input, text); + await tester.pumpAndSettle(); + } + testWidgets( 'source ID collisions keep automatic scope selected by kind and ID', (tester) async { @@ -229,19 +256,11 @@ void main() { 'Inactive ordinary cache', 'Unreadable', ]) { - await tester.enterText( - find.byKey(const ValueKey('connection-target-search')), - title, - ); - await tester.pumpAndSettle(); + await search(tester, title); expect(tile(title), findsNothing); expect(find.text(l.serverNoMatchingResources), findsOneWidget); } - await tester.enterText( - find.byKey(const ValueKey('connection-target-search')), - '', - ); - await tester.pumpAndSettle(); + await search(tester, ''); await reveal(tester, tile('Failed probe')); expect(isSelected(tester, tile('Failed probe')), isTrue); expect(isSelected(tester, tile('Unmeasured')), isFalse); @@ -321,18 +340,11 @@ void main() { final settings = ConnectionSettings(expert: true, rawId: 40); final before = settings.toJson(); await open(tester, settings); - await tester.enterText( - find.byKey(const ValueKey('connection-target-search')), - 'legacy raw 3', - ); - await tester.pumpAndSettle(); + await search(tester, 'legacy raw 3'); expect(tile('Legacy Raw 3'), findsOneWidget); expect(tile('Duplicate', subtitle: l.xrayRawPageTitle), findsNothing); expect(completions, 0); - await tester.enterText( - find.byKey(const ValueKey('connection-target-search')), - '', - ); + await search(tester, ''); await tab(tester, l.serverSelectionAutomaticScope); await tester.tap(find.text(l.connectionRestoreOrdinary)); await tester.pumpAndSettle(); @@ -349,6 +361,134 @@ void main() { expect(tester.takeException(), isNull); }, ); + + testWidgets( + 'normal-height target picker keeps actions fixed while its one content surface scrolls', + (tester) async { + final many = ServerCatalog( + servers: List.generate( + 60, + (index) => _node( + 100 + index, + 'Long-list server ${index + 1}', + PingDelayConstants.unknown, + ), + ), + ); + await open( + tester, + ConnectionSettings(selection: const ServerSelection.server(100)), + catalogOverride: many, + ); + final cancel = find.widgetWithText(ShadButton, l.prototypeCancel); + final manage = find.widgetWithText(OutlinedButton, l.prototypeServers); + final cancelBounds = tester.getRect(cancel); + final manageBounds = tester.getRect(manage); + await reveal(tester, tile('Long-list server 60')); + expect(tester.getRect(cancel), cancelBounds); + expect(tester.getRect(manage), manageBounds); + expect(cancel.hitTestable(), findsOneWidget); + expect(manage.hitTestable(), findsOneWidget); + expect(completions, 0); + await tester.tap(cancel); + await tester.pumpAndSettle(); + expect(choice, isNull); + expect(tester.takeException(), isNull); + }, + ); + + for (final (name, size, insets, scale) in const [ + ('phone keyboard', Size(390, 844), EdgeInsets.only(bottom: 320), 1.3), + ('short phone with large text', Size(390, 360), EdgeInsets.zero, 2.0), + ( + 'wide short window with keyboard', + Size(850, 340), + EdgeInsets.only(bottom: 120), + 1.3, + ), + ]) { + testWidgets( + 'target browsing and both footer actions remain reachable: $name', + (tester) async { + final many = ServerCatalog( + servers: List.generate( + 60, + (index) => _node( + 100 + index, + 'Long-list server ${index + 1}', + PingDelayConstants.unknown, + ), + ), + ); + final settings = ConnectionSettings( + selection: const ServerSelection.server(100), + ); + final before = settings.toJson(); + for (final manage in [false, true]) { + await open( + tester, + settings, + size: size, + viewInsets: insets, + textScale: scale, + catalogOverride: many, + ); + expect(tester.takeException(), isNull); + final picker = find.byType(ConnectionTargetPicker); + final pickerBounds = tester.getRect(picker); + final keyboardTop = size.height - insets.bottom; + expect(pickerBounds.bottom, lessThanOrEqualTo(keyboardTop + .01)); + expect(pickerBounds.top, greaterThanOrEqualTo(0)); + expect(pickerBounds.height, greaterThan(100)); + final surface = find.byKey( + const ValueKey('connection-target-options'), + ); + expect( + find.descendant( + of: surface, + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + widget.axisDirection == AxisDirection.down, + ), + ), + findsOneWidget, + ); + final searchInput = find.byKey( + const ValueKey('connection-target-search'), + ); + await reveal(tester, searchInput); + expect(searchInput.hitTestable(), findsOneWidget); + await search(tester, 'Long-list server 60'); + expect(tile('Long-list server 60'), findsOneWidget); + expect(completions, 0); + await search(tester, ''); + await reveal(tester, tile('Long-list server 60')); + expect(tile('Long-list server 60').hitTestable(), findsOneWidget); + expect(settings.toJson(), before); + expect(completions, 0); + final action = manage + ? find.widgetWithText(OutlinedButton, l.prototypeServers) + : find.widgetWithText(ShadButton, l.prototypeCancel); + await reveal(tester, action); + expect(action.hitTestable(), findsOneWidget); + expect( + tester.getRect(action).bottom, + lessThanOrEqualTo(keyboardTop + .01), + ); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(picker, findsNothing); + expect(completions, 1); + expect(choice?.manage, manage ? isTrue : isNull); + expect(choice?.selection, isNull); + expect(choice?.rawId, isNull); + expect(settings.toJson(), before); + expect(tester.takeException(), isNull); + } + }, + ); + } } CoreConfigData _node( From 8292e5da7e25f803578d0d1482e3aeea7b11da49 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:46:46 +0800 Subject: [PATCH 07/60] refactor(ui): move server resources and routing editors into single-column pages Separate browsing from explicit selection and preserve full-page draft guards. Isolate damaged routing assets and ignore stale loads. Keep server geography retired and Smart direct-region selection unchanged. Validation: 178 server tests and 223 connect/routing/launch tests passed; flutter analyze reports no issues. Native UI acceptance remains the final stage. --- lib/pages/advanced/xray/diagnostics.dart | 202 +++ lib/pages/connect/routing/controller.dart | 198 ++- lib/pages/connect/routing/custom/page.dart | 4 +- .../connect/routing/custom/rule_page.dart | 6 +- .../connect/routing/custom/sections.dart | 69 +- lib/pages/connect/routing/page.dart | 80 +- lib/pages/connect/routing/smart/page.dart | 48 +- lib/pages/connect/routing/smart/regions.dart | 44 +- lib/pages/connect/routing/widgets.dart | 126 +- lib/pages/main/navigation.dart | 11 +- lib/pages/main/url.dart | 91 +- lib/pages/servers/catalog.dart | 2 +- lib/pages/servers/controller.dart | 130 +- lib/pages/servers/details.dart | 484 +++++++ lib/pages/servers/editor/page.dart | 4 +- lib/pages/servers/form_page.dart | 84 ++ lib/pages/servers/import/controller.dart | 12 +- lib/pages/servers/import/page.dart | 12 +- lib/pages/servers/page.dart | 61 +- lib/pages/servers/sources.dart | 95 ++ lib/pages/servers/subscription/form_view.dart | 18 +- lib/pages/servers/view.dart | 1259 +++++++---------- lib/pages/theme/font.dart | 8 +- .../connect/routing/custom_layout_test.dart | 29 +- .../connect/routing/direct_regions_test.dart | 112 +- .../pages/connect/routing/root_page_test.dart | 344 +++++ .../routing/smart/exit_picker_test.dart | 79 +- .../connect/routing/smart/layout_test.dart | 70 +- test/pages/launch/setup_controller_test.dart | 1 + test/pages/servers/controller_test.dart | 134 +- test/pages/servers/editor_dismissal_test.dart | 25 +- .../servers/fixtures/server_test_router.dart | 44 + test/pages/servers/import_test.dart | 7 +- test/pages/servers/manual_groups_test.dart | 187 ++- test/pages/servers/provider_ui_test.dart | 29 +- test/pages/servers/raw_selection_test.dart | 246 ++++ .../pages/servers/subscription_open_test.dart | 10 +- test/pages/servers/view_test.dart | 419 +++--- 38 files changed, 3354 insertions(+), 1430 deletions(-) create mode 100644 lib/pages/advanced/xray/diagnostics.dart create mode 100644 lib/pages/servers/details.dart create mode 100644 lib/pages/servers/form_page.dart create mode 100644 test/pages/connect/routing/root_page_test.dart create mode 100644 test/pages/servers/fixtures/server_test_router.dart create mode 100644 test/pages/servers/raw_selection_test.dart diff --git a/lib/pages/advanced/xray/diagnostics.dart b/lib/pages/advanced/xray/diagnostics.dart new file mode 100644 index 000000000..f86791af8 --- /dev/null +++ b/lib/pages/advanced/xray/diagnostics.dart @@ -0,0 +1,202 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/tab_visibility.dart'; +import 'package:onexray/pages/advanced/xray/config/params.dart'; +import 'package:onexray/pages/advanced/xray/controller.dart'; +import 'package:onexray/pages/advanced/xray/log/params.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/service/shared/failure.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Reads coordinator and saved runtime facts without changing the connection. +class XrayDiagnosticsPage extends StatelessWidget { + final void Function(BuildContext, LogFileViewerParams) onLog; + final void Function(BuildContext, ConfigFileViewerParams) onConfig; + final XrayRuntimeController Function()? createController; + + const XrayDiagnosticsPage({ + super.key, + required this.onLog, + required this.onConfig, + this.createController, + }); + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => createController?.call() ?? XrayRuntimeController(), + child: BlocBuilder( + builder: (context, state) { + final controller = context.read(); + final l = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; + return AdvancedTabVisibility( + tabIndex: 1, + onChanged: controller.reader.setVisible, + child: Scaffold( + appBar: PageAppBar( + title: Text(l.xrayDiagnostics), + actions: [ + IconButton( + tooltip: l.xrayRefreshStatus, + onPressed: state.loading ? null : () => controller.load(), + icon: state.loading + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.refreshCw, size: 19), + ), + ], + ), + body: SafeArea( + child: SettingsPageScroll( + padding: EdgeInsetsDirectional.fromSTEB(gutter, 16, gutter, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + l.xrayDiagnosticsHint, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.mutedForeground), + ), + SettingSection( + title: l.prototypeRuntimeStatus, + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: l.prototypeXrayCore, + value: controller.statusLabel(l), + valueStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: state.connection.failed + ? palette.destructive + : palette.foreground, + ), + ), + SettingRow( + title: l.prototypeVersion, + value: controller.xrayVersion, + valueTextDirection: TextDirection.ltr, + ), + SettingRow( + title: l.prototypeUptime, + value: controller.uptime, + valueTextDirection: TextDirection.ltr, + ), + ], + ), + if (state.connection.failed) + ShadAlert.destructive( + icon: const Icon(LucideIcons.circleAlert, size: 19), + title: Text(l.prototypeConnectionFailed), + description: SelectableText( + state.connection.error == null + ? nativeOperationFailureTitle( + l, + state.connection.issue, + ) ?? + l.prototypeConnectionFailed + : appFailureMessage(l, state.connection.error), + ), + ) + else if (!state.loading && !state.failed) + ShadAlert( + icon: const Icon(LucideIcons.info, size: 19), + description: Text(l.xrayNoConnectionError), + ), + if (state.failed) + ShadAlert.destructive( + description: SelectableText( + appFailureMessage(l, state.failure), + ), + ), + SettingSection( + title: l.prototypeRuntimeConfiguration, + padding: EdgeInsets.zero, + children: [ + if (controller.runtime == null) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + l.xrayNoRuntimeConfiguration, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: palette.mutedForeground), + ), + ) + else + SettingRow( + title: l.prototypeRecentXrayConfiguration, + subtitle: l.prototypeReadOnlyRuntimeConfiguration, + leading: const Icon(LucideIcons.fileJson, size: 19), + decorateLeading: false, + showChevron: true, + onTap: () => + controller.openConfig(context, onConfig), + ), + ], + ), + if (!state.systemExtension && !state.loading) + SettingSection( + title: l.prototypeLogs, + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: l.prototypeErrorLog, + subtitle: l.prototypeErrorLogHint, + leading: const Icon(LucideIcons.terminal, size: 19), + decorateLeading: false, + showChevron: true, + enabled: controller.logPath(false) != null, + onTap: () => + controller.openLog(context, false, onLog), + ), + SettingRow( + title: l.prototypeAccessLog, + subtitle: l.prototypeAccessLogHint, + leading: const Icon(LucideIcons.fileText, size: 19), + decorateLeading: false, + showChevron: true, + enabled: controller.logPath(true) != null, + onTap: () => + controller.openLog(context, true, onLog), + ), + ], + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.outline( + onPressed: () => context.goPrimaryRoot( + AppPrimaryDestination.servers, + ), + leading: const Icon(LucideIcons.server, size: 17), + child: Text(l.prototypeServers), + ), + ShadButton( + onPressed: () => context.goPrimaryRoot( + AppPrimaryDestination.connect, + ), + leading: const Icon(LucideIcons.arrowLeft, size: 17), + child: Text(l.prototypeConnect), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); +} diff --git a/lib/pages/connect/routing/controller.dart b/lib/pages/connect/routing/controller.dart index 54aaf7a94..9e1ffd5b6 100644 --- a/lib/pages/connect/routing/controller.dart +++ b/lib/pages/connect/routing/controller.dart @@ -21,30 +21,42 @@ import 'package:onexray/service/shared/failure.dart'; class RoutingPageState { final ConnectionConfiguration? configuration; final List profiles; + final Map profileConfigurations; + final Map profileErrors; final bool completeConfiguration; final bool busy; final bool failed; final String? error; + final Object? failure; const RoutingPageState({ this.configuration, this.profiles = const [], + this.profileConfigurations = const {}, + this.profileErrors = const {}, this.completeConfiguration = false, this.busy = false, this.failed = false, this.error, + this.failure, }); } +const _unchanged = Object(); + class RoutingPageController extends PageCubit { final AppDatabase db; final ConnectionCoordinator coordinator; final List> _subscriptions = []; int _revision = 0; + int _loadRevision = 0, _configurationRevision = 0, _profilesRevision = 0; + int _operationRevision = 0; + final Future> Function()? readProfiles; RoutingPageController({ AppDatabase? database, ConnectionCoordinator? coordinator, + this.readProfiles, }) : db = database ?? AppDatabase(), coordinator = coordinator ?? ConnectionCoordinator.instance, super(const RoutingPageState()) { @@ -55,72 +67,116 @@ class RoutingPageController extends PageCubit { void _update({ ConnectionConfiguration? configuration, List? profiles, + Map? profileConfigurations, + Map? profileErrors, bool? completeConfiguration, bool? busy, bool? failed, - String? error, + Object? error = _unchanged, + Object? failure = _unchanged, }) => emit( RoutingPageState( configuration: configuration ?? state.configuration, profiles: profiles ?? state.profiles, + profileConfigurations: + profileConfigurations ?? state.profileConfigurations, + profileErrors: profileErrors ?? state.profileErrors, completeConfiguration: completeConfiguration ?? state.completeConfiguration, busy: busy ?? state.busy, failed: failed ?? state.failed, - error: error, + error: identical(error, _unchanged) ? state.error : error as String?, + failure: identical(failure, _unchanged) ? state.failure : failure, ), ); Future load() async { - _update(failed: false); + final revision = ++_loadRevision; + final configurationRevision = _configurationRevision; + final profilesRevision = _profilesRevision; + final operationRevision = _operationRevision; + _update(failed: false, error: null, failure: null); try { final configuration = await coordinator.configuration; - final rows = await db.routingProfileDao.allRows; - for (final row in rows) { - CustomRoutingService.readConfiguration(row); + final rows = await (readProfiles?.call() ?? db.routingProfileDao.allRows); + if (!isPageActive || revision != _loadRevision) return; + if (operationRevision != _operationRevision) return; + final acceptConfiguration = + configurationRevision == _configurationRevision; + final acceptProfiles = profilesRevision == _profilesRevision; + if (acceptConfiguration) { + _configurationRevision++; + _update(configuration: configuration); } - if (!isPageActive) return; - _update(configuration: configuration, profiles: rows); + if (acceptProfiles) _receiveProfiles(rows); + _update(failed: false, failure: null, error: null); await _readCompleteConfiguration(); - if (_subscriptions.isEmpty && isPageActive) { + if (_subscriptions.isEmpty && isPageActive && revision == _loadRevision) { _subscriptions.add( - db.connectionConfigDao.watch().listen((row) { - try { - _update( - configuration: ConnectionConfiguration.fromJson( + db.connectionConfigDao.watch().listen( + (row) { + if (!isPageActive) return; + try { + final configuration = ConnectionConfiguration.fromJson( jsonDecode(row.configurationJson) as Map, - ), - ); - unawaited(_readCompleteConfiguration()); - } catch (_) { - _update(failed: true); - } - }, onError: (_) => _update(failed: true)), + ); + _configurationRevision++; + _update(configuration: configuration); + unawaited(_readCompleteConfiguration()); + } catch (failure) { + _update(failed: true, failure: failure); + } + }, + onError: (Object failure) => + _update(failed: true, failure: failure), + ), ); _subscriptions.add( - db.routingProfileDao.allRowsStream.listen((rows) { - try { - for (final row in rows) { - CustomRoutingService.readConfiguration(row); - } - _update(profiles: rows); - } catch (_) { - _update(failed: true); - } - }, onError: (_) => _update(failed: true)), + db.routingProfileDao.allRowsStream.listen( + (rows) { + if (isPageActive) _receiveProfiles(rows); + }, + onError: (Object failure) => + _update(failed: true, failure: failure), + ), ); } - } catch (_) { - _update(failed: true); + } catch (failure) { + if (isPageActive && + revision == _loadRevision && + operationRevision == _operationRevision) { + _update(failed: true, failure: failure); + } } } + void _receiveProfiles(List rows) { + final configurations = {}; + final errors = {}; + // One damaged document must not hide the other routes or its own actions. + for (final row in rows) { + try { + configurations[row.id] = CustomRoutingService.readConfiguration(row); + } catch (failure) { + errors[row.id] = failure; + } + } + _profilesRevision++; + _update( + profiles: List.unmodifiable(rows), + profileConfigurations: Map.unmodifiable(configurations), + profileErrors: Map.unmodifiable(errors), + ); + } + void _runtimeChanged() => unawaited(_readCompleteConfiguration()); Future _readCompleteConfiguration() async { final configuration = state.configuration; if (configuration == null) return; final revision = ++_revision; + final configurationRevision = _configurationRevision; + final operationRevision = _operationRevision; try { final complete = configuration.connection.expert || @@ -129,20 +185,52 @@ class RoutingPageController extends PageCubit { configuration.connection, runtime: coordinator.state.value.runtime, ); - if (isPageActive && revision == _revision) { + if (isPageActive && + revision == _revision && + configurationRevision == _configurationRevision && + operationRevision == _operationRevision) { _update(completeConfiguration: complete); } - } catch (_) { - if (revision == _revision) _update(failed: true); + } catch (failure) { + if (isPageActive && + revision == _revision && + configurationRevision == _configurationRevision && + operationRevision == _operationRevision) { + _update(failed: true, failure: failure); + } } } - RoutingConfiguration configurationFor(RoutingProfileData row) => - CustomRoutingService.readConfiguration(row); + RoutingConfiguration? configurationFor(RoutingProfileData row) => + state.profileConfigurations[row.id]; + + bool canUseProfile(int? id) => + id != null && + state.profiles.any((row) => row.id == id) && + state.profileConfigurations.containsKey(id) && + !state.profileErrors.containsKey(id); + + Future _validateProfile(int id) async { + final row = await db.routingProfileDao.searchRow(id); + if (row == null) throw const CustomRoutingEditorException('missing'); + CustomRoutingService.readConfiguration(row); + } Future select(BuildContext context, TrafficMode mode, {int? id}) async { if (state.busy) return; - _update(busy: true); + if (mode == TrafficMode.custom && !canUseProfile(id)) { + _update( + error: state.profileErrors[id] == null + ? AppLocalizations.of(context)!.prototypeTemporarilyUnavailable + : appFailureMessage( + AppLocalizations.of(context)!, + state.profileErrors[id], + ), + ); + return; + } + final operation = ++_operationRevision; + _update(busy: true, error: null); try { final current = await coordinator.configuration; final complete = @@ -152,8 +240,12 @@ class RoutingPageController extends PageCubit { current.connection, runtime: coordinator.state.value.runtime, ); - if (!context.mounted || !isPageActive) return; - await applyConnectionChange( + if (!context.mounted || + !isPageActive || + operation != _operationRevision) { + return; + } + final saved = await applyConnectionChange( context, coordinator, { @@ -161,6 +253,9 @@ class RoutingPageController extends PageCubit { 'customId': mode == TrafficMode.custom ? id : null, }, affectsRuntime: !complete, + validateAssets: mode == TrafficMode.custom + ? () => _validateProfile(id!) + : null, label: (settings) { final l = AppLocalizations.of(context)!; if (mode == TrafficMode.smart) return l.prototypeSmartRouting; @@ -168,13 +263,21 @@ class RoutingPageController extends PageCubit { return state.profiles.firstWhere((row) => row.id == id).name; }, ); + if (saved != null && isPageActive && operation == _operationRevision) { + _configurationRevision++; + _update(configuration: saved); + } } catch (failure) { - if (context.mounted) + if (context.mounted && isPageActive && operation == _operationRevision) { _update( error: appFailureMessage(AppLocalizations.of(context)!, failure), ); + } } finally { - _update(busy: false); + if (operation == _operationRevision) { + _update(busy: false); + if (isPageActive) unawaited(_readCompleteConfiguration()); + } } } @@ -194,7 +297,8 @@ class RoutingPageController extends PageCubit { Future delete(BuildContext context, RoutingProfileData row) async { if (state.busy) return; final l = AppLocalizations.of(context)!; - _update(busy: true); + final operation = ++_operationRevision; + _update(busy: true, error: null); try { await CustomRoutingEditorService( database: db, @@ -219,15 +323,19 @@ class RoutingPageController extends PageCubit { : Future.value(false), ); } catch (failure) { - _update(error: appFailureMessage(l, failure)); + if (isPageActive && operation == _operationRevision) { + _update(error: appFailureMessage(l, failure)); + } } finally { - _update(busy: false); + if (operation == _operationRevision) _update(busy: false); } } @override Future disposePageResources() async { _revision++; + _loadRevision++; + _operationRevision++; coordinator.state.removeListener(_runtimeChanged); for (final subscription in _subscriptions) { await subscription.cancel(); diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index fbf4902a1..50b209497 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -47,9 +47,7 @@ class _CustomRoutingEditorPageState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - controller.setInlineEditing( - MediaQuery.sizeOf(context).width > AppLayout.mobileBreakpoint, - ); + controller.setInlineEditing(false); if (!_started) { _started = true; controller.load(context); diff --git a/lib/pages/connect/routing/custom/rule_page.dart b/lib/pages/connect/routing/custom/rule_page.dart index 4f949ce4d..1ba78a4c5 100644 --- a/lib/pages/connect/routing/custom/rule_page.dart +++ b/lib/pages/connect/routing/custom/rule_page.dart @@ -84,7 +84,7 @@ class _CustomRoutingRulePageState extends State { } } -/// The same field composition can be hosted beside a wide-screen rule list. +/// Rule edits live on their own page, independently of the ordered rule list. class CustomRoutingRuleForm extends StatelessWidget { final CustomRoutingRuleController controller; const CustomRoutingRuleForm({super.key, required this.controller}); @@ -127,7 +127,9 @@ class CustomRoutingRuleForm extends StatelessWidget { ), ShadInput( controller: controller.name, - constraints: const BoxConstraints(minHeight: 40), + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent(40), + ), padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 8, diff --git a/lib/pages/connect/routing/custom/sections.dart b/lib/pages/connect/routing/custom/sections.dart index 2b541e5b6..f6fbfe5fe 100644 --- a/lib/pages/connect/routing/custom/sections.dart +++ b/lib/pages/connect/routing/custom/sections.dart @@ -1,7 +1,6 @@ import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/theme/layout.dart'; -/// Rule editing and runtime settings adapt independently to the content width. +/// Every section shares one content surface at all window widths. class CustomRoutingSections extends StatefulWidget { final Widget rules; final Widget? editor; @@ -31,60 +30,16 @@ class _CustomRoutingSectionsState extends State { final _sniffing = GlobalKey(); @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final rules = KeyedSubtree(key: _rules, child: widget.rules); - final editor = widget.editor == null - ? null - : KeyedSubtree(key: _editor, child: widget.editor!); - final options = KeyedSubtree(key: _options, child: widget.options); - final dns = KeyedSubtree(key: _dns, child: widget.dns); - final sniffing = KeyedSubtree(key: _sniffing, child: widget.sniffing); - final Widget workspace; - if (editor != null && constraints.maxWidth >= 876) { - workspace = Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - Expanded(child: rules), - Expanded(child: editor), - ], - ); - } else { - workspace = Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [rules, ?editor], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [ - workspace, - if (constraints.maxWidth >= AppLayout.mobileBreakpoint) - Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - Expanded(child: dns), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [options, sniffing], - ), - ), - ], - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 12, - children: [options, dns, sniffing], - ), - ], - ); - }, + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + KeyedSubtree(key: _rules, child: widget.rules), + if (widget.editor case final editor?) + KeyedSubtree(key: _editor, child: editor), + KeyedSubtree(key: _options, child: widget.options), + KeyedSubtree(key: _dns, child: widget.dns), + KeyedSubtree(key: _sniffing, child: widget.sniffing), + ], ); } diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart index 484bfae1b..a4cc0988a 100644 --- a/lib/pages/connect/routing/page.dart +++ b/lib/pages/connect/routing/page.dart @@ -11,21 +11,23 @@ import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/shared/failure.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class RoutingPage extends StatefulWidget { - const RoutingPage({super.key}); + const RoutingPage({super.key, this.controller}); + final RoutingPageController? controller; @override State createState() => _RoutingPageState(); } class _RoutingPageState extends State { - final controller = RoutingPageController(); + late final controller = widget.controller ?? RoutingPageController(); @override void dispose() { - unawaited(controller.close()); + if (widget.controller == null) unawaited(controller.close()); super.dispose(); } @@ -46,9 +48,21 @@ class _RoutingPageState extends State { child: settings == null ? Center( child: state.failed - ? TextButton( - onPressed: controller.load, - child: Text(l.prototypeRetry), + ? Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + appFailureMessage(l, state.failure), + ), + const SizedBox(height: 12), + TextButton( + onPressed: controller.load, + child: Text(l.prototypeRetry), + ), + ], + ), ) : const CircularProgressIndicator(), ) @@ -85,6 +99,7 @@ class _RoutingPageState extends State { selected: settings.trafficMode == TrafficMode.smart, icon: LucideIcons.sparkles, + useKey: const ValueKey('routing-use-smart'), onUse: () => controller.select(context, TrafficMode.smart), onEdit: () => context.pushScoped( @@ -98,6 +113,7 @@ class _RoutingPageState extends State { selected: settings.trafficMode == TrafficMode.allVpn, icon: LucideIcons.shield, + useKey: const ValueKey('routing-use-all'), onUse: () => controller.select( context, TrafficMode.allVpn, @@ -121,10 +137,24 @@ class _RoutingPageState extends State { for (final row in state.profiles) _choice( context, + key: ValueKey('routing-profile-${row.id}'), title: row.name, - description: row.advanced + description: + state.profileErrors.containsKey(row.id) + ? l.prototypeTemporarilyUnavailable + : row.advanced ? l.routingAdvancedJson - : '${l.routingNormalConfiguration} · ${l.prototypeRuleCount(controller.configurationFor(row).ruleCount)}', + : '${l.routingNormalConfiguration} · ${l.prototypeRuleCount(controller.configurationFor(row)?.ruleCount ?? 0)}', + canUse: controller.canUseProfile(row.id), + useKey: ValueKey( + 'routing-use-profile-${row.id}', + ), + diagnostic: state.profileErrors[row.id] == null + ? null + : appFailureMessage( + l, + state.profileErrors[row.id], + ), selected: settings.trafficMode == TrafficMode.custom && @@ -172,12 +202,23 @@ class _RoutingPageState extends State { ), ], ), - if (state.failed || state.error != null) + if (state.failed || state.error != null) ...[ ShadAlert.destructive( - description: Text( - state.error ?? l.prototypeTemporarilyUnavailable, + description: SelectableText( + state.error ?? + appFailureMessage(l, state.failure), ), ), + if (state.failed) + Align( + alignment: AlignmentDirectional.centerStart, + child: ShadButton.outline( + enabled: !state.busy, + onPressed: controller.load, + child: Text(l.prototypeRetry), + ), + ), + ], ], ), ), @@ -189,17 +230,22 @@ class _RoutingPageState extends State { Widget _choice( BuildContext context, { + Key? key, required String title, required String description, required bool selected, required IconData icon, required VoidCallback onUse, + Key? useKey, + bool canUse = true, + String? diagnostic, VoidCallback? onEdit, VoidCallback? onDelete, }) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); return Semantics( + key: key, selected: selected, child: Container( color: selected ? palette.selectedSurface : null, @@ -247,13 +293,23 @@ class _RoutingPageState extends State { ), ], ), + if (diagnostic != null) + Semantics( + liveRegion: true, + child: SelectableText( + diagnostic, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: palette.destructive), + ), + ), Wrap( spacing: 8, runSpacing: 8, children: [ ShadButton( + key: useKey, size: ShadButtonSize.sm, - enabled: !controller.state.busy && !selected, + enabled: !controller.state.busy && !selected && canUse, onPressed: onUse, child: Text( selected ? l.prototypeCurrentSelection : l.prototypeUse, diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index 5d94dc4c7..e19774ddb 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -142,44 +142,16 @@ class SmartRoutingEditorContent extends StatelessWidget { }); @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final direct = _directSettings(context); - final proxy = _proxySettings(context); - final dns = _dnsSettings(context); - final sniffing = _sniffingSettings(context); - final preview = _preview(context, state); - // Account for the sidebar and page gutters before choosing two columns. - if (constraints.maxWidth < AppLayout.mobileBreakpoint) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [direct, proxy, dns, sniffing, preview], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - Expanded( - child: Column( - key: const ValueKey('smart-routing-primary'), - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [direct, dns], - ), - ), - Expanded( - child: Column( - key: const ValueKey('smart-routing-secondary'), - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, - children: [proxy, sniffing, preview], - ), - ), - ], - ); - }, + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + _directSettings(context), + _proxySettings(context), + _dnsSettings(context), + _sniffingSettings(context), + _preview(context, state), + ], ); Widget _directSettings(BuildContext context) { diff --git a/lib/pages/connect/routing/smart/regions.dart b/lib/pages/connect/routing/smart/regions.dart index dc10937ab..fecdd7015 100644 --- a/lib/pages/connect/routing/smart/regions.dart +++ b/lib/pages/connect/routing/smart/regions.dart @@ -227,47 +227,9 @@ class _DirectRegionsPageState extends State { ) else SliverList.builder( - itemCount: mobile - ? visible.length - : (visible.length / 2).ceil(), - itemBuilder: (context, index) { - if (mobile) { - return _region( - context, - visible[index], - state, - ); - } - final first = index * 2; - return IntrinsicHeight( - child: Row( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Expanded( - child: _region( - context, - visible[first], - state, - ), - ), - VerticalDivider( - width: 1, - color: palette.border, - ), - Expanded( - child: first + 1 < visible.length - ? _region( - context, - visible[first + 1], - state, - ) - : const SizedBox.shrink(), - ), - ], - ), - ); - }, + itemCount: visible.length, + itemBuilder: (context, index) => + _region(context, visible[index], state), ), SliverToBoxAdapter(child: _note(context, mobile)), ], diff --git a/lib/pages/connect/routing/widgets.dart b/lib/pages/connect/routing/widgets.dart index bb648fb76..f9f6c4e21 100644 --- a/lib/pages/connect/routing/widgets.dart +++ b/lib/pages/connect/routing/widgets.dart @@ -376,7 +376,8 @@ class RoutingSettingRow extends StatelessWidget { class RoutingControlFrame extends StatelessWidget { final Widget child; - const RoutingControlFrame({super.key, required this.child}); + const RoutingControlFrame({super.key, required this.child, this.clip = true}); + final bool clip; @override Widget build(BuildContext context) => Material( @@ -385,7 +386,7 @@ class RoutingControlFrame extends StatelessWidget { side: BorderSide(color: ColorManager.palette(context).border), borderRadius: BorderRadius.circular(AppRadii.control), ), - clipBehavior: Clip.antiAlias, + clipBehavior: clip ? Clip.antiAlias : Clip.none, child: Padding(padding: const EdgeInsets.all(1), child: child), ); } @@ -404,48 +405,81 @@ class EntryCountPicker extends StatelessWidget { 34, platform: Theme.of(context).platform, ); - return RoutingControlFrame( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (final count in const [1, 2, 3]) - Semantics( - label: l.prototypeUseEntryServers(count), - selected: value == count, - button: true, - enabled: onChanged != null, - child: Tooltip( - message: l.prototypeUseEntryServers(count), - excludeFromSemantics: true, - child: SizedBox( - width: extent, - child: Material( - color: value == count - ? palette.selectedSurface - : palette.muted, - child: InkWell( - onTap: onChanged == null ? null : () => onChanged!(count), - child: Container( - constraints: BoxConstraints(minHeight: extent), - padding: const EdgeInsets.symmetric(vertical: 6), - foregroundDecoration: BoxDecoration( - border: count < 3 - ? BorderDirectional( - end: BorderSide(color: palette.border), - ) - : null, - ), - child: Center( - heightFactor: 1, - child: ExcludeSemantics( - child: Text( - '$count', - style: AppTypography.routingCount.copyWith( - color: onChanged == null - ? Theme.of(context).disabledColor - : value == count - ? palette.primaryText - : palette.mutedStrong, + return GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTapUp: onChanged == null + ? null + : (details) { + final logicalX = Directionality.of(context) == TextDirection.rtl + ? extent * 3 + 1 - details.localPosition.dx + : details.localPosition.dx - 1; + final segment = logicalX ~/ extent; + onChanged!( + segment < 0 + ? 1 + : segment > 2 + ? 3 + : segment + 1, + ); + }, + child: RoutingControlFrame( + clip: false, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final count in const [1, 2, 3]) + Semantics( + label: l.prototypeUseEntryServers(count), + selected: value == count, + button: true, + enabled: onChanged != null, + child: Tooltip( + message: l.prototypeUseEntryServers(count), + excludeFromSemantics: true, + child: SizedBox( + width: extent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onChanged == null + ? null + : () => onChanged!(count), + child: Container( + constraints: BoxConstraints(minHeight: extent), + decoration: BoxDecoration( + color: value == count + ? palette.selectedSurface + : palette.muted, + borderRadius: BorderRadiusDirectional.horizontal( + start: Radius.circular( + count == 1 ? AppRadii.control - 1 : 0, + ), + end: Radius.circular( + count == 3 ? AppRadii.control - 1 : 0, + ), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 6), + foregroundDecoration: BoxDecoration( + border: count < 3 + ? BorderDirectional( + end: BorderSide(color: palette.border), + ) + : null, + ), + child: Center( + heightFactor: 1, + child: ExcludeSemantics( + child: Text( + '$count', + style: AppTypography.routingCount.copyWith( + color: onChanged == null + ? Theme.of(context).disabledColor + : value == count + ? palette.primaryText + : palette.mutedStrong, + ), ), ), ), @@ -455,8 +489,8 @@ class EntryCountPicker extends StatelessWidget { ), ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/pages/main/navigation.dart b/lib/pages/main/navigation.dart index cceee504b..460a40818 100644 --- a/lib/pages/main/navigation.dart +++ b/lib/pages/main/navigation.dart @@ -41,6 +41,10 @@ enum AppPageDestination { settings("settings"), serversImport("servers-import"), serverGroup("server-group"), + serverResource("server-resource"), + sourceDetails("source-details"), + serverManualGroups("server-manual-groups"), + xrayDiagnostics("xray-diagnostics"), manualServerOrder("manual-server-order"), serverEditor("server-editor"), serverFinalExitPicker("server-final-exit-picker"), @@ -80,12 +84,7 @@ enum AppPageDestination { const AppPageDestination(this.segment); - static const adaptiveDialogs = { - serversImport, - serverEditor, - share, - subscriptionEdit, - }; + static const adaptiveDialogs = {share}; } extension AppNavigationContext on BuildContext { diff --git a/lib/pages/main/url.dart b/lib/pages/main/url.dart index 10cb68bf9..6e6543f44 100644 --- a/lib/pages/main/url.dart +++ b/lib/pages/main/url.dart @@ -1,6 +1,8 @@ import 'package:material_ui/material_ui.dart'; import 'package:go_router/go_router.dart'; import 'package:onexray/core/tools/platform.dart'; +import 'package:onexray/core/pigeon/host_api.dart'; +import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/xray/log/page.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; @@ -39,6 +41,9 @@ import 'package:onexray/pages/launch/setup/page.dart'; import 'package:onexray/pages/launch/setup/selectors.dart'; import 'package:onexray/pages/servers/import/page.dart'; import 'package:onexray/pages/servers/page.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/servers/view.dart'; +import 'package:onexray/pages/advanced/xray/diagnostics.dart'; import 'package:onexray/pages/servers/order/controller.dart'; import 'package:onexray/pages/servers/order/page.dart'; import 'package:onexray/pages/connect/page.dart'; @@ -227,6 +232,38 @@ final _pageRoutes = <_PageRoute>[ _route(AppPageDestination.tunnel, (_, _) => const TunnelRootPage()), _route(AppPageDestination.xray, (_, _) => const XrayRootPage()), _route(AppPageDestination.settings, (_, _) => const SettingsPage()), + _route( + AppPageDestination.serverResource, + (_, state) => _withExtra( + state, + (params) => ServerResourcePage(params: params), + ), + ), + _route( + AppPageDestination.sourceDetails, + (_, state) => _withExtra( + state, + (params) => ServerSourcePage(params: params), + ), + ), + _route( + AppPageDestination.serverManualGroups, + (_, state) => _withExtra( + state, + (controller) => ServerManualGroupsPage(controller: controller), + ), + ), + _route( + AppPageDestination.xrayDiagnostics, + (_, _) => XrayDiagnosticsPage( + onLog: (context, params) => + context.pushScoped(AppPageDestination.logFile, extra: params), + onConfig: (context, params) => context.pushScoped( + AppPageDestination.configFileViewer, + extra: params, + ), + ), + ), _route(AppPageDestination.localApi, (_, _) => const LocalApiPage()), _route(AppPageDestination.lanProxy, (_, _) => const LanProxyPage()), _route(AppPageDestination.serverDialing, (_, _) => const ServerDialingPage()), @@ -403,7 +440,7 @@ final _pageRoutes = <_PageRoute>[ AppPageDestination.logFile, (_, state) => _withExtra( state, - (params) => LogFileViewerPage(params: params), + (params) => _LogRouteGate(params: params), ), ), _route( @@ -498,3 +535,55 @@ class _InvalidRouteDialog extends StatelessWidget { ); } } + +/// Capability checks precede construction so a direct route cannot read SE logs. +class _LogRouteGate extends StatefulWidget { + const _LogRouteGate({required this.params}); + final LogFileViewerParams params; + @override + State<_LogRouteGate> createState() => _LogRouteGateState(); +} + +class _LogRouteGateState extends State<_LogRouteGate> { + late Future _systemExtension = AppHostApi().useSystemExtension(); + @override + Widget build(BuildContext context) => FutureBuilder( + future: _systemExtension, + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == false) { + return LogFileViewerPage(params: widget.params); + } + final l = AppLocalizations.of(context)!; + return Scaffold( + appBar: PageAppBar(title: Text(l.prototypeLogs)), + body: SafeArea( + child: Center( + child: snapshot.connectionState != ConnectionState.done + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + snapshot.hasError + ? appFailureMessage(l, snapshot.error) + : l.logsUnavailableSystemExtension, + ), + if (snapshot.hasError) + TextButton( + onPressed: () => setState( + () => _systemExtension = AppHostApi() + .useSystemExtension(), + ), + child: Text(l.prototypeRetry), + ), + ], + ), + ), + ), + ), + ); + }, + ); +} diff --git a/lib/pages/servers/catalog.dart b/lib/pages/servers/catalog.dart index 291c61c42..11087b3f0 100644 --- a/lib/pages/servers/catalog.dart +++ b/lib/pages/servers/catalog.dart @@ -48,7 +48,7 @@ mixin ServerLabels { row.delay == PingDelayConstants.unknown ? l.prototypeNotTested : !PingDelayConstants.isSuccessful(row.delay) - ? l.prototypeTemporarilyUnavailable + ? l.serverLatencyFailed : row.delay <= 500 ? l.prototypeFastLatency(row.delay) : row.delay <= 1000 diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index bc49513a9..17e70dba9 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -35,6 +35,8 @@ import 'package:onexray/service/servers/server.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/connect/raw/editor.dart'; +import 'package:onexray/service/connect/resolver.dart'; import 'package:onexray/service/shared/ping/service.dart'; import 'package:onexray/service/servers/subscription/service.dart'; import 'package:onexray/service/servers/subscription/failure.dart'; @@ -73,13 +75,15 @@ class ServersPageState { this.selectingServers, this.sortSaving = false, Map sourceErrors = const {}, + List rawConfigurations = const [], }) : configuration = configuration ?? ConnectionConfiguration(), catalog = catalog ?? ServerCatalog(), customRoutes = List.unmodifiable(customRoutes), pendingServerActions = Set.unmodifiable(pendingServerActions), serverTests = Map.unmodifiable(serverTests), favoritingServerIds = Set.unmodifiable(favoritingServerIds), - sourceErrors = Map.unmodifiable(sourceErrors); + sourceErrors = Map.unmodifiable(sourceErrors), + rawConfigurations = List.unmodifiable(rawConfigurations); final ConnectionConfiguration configuration; final ConnectionView connectionView; @@ -99,6 +103,7 @@ class ServersPageState { final ServerSelection? selectingServers; final bool sortSaving; final Map sourceErrors; + final List rawConfigurations; ServersPageState copyWith({ ConnectionConfiguration? configuration, @@ -116,6 +121,7 @@ class ServersPageState { Object? selectingServers = _unchanged, bool? sortSaving, Map? sourceErrors, + List? rawConfigurations, }) => ServersPageState( configuration: configuration ?? this.configuration, connectionView: connectionView ?? this.connectionView, @@ -136,6 +142,7 @@ class ServersPageState { : selectingServers as ServerSelection?, sortSaving: sortSaving ?? this.sortSaving, sourceErrors: sourceErrors ?? this.sourceErrors, + rawConfigurations: rawConfigurations ?? this.rawConfigurations, ); } @@ -174,6 +181,7 @@ class ServersController extends PageCubit set configuration(ConnectionConfiguration value) => emit(state.copyWith(configuration: value)); List get customRoutes => state.customRoutes; + List get rawConfigurations => state.rawConfigurations; set customRoutes(List value) => emit(state.copyWith(customRoutes: value)); ConnectionView get connectionView => state.connectionView; @@ -201,6 +209,12 @@ class ServersController extends PageCubit onError: _readFailed, ), ); + _subscriptions.add( + db.coreConfigDao.allRawRowsWithDataStream.listen( + (rows) => emit(state.copyWith(rawConfigurations: rows)), + onError: _readFailed, + ), + ); _subscriptions.add( db.connectionConfigDao.watch().listen((row) { configuration = ConnectionConfiguration.fromJson( @@ -348,7 +362,10 @@ class ServersController extends PageCubit : null; return count > 0 && rows - .where((row) => row.id != finalExit && catalog.selectable(row)) + .where( + (row) => + row.id != finalExit && ServerAssetService.selectable(row), + ) .take(count) .length >= count; @@ -360,7 +377,8 @@ class ServersController extends PageCubit jsonEncode(selection.toJson()); // Display only: running identities come from the active runtime. Offline - // previews use existing successful probes, never start a probe or select VPN. + // previews use the resolver's readable-candidate order, never start a probe + // or change a connection plan. ({List names, CoreConfigData? first}) get _displaySelection { final settings = configuration.connection; if (settings.expert) return (names: [], first: null); @@ -396,23 +414,18 @@ class ServersController extends PageCubit } } final count = entryCount(selection); - final rows = - servers.where((row) { - if (row.id == settings.finalExitId || - !catalog.outboundEligible(row) || - !ServerAssetService.healthy(row) || - !catalog.selectable(row)) { - return false; - } - return switch (selection.kind) { - SelectionKind.automatic => true, - SelectionKind.source => row.subId == selection.id, - SelectionKind.server => row.id == selection.id, - }; - }).toList()..sort((a, b) { - final delay = a.delay.compareTo(b.delay); - return delay == 0 ? a.id.compareTo(b.id) : delay; - }); + final rows = servers.where((row) { + if (row.id == settings.finalExitId || + !catalog.outboundEligible(row) || + !ServerAssetService.selectable(row)) { + return false; + } + return switch (selection.kind) { + SelectionKind.automatic => true, + SelectionKind.source => row.subId == selection.id, + SelectionKind.server => row.id == selection.id, + }; + }).toList()..sort(ConnectionResolver.compareCandidates); if (count <= 0 || rows.length < count) return (names: [], first: null); return ( names: rows.take(count).map(serverName).toList(), @@ -549,14 +562,83 @@ class ServersController extends PageCubit required bool mobile, }) async { activeGroupId = group.id; - if (mobile) { - await context.pushScoped( - AppPageDestination.serverGroup, - extra: ServerGroupParams(this, group.id), + await context.pushScoped( + AppPageDestination.serverGroup, + extra: ServerGroupParams(this, group.id), + ); + } + + Future editRaw(BuildContext context, [int? id]) => + context.pushScoped(AppPageDestination.rawEditor, extra: id); + + Future chooseRaw(BuildContext context, CoreConfigData row) async { + if (busy || serverBusy(row)) return; + selecting = ServerSelection.server(row.id); + try { + Future validate() async { + await RawEditorService( + database: db, + coordinator: coordinator, + ).validateSelection(row.id); + } + + if (!await runConnectionAction(context, coordinator, validate)) return; + if (!context.mounted || !isPageActive) return; + final current = await coordinator.configuration; + if (!context.mounted || !isPageActive) return; + if (current.connection.expert && current.connection.rawId == row.id) { + configuration = current; + return; + } + final saved = await applyConnectionChange( + context, + coordinator, + {'expert': true, 'rawId': row.id}, + validateAssets: validate, + writeAssets: validate, + label: (_) => row.name, ); + if (saved != null) configuration = saved; + } catch (error, stackTrace) { + if (context.mounted && isPageActive) { + await runConnectionAction(context, coordinator, () async { + Error.throwWithStackTrace(error, stackTrace); + }); + } + } finally { + selecting = null; } } + Future removeRaw(BuildContext context, CoreConfigData row) async { + if (busy || serverBusy(row)) return; + await perform(context, () async { + await RawEditorService(database: db, coordinator: coordinator).delete( + row, + confirm: (selected, reconnect, disconnect) async { + if (!context.mounted || !isPageActive) return false; + final l = AppLocalizations.of(context)!; + final confirmed = await showDestructiveConfirmationDialog( + context, + title: l.prototypeDeleteRawQuestion, + subtitle: row.name, + warning: disconnect + ? l.prototypeRawDeleteDisconnectNotice + : selected + ? l.prototypeActiveRawDeleteNotice + : l.prototypeRawDeleteNotice, + confirmLabel: disconnect + ? l.prototypeDeleteAndDisconnect + : reconnect + ? l.prototypeDeleteAndReconnect + : l.prototypeDelete, + ); + return confirmed && context.mounted && isPageActive; + }, + ); + }, ids: {row.id}); + } + Future choose(BuildContext context, ServerSelection selection) async { if (busy) return; if (selection.kind == SelectionKind.source && diff --git a/lib/pages/servers/details.dart b/lib/pages/servers/details.dart new file mode 100644 index 000000000..af43c8e29 --- /dev/null +++ b/lib/pages/servers/details.dart @@ -0,0 +1,484 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/subscription/user_info.dart'; +import 'package:onexray/pages/shared/share/params.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/service/servers/subscription/user_info.dart'; +import 'package:onexray/service/servers/catalog.dart'; +import 'package:onexray/pages/main/navigation.dart'; + +class ServerResourceParams { + const ServerResourceParams({ + required this.controller, + required this.resourceId, + this.raw = false, + }); + final ServersController controller; + final int resourceId; + final bool raw; +} + +class ServerSourceParams { + const ServerSourceParams({required this.controller, required this.sourceId}); + final ServersController controller; + final int sourceId; +} + +/// A resource is inspected before an explicit action changes the connection. +class ServerResourcePage extends StatelessWidget { + const ServerResourcePage({super.key, required this.params}); + final ServerResourceParams params; + ServersController get controller => params.controller; + int get resourceId => params.resourceId; + bool get raw => params.raw; + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: controller, + builder: (context, state) { + final l = AppLocalizations.of(context)!; + final row = (raw ? state.rawConfigurations : state.servers) + .where((row) => row.id == resourceId) + .firstOrNull; + final name = row == null + ? l.prototypeServers + : raw + ? row.name + : controller.serverName(row); + final provider = row != null && !raw && controller.providerRow(row); + final chosen = + row != null && + (raw + ? state.configuration.connection.expert && + state.configuration.connection.rawId == row.id + : controller.chosen(row)); + final busy = + row != null && (controller.busy || controller.serverBusy(row)); + return Scaffold( + appBar: PageAppBar(title: Text(name)), + body: SafeArea( + child: ResponsiveContent( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (row == null) + Padding( + padding: const EdgeInsets.all(24), + child: Text(l.prototypeNoServersYet), + ) + else ...[ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: busy || (!raw && !controller.canChoose(row)) + ? null + : () => raw + ? controller.chooseRaw(context, row) + : controller.chooseRow(context, row), + icon: controller.selecting?.id == row.id + ? const ButtonProgressIndicator() + : Icon( + chosen + ? LucideIcons.circleCheck + : LucideIcons.check, + size: 17, + ), + label: Text( + chosen ? l.prototypeCurrentSelection : l.prototypeUse, + ), + ), + OutlinedButton.icon( + onPressed: busy + ? null + : () => raw + ? controller.editRaw(context, row.id) + : controller.serverAction( + context, + row, + ServerAction.edit, + ), + icon: Icon( + provider ? LucideIcons.fileJson : LucideIcons.pencil, + size: 17, + ), + label: Text( + provider + ? l.providerViewConfiguration + : raw + ? l.prototypeEditRawJson + : l.prototypeEditServer, + ), + ), + if (!raw && !provider) + OutlinedButton.icon( + onPressed: busy + ? null + : () => controller.test(context, [row]), + icon: const Icon(LucideIcons.activity, size: 17), + label: Text(l.prototypeTestAgain), + ), + ], + ), + const SizedBox(height: 20), + SettingSection( + title: raw + ? l.xrayRawPageTitle + : provider + ? l.providerConfigurationTitle + : l.serverNodesTab, + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: name, + subtitle: raw + ? l.prototypeCompleteXrayConfiguration + : controller.protocol(row), + leading: Icon( + raw || provider + ? LucideIcons.fileJson + : LucideIcons.server, + size: 20, + ), + titleTrailing: chosen + ? Icon( + LucideIcons.circleCheck, + size: 16, + color: ColorManager.palette(context).primary, + ) + : null, + ), + SettingRow( + title: l.prototypeSource, + value: raw + ? l.prototypeManualAdditions + : controller.sourceName(l, row), + onTap: row.subId == 0 + ? null + : () => context.pushScoped( + AppPageDestination.sourceDetails, + extra: ServerSourceParams( + controller: controller, + sourceId: row.subId, + ), + ), + ), + if (!raw && !provider) + SettingRow( + title: l.prototypeSpeedTest, + value: controller.health(l, row), + ), + ], + ), + const SizedBox(height: 20), + if (raw || provider) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + raw + ? l.prototypeRawManagedSettingsNotice + : l.providerViewConfigurationHint, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).mutedForeground, + ), + ), + ), + const SizedBox(height: 20), + ], + SettingSection( + title: l.prototypeMoreActions, + padding: EdgeInsets.zero, + children: [ + if (!raw) + SettingRow( + title: provider + ? l.providerCopyAsRaw + : l.prototypeSaveAsLocalServer, + subtitle: provider + ? l.providerCopyAsRawHint + : l.prototypeLocalCopyHint, + leading: const Icon(LucideIcons.copy, size: 18), + enabled: !busy, + onTap: () => controller.serverAction( + context, + row, + ServerAction.copy, + ), + ), + if (!raw && !provider) + SettingRow( + title: row.favorite + ? l.prototypeRemoveFavorite + : l.prototypeAddFavorite, + leading: const Icon(LucideIcons.star, size: 18), + enabled: !busy, + onTap: () => controller.toggleFavorite(context, row), + ), + if (!raw && ServerCatalog.isManual(row)) + SettingRow( + title: l.manualGroupMove, + leading: const Icon( + LucideIcons.folderInput, + size: 18, + ), + enabled: !busy, + onTap: () => controller.serverAction( + context, + row, + ServerAction.move, + ), + ), + SettingRow( + title: l.prototypeShare, + leading: const Icon(LucideIcons.share2, size: 18), + enabled: !busy, + onTap: () => raw + ? controller.shareAsset( + context, + SharePageParams(ShareType.config, row.id), + ) + : controller.serverAction( + context, + row, + ServerAction.share, + ), + ), + SettingRow( + title: l.prototypeDelete, + leading: const Icon(LucideIcons.trash2, size: 18), + enabled: !busy, + onTap: () => raw + ? controller.removeRaw(context, row) + : controller.serverAction( + context, + row, + ServerAction.delete, + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + }, + ); +} + +class ServerSourcePage extends StatelessWidget { + const ServerSourcePage({super.key, required this.params}); + final ServerSourceParams params; + ServersController get controller => params.controller; + int get sourceId => params.sourceId; + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: controller, + builder: (context, state) { + final l = AppLocalizations.of(context)!; + final source = state.sources + .where((source) => source.id == sourceId) + .firstOrNull; + final rows = state.servers.where((row) => row.subId == sourceId).toList(); + final busy = controller.sourceBusy(sourceId); + final error = state.sourceErrors[sourceId]; + final package = source == null + ? null + : SubscriptionUserInfo.fromSubscription(source); + return Scaffold( + appBar: PageAppBar( + title: Text(source?.name ?? l.prototypeSubscriptions), + ), + body: SafeArea( + child: ResponsiveContent( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (source == null) + Padding( + padding: const EdgeInsets.all(24), + child: Text(l.prototypeNoServersYet), + ) + else ...[ + if (error != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Semantics( + liveRegion: true, + child: SelectableText( + error, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).destructive, + ), + ), + ), + ), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: busy + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.update, + ), + icon: busy + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.refreshCw, size: 17), + label: Text(l.prototypeCheckForUpdates), + ), + OutlinedButton.icon( + onPressed: busy + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.edit, + ), + icon: const Icon(LucideIcons.pencil, size: 17), + label: Text(l.prototypeEditSubscription), + ), + ], + ), + const SizedBox(height: 20), + SettingSection( + title: l.prototypeSubscriptions, + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: l.prototypeSubscriptionName, + value: source.name, + ), + SettingRow( + title: l.prototypeSubscriptionLink, + subtitle: source.url, + valueMaxLines: 4, + ), + SettingRow( + title: l.subscriptionPanelTitle, + value: source.panel == 'remnawave' + ? 'Remnawave' + : l.subscriptionPanelGeneric, + ), + SettingRow( + title: l.subscriptionModeTitle, + value: controller.providerSource(source) + ? l.subscriptionModeProviderManaged + : l.subscriptionModeAppManaged, + ), + SettingRow( + title: l.subscriptionUserAgentTitle, + subtitle: source.userAgent?.isNotEmpty == true + ? source.userAgent + : l.subscriptionUserAgentHint, + ), + ], + ), + if (package != null) ...[ + const SizedBox(height: 20), + SettingSection( + title: '', + padding: EdgeInsets.zero, + children: [SubscriptionPackageDetails(info: package)], + ), + ], + const SizedBox(height: 20), + SettingSection( + title: controller.providerSource(source) + ? l.providerConfigurationTitle + : l.serverNodesTab, + description: controller.providerSource(source) + ? l.providerChooseConfigurationHint + : l.prototypeServerCount(rows.length), + padding: EdgeInsets.zero, + children: [ + if (rows.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text(l.subscriptionModeNeedsRefresh), + ), + for (final row in rows) + SettingRow( + title: controller.serverName(row), + subtitle: controller.protocol(row), + leading: Icon( + controller.providerRow(row) + ? LucideIcons.fileJson + : LucideIcons.server, + size: 19, + ), + onTap: () => context.pushScoped( + AppPageDestination.serverResource, + extra: ServerResourceParams( + controller: controller, + resourceId: row.id, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + SettingSection( + title: l.prototypeMoreActions, + padding: EdgeInsets.zero, + children: [ + if (!controller.providerSource(source)) + SettingRow( + title: l.prototypeTestServers, + enabled: !busy && rows.isNotEmpty, + leading: const Icon(LucideIcons.activity, size: 18), + onTap: () => controller.test( + context, + rows, + groupId: 'subscription:$sourceId', + ), + ), + SettingRow( + title: l.prototypeShareSubscription, + enabled: !busy, + leading: const Icon(LucideIcons.share2, size: 18), + onTap: () => controller.sourceAction( + context, + source, + SourceAction.share, + ), + ), + SettingRow( + title: l.prototypeDeleteSource, + enabled: !busy, + leading: const Icon(LucideIcons.trash2, size: 18), + onTap: () => controller.sourceAction( + context, + source, + SourceAction.delete, + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + }, + ); +} diff --git a/lib/pages/servers/editor/page.dart b/lib/pages/servers/editor/page.dart index baf00cfa6..36de57233 100644 --- a/lib/pages/servers/editor/page.dart +++ b/lib/pages/servers/editor/page.dart @@ -1,4 +1,5 @@ import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/servers/form_page.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/core/errors/json_diagnostic.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -7,7 +8,6 @@ import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; class ServerEditorPage extends StatelessWidget { @@ -37,7 +37,7 @@ class ServerEditorPage extends StatelessWidget { onPopInvokedWithResult: (didPop, result) { if (!didPop) controller.closePage(context); }, - child: AppDialog( + child: ServerFormPage( title: l.prototypeEditServer, subtitle: state.name, onClose: () => controller.closePage(context), diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart new file mode 100644 index 000000000..dbd4553ab --- /dev/null +++ b/lib/pages/servers/form_page.dart @@ -0,0 +1,84 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; + +/// Full content presentation for import and server editing. Choice and +/// confirmation dialogs keep their existing modal surfaces. +class ServerFormPage extends StatelessWidget { + const ServerFormPage({ + super.key, + required this.title, + this.subtitle, + required this.body, + this.actions = const [], + this.onBack, + this.onClose, + }); + final String title; + final String? subtitle; + final Widget body; + final List actions; + final VoidCallback? onBack, onClose; + + @override + Widget build(BuildContext context) => CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.escape): () => + (onBack ?? onClose)?.call(), + }, + child: Focus( + autofocus: true, + child: Scaffold( + appBar: PageAppBar( + title: Text(title), + leading: BackButton(onPressed: onBack ?? onClose), + actions: [ + if (onBack != null && onClose != null) + IconButton( + tooltip: AppLocalizations.of(context)!.prototypeCloseDialog, + onPressed: onClose, + icon: const Icon(LucideIcons.x, size: 20), + ), + ], + ), + body: SafeArea( + child: ResponsiveContent( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (subtitle?.isNotEmpty == true) + Padding( + padding: const EdgeInsetsDirectional.fromSTEB( + 16, + 16, + 16, + 0, + ), + child: Text( + subtitle!, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).mutedForeground, + ), + ), + ), + body, + const SizedBox(height: 24), + ], + ), + ), + ), + ), + bottomNavigationBar: actions.isEmpty + ? null + : PageActionBar(children: actions), + ), + ), + ); +} diff --git a/lib/pages/servers/import/controller.dart b/lib/pages/servers/import/controller.dart index f64fc1992..cf928f472 100644 --- a/lib/pages/servers/import/controller.dart +++ b/lib/pages/servers/import/controller.dart @@ -11,7 +11,6 @@ import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/servers/import/page.dart'; import 'package:onexray/pages/servers/subscription/format_confirmation.dart'; -import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/service/servers/subscription/failure.dart'; @@ -478,7 +477,7 @@ class ServerImportController extends PageCubit { if (action == ServerImportAction.json && jsonText.text.isEmpty) { jsonText.text = '{\n "outbounds": []\n}'; } - result = await showAppDialog( + result = await _pushServerImportStep( context, (dialogContext) => ServerImportFormPage( controller: this, @@ -561,7 +560,7 @@ class ServerImportController extends PageCubit { emit(state.copyWith(busy: false, openingAction: null)); } if (!isPageActive || !context.mounted || _closingFlow) return null; - return showAppDialog( + return _pushServerImportStep( context, (dialogContext) => ServerImportFormPage( controller: this, @@ -794,7 +793,7 @@ class ServerImportController extends PageCubit { final ready = preview; _previewOpen = true; try { - return await showAppDialog( + return await _pushServerImportStep( context, (dialogContext) => ServerImportPreviewPage( controller: this, @@ -1154,3 +1153,8 @@ class ServerImportController extends PageCubit { } } } + +Future _pushServerImportStep( + BuildContext context, + WidgetBuilder builder, +) => Navigator.of(context).push(MaterialPageRoute(builder: builder)); diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 8ad4201a7..96415b7dc 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -12,12 +12,12 @@ import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:onexray/pages/servers/form_page.dart'; export 'controller.dart' show ServerImportAction; @@ -64,7 +64,7 @@ class _ServersImportPageState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return PopScope( canPop: state.canClose, - child: AppDialog( + child: ServerFormPage( title: l10n.prototypeAddServersToOneXray, onClose: () => controller.closeFlow(context), body: Column( @@ -74,9 +74,7 @@ class _ServersImportPageState extends State { padding: EdgeInsets.all(mobile ? 14 : 20), child: LayoutBuilder( builder: (context, constraints) { - final width = mobile - ? constraints.maxWidth - : (constraints.maxWidth - 10) / 2; + final width = constraints.maxWidth; return Wrap( spacing: 10, runSpacing: 10, @@ -220,7 +218,7 @@ class ServerImportFormPage extends StatelessWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return PopScope( canPop: state.canClose, - child: AppDialog( + child: ServerFormPage( title: subscription ? controller.editingSubscription ? l10n.prototypeEditSubscription @@ -492,7 +490,7 @@ class ServerImportPreviewPage extends StatelessWidget { controller.closeFlow(context); } }, - child: AppDialog( + child: ServerFormPage( title: l10n.prototypeImportPreview, onBack: committed == null ? onBack : null, onClose: onClose ?? () => controller.closeFlow(context), diff --git a/lib/pages/servers/page.dart b/lib/pages/servers/page.dart index 16ccb6950..fb4a070be 100644 --- a/lib/pages/servers/page.dart +++ b/lib/pages/servers/page.dart @@ -5,14 +5,12 @@ import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; -import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/pages/servers/view.dart'; import 'package:onexray/pages/servers/sort.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; -import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; @@ -49,55 +47,32 @@ class _ServersPageState extends State { child: BlocBuilder( builder: (context, state) { final l = AppLocalizations.of(context)!; - final mobileRoot = + final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final empty = - state.ready && - !state.failed && - state.servers.isEmpty && - state.catalog.manualGroups.isEmpty; final page = Scaffold( appBar: PageAppBar( title: Text(l.prototypeServers), actions: [ - if (mobileRoot) + if (!mobile) IconButton( - tooltip: l.prototypeUpdatesAndSources, - onPressed: () => controller.openSources(context), - icon: AppActivityBuilder( - builder: (context, activity) => activity.downloading - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.refreshCw), - ), - ), - IconButton( - tooltip: l.manualGroupNew, - onPressed: () => controller.createManualGroup(context), - icon: const Icon(LucideIcons.folderPlus), - ), - if (!mobileRoot && !empty) ...[ - OutlinedButton.icon( - onPressed: () => controller.openSources(context), - icon: AppActivityBuilder( - builder: (context, activity) => activity.downloading - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.refreshCw), - ), - label: Text(l.prototypeUpdatesAndSources), + tooltip: l.manualGroupNew, + onPressed: () => controller.createManualGroup(context), + icon: const Icon(LucideIcons.folderPlus, size: 20), ), - const SizedBox(width: 10), + if (mobile) + IconButton( + key: const ValueKey('servers-add'), + tooltip: l.prototypeAddServer, + onPressed: () => controller.addServers(context), + icon: const Icon(LucideIcons.plus, size: 20), + ) + else FilledButton.icon( + key: const ValueKey('servers-add'), onPressed: () => controller.addServers(context), - icon: const Icon(LucideIcons.plus), + icon: const Icon(LucideIcons.plus, size: 18), label: Text(l.prototypeAddServer), ), - ] else if (mobileRoot) - IconButton( - color: ColorManager.palette(context).primary, - tooltip: l.prototypeAddServers, - onPressed: () => controller.addServers(context), - icon: const Icon(LucideIcons.plus), - ), ], ), body: SafeArea( @@ -162,6 +137,12 @@ class ServerGroupPage extends StatelessWidget { overflow: TextOverflow.ellipsis, ), actions: [ + if (controller.query.trim().isNotEmpty) + IconButton( + tooltip: l.prototypeClear, + onPressed: controller.search.clear, + icon: const Icon(LucideIcons.x, size: 18), + ), ServerSortButton(controller: controller), const AppActivityIndicator(pinging: false), ], diff --git a/lib/pages/servers/sources.dart b/lib/pages/servers/sources.dart index e4678648b..da0378bc7 100644 --- a/lib/pages/servers/sources.dart +++ b/lib/pages/servers/sources.dart @@ -12,6 +12,101 @@ import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/service/servers/subscription/user_info.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/main/navigation.dart'; + +/// The subscription destination shares all operations with the existing source +/// workflow, while opening a full content page for inspection. +class ServerSourcesView extends StatelessWidget { + const ServerSourcesView({super.key, required this.controller}); + final ServersController controller; + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: controller, + builder: (context, state) { + final l = AppLocalizations.of(context)!; + final query = controller.query.trim().toLowerCase(); + final sources = state.sources + .where( + (source) => + query.isEmpty || + '${source.name} ${source.url}'.toLowerCase().contains(query), + ) + .toList(); + final material = MaterialLocalizations.of(context); + String checkedAt(SubscriptionData source) => + controller.sourceCheckedLabel(l, source.timestamp) ?? + '${material.formatMediumDate(source.timestamp.toLocal())} ' + '${material.formatTimeOfDay(TimeOfDay.fromDateTime(source.timestamp.toLocal()))}'; + void open(SubscriptionData source) => context.pushScoped( + AppPageDestination.sourceDetails, + extra: ServerSourceParams(controller: controller, sourceId: source.id), + ); + return SettingSection( + title: l.prototypeSubscriptions, + padding: EdgeInsets.zero, + separated: false, + children: [ + if (sources.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Text( + query.isEmpty + ? l.prototypeNoServersYet + : l.prototypeNoMatchingSubscriptions, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + TextButton( + onPressed: query.isEmpty + ? () => controller.addServers(context) + : controller.search.clear, + child: Text( + query.isEmpty + ? l.prototypeAddSubscription + : l.prototypeClear, + ), + ), + ], + ), + ), + for (final source in sources) + Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () => open(source), + child: _SourceRow( + name: source.name, + package: SubscriptionUserInfo.fromSubscription(source), + detail: + '${controller.providerSource(source) ? l.providerConfigurationCount(controller.sourceCount(source.id)) : l.prototypeServerCount(controller.sourceCount(source.id))} · ${checkedAt(source)}', + status: state.sourceErrors[source.id] ?? l.prototypeUpdated, + failed: state.sourceErrors.containsKey(source.id), + showDivider: source != sources.last, + subscription: true, + busy: controller.sourceBusy(source.id), + onMore: () => open(source), + onUpdate: controller.sourceBusy(source.id) + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.update, + ), + ), + ), + ), + ], + ); + }, + ); +} class ServerSourcesDialog extends StatelessWidget { const ServerSourcesDialog({super.key, required this.controller}); diff --git a/lib/pages/servers/subscription/form_view.dart b/lib/pages/servers/subscription/form_view.dart index b7833ae80..43aaca560 100644 --- a/lib/pages/servers/subscription/form_view.dart +++ b/lib/pages/servers/subscription/form_view.dart @@ -325,20 +325,10 @@ class SubscriptionFormView extends StatelessWidget { onChanged: onUrlChanged, ), ]; - if (MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint) { - return Column( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: fields, - ); - } - return Row( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(flex: 3, child: fields[0]), - Expanded(flex: 5, child: fields[1]), - ], + return Column( + spacing: 14, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: fields, ); } diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index 0d266ea2a..6ef444d9c 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -4,7 +4,6 @@ import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/service/shared/event_bus/state.dart'; import 'package:onexray/service/shared/ping/batch.dart'; import 'package:onexray/service/shared/failure.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; @@ -18,588 +17,533 @@ import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/subscription/user_info.dart'; import 'package:onexray/service/servers/catalog.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/servers/sources.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:onexray/pages/main/navigation.dart'; -class ServerBrowser extends StatelessWidget { - final ServersController controller; - final ScrollController scroll; +enum _ServerBrowserTab { nodes, configurations, subscriptions } + +class ServerBrowser extends StatefulWidget { const ServerBrowser({ super.key, required this.controller, required this.scroll, }); + final ServersController controller; + final ScrollController scroll; + @override + State createState() => _ServerBrowserState(); +} + +class _ServerBrowserState extends State { + ServersController get controller => widget.controller; + _ServerBrowserTab tab = _ServerBrowserTab.nodes; + int sourceId = -1; + bool favoritesOnly = false; + int get filterSourceId => + sourceId > 0 && !controller.sources.any((source) => source.id == sourceId) + ? -1 + : sourceId; @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; - final groups = controller.groups(l); - final favorites = controller.favorites(l); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - if (controller.servers.isEmpty && controller.catalog.manualGroups.isEmpty) { - if (controller.sources.isNotEmpty) { - return PageEmptyState( - title: l.subscriptionModeNoCacheTitle, - description: l.subscriptionModeNeedsRefresh, - primaryLabel: l.prototypeUpdatesAndSources, - onPrimary: () => controller.openSources(context), - secondaryLabel: l.prototypeAddServer, - onSecondary: () => controller.addServers(context), - scrollController: scroll, - ); - } - return PageEmptyState( + if (controller.servers.isEmpty && + controller.sources.isEmpty && + controller.rawConfigurations.isEmpty && + controller.catalog.manualGroups.isEmpty) { + final empty = PageEmptyState( title: l.prototypeNoServersYet, description: l.prototypeAddProviderSubscriptionHint, primaryLabel: l.prototypeAddServer, onPrimary: () => controller.addServers(context), secondaryLabel: l.prototypeHowGetServers, onSecondary: () => controller.openServerHelp(context), - scrollController: scroll, + scrollController: widget.scroll, ); - } - if (mobile) { - return _mobileBrowser(context, groups, favorites); - } - final active = _activeGroup(groups); - final compact = - MediaQuery.sizeOf(context).width <= AppLayout.compactDesktopBreakpoint; - return Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.page, - AppSpacing.desktopPageTop, - AppSpacing.page, - AppSpacing.desktopPageBottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + if (MediaQuery.sizeOf(context).width > AppLayout.mobileBreakpoint) { + return empty; + } + return Column( children: [ - _search(context), - const SizedBox(height: 15), - Expanded( - child: Flex( - direction: compact ? Axis.vertical : Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: SingleChildScrollView( - controller: scroll, - child: _subscriptionList(context, groups, favorites), - ), - ), - if (active != null) ...[ - SizedBox(width: compact ? 0 : 16, height: compact ? 16 : 0), - Expanded( - child: ServerGroupView( - controller: controller, - group: active, - embedded: true, - ), - ), - ], - ], + Expanded(child: empty), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TextButton.icon( + onPressed: () => context.pushScoped( + AppPageDestination.serverManualGroups, + extra: controller, + ), + icon: const Icon(LucideIcons.folder, size: 18), + label: Text(l.manualGroupsTitle), ), ), ], - ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: ShadTabs<_ServerBrowserTab>( + key: const ValueKey('server-resource-tabs'), + value: tab, + scrollable: true, + tabBarAlignment: Directionality.of(context) == TextDirection.rtl + ? Alignment.centerRight + : Alignment.centerLeft, + onChanged: (value) => setState(() => tab = value), + tabs: [ + ShadTab( + value: _ServerBrowserTab.nodes, + content: const SizedBox.shrink(), + child: Text(l.serverNodesTab), + ), + ShadTab( + value: _ServerBrowserTab.configurations, + content: const SizedBox.shrink(), + child: Text(l.serverCompleteConfigurationsTab), + ), + ShadTab( + value: _ServerBrowserTab.subscriptions, + content: const SizedBox.shrink(), + child: Text(l.prototypeSubscriptions), + ), + ], + ), + ), + Expanded( + child: tab == _ServerBrowserTab.subscriptions + ? SingleChildScrollView( + controller: widget.scroll, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 28), + child: _subscriptions(context), + ) + : _resources( + context, + complete: tab == _ServerBrowserTab.configurations, + ), + ), + ], ); } - ServerGroup? _activeGroup(List groups) { - final active = groups - .where((group) => group.id == controller.activeGroupId) - .firstOrNull; - if (active != null) return active; - final currentId = controller.currentGroupId; - return groups.where((group) => group.id == currentId).firstOrNull ?? - groups.firstOrNull; + Widget _search(BuildContext context) { + final l = AppLocalizations.of(context)!; + return TextField( + key: const ValueKey('server-search'), + controller: controller.search, + onChanged: controller.searchChanged, + decoration: InputDecoration( + hintText: l.prototypeSearchSubscriptionsServers, + prefixIcon: const Icon(LucideIcons.search, size: 18), + suffixIcon: controller.search.text.isEmpty + ? null + : IconButton( + key: const ValueKey('server-search-clear'), + tooltip: l.prototypeClear, + onPressed: controller.search.clear, + icon: const Icon(LucideIcons.x, size: 18), + ), + ), + ); } - Widget _mobileBrowser( - BuildContext context, - List groups, - List favorites, - ) { - return ListView( - controller: scroll, - padding: const EdgeInsets.fromLTRB(15, 13, 15, 22), - children: [ - _search(context), - const SizedBox(height: 12), - _subscriptionList(context, groups, favorites), - ], - ); + bool _matchesSource(CoreConfigData row) => + filterSourceId == -1 || row.subId == filterSourceId; + bool _matchesRaw(CoreConfigData row) { + final query = controller.query.trim().toLowerCase(); + return (filterSourceId == -1 || filterSourceId == 0) && + (query.isEmpty || + row.name.toLowerCase().contains(query) || + AppLocalizations.of(context)!.xrayRawPageTitle + .toLowerCase() + .contains(query)); } - Widget _search(BuildContext context) { + Widget _resources(BuildContext context, {required bool complete}) { final l = AppLocalizations.of(context)!; + final ordinary = controller.servers + .where((row) => controller.providerRow(row) == complete) + .where(_matchesSource) + .where((row) => controller.matches(l, row)) + .where((row) => complete || !favoritesOnly || row.favorite) + .toList(); + final raw = complete + ? controller.rawConfigurations.where(_matchesRaw).toList() + : []; + final count = ordinary.length + raw.length; + final validSource = filterSourceId; + final filtered = + controller.query.trim().isNotEmpty || + validSource != -1 || + (!complete && favoritesOnly); final palette = ColorManager.palette(context); - final border = OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadii.card), - borderSide: BorderSide(color: palette.border), - ); - final searchHeight = AppLayout.touchExtent( - 43, - platform: Theme.of(context).platform, - ); - return SizedBox( - height: searchHeight, - child: Row( - children: [ - Expanded( - child: TextField( - controller: controller.search, - onChanged: controller.searchChanged, - textAlignVertical: TextAlignVertical.center, - style: AppTypography.serverBody.copyWith( - color: palette.foreground, - ), - decoration: InputDecoration( - isDense: true, - contentPadding: const EdgeInsetsDirectional.only(end: 13), - hintText: l.prototypeSearchSubscriptionsServers, - hintStyle: AppTypography.serverBody.copyWith( - color: palette.mutedForeground, + return CustomScrollView( + controller: widget.scroll, + key: PageStorageKey(('server-resources', complete)), + slivers: [ + SliverPadding( + padding: const EdgeInsets.fromLTRB(16, 18, 16, 10), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _search(context), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 140), + child: ShadSelect( + key: ValueKey(('server-source-filter', validSource)), + initialValue: validSource, + selectedOptionBuilder: (_, value) => Text( + value == -1 + ? l.serverAllSources + : value == 0 + ? l.prototypeManualAdditions + : controller.sources + .firstWhere((source) => source.id == value) + .name, + ), + options: [ + ShadOption( + value: -1, + child: Text(l.serverAllSources), + ), + ShadOption( + value: 0, + child: Text(l.prototypeManualAdditions), + ), + for (final source in controller.sources) + ShadOption( + value: source.id, + child: Text(source.name), + ), + ], + onChanged: (value) { + if (value != null) setState(() => sourceId = value); + }, + ), + ), + if (!complete) ...[ + ServerSortButton(controller: controller), + IconButton( + tooltip: l.prototypeFavorites, + isSelected: favoritesOnly, + onPressed: () => + setState(() => favoritesOnly = !favoritesOnly), + icon: const Icon(LucideIcons.star, size: 18), + ), + const AppActivityIndicator( + key: ValueKey('server-probe-activity'), + downloading: false, + ), + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.automatic(), + ), + icon: const Icon(LucideIcons.shuffle, size: 17), + label: Text(l.prototypeAutomaticSelection), + ), + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.source(0), + ), + icon: const Icon(LucideIcons.layers, size: 17), + label: Text(l.prototypeManualAdditions), + ), + ] else + FilledButton.icon( + onPressed: () => controller.editRaw(context), + icon: const Icon(LucideIcons.plus, size: 17), + label: Text(l.prototypeAddRawJson), + ), + ], ), - prefixIconConstraints: BoxConstraints.tightFor( - width: 41, - height: searchHeight, + if (!complete) ...[ + const SizedBox(height: 16), + SettingSection( + title: '', + padding: EdgeInsets.zero, + children: [ + SettingRow( + title: l.manualGroupsTitle, + subtitle: l.manualGroupCount( + controller.catalog.manualGroups.length, + ), + leading: const Icon(LucideIcons.folder, size: 19), + onTap: () => context.pushScoped( + AppPageDestination.serverManualGroups, + extra: controller, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Text( + complete + ? l.serverCompleteConfigurationsTab + : l.serverNodesTab, + style: AppTypography.sectionTitle, ), - prefixIcon: Padding( - padding: const EdgeInsetsDirectional.only(start: 13, end: 10), - child: Icon( - LucideIcons.search, - size: 18, + const SizedBox(height: 4), + Text( + complete + ? l.providerConfigurationCount(count) + : l.prototypeServerCount(count), + style: AppTypography.supporting.copyWith( color: palette.mutedForeground, ), ), - suffixIcon: controller.search.text.isEmpty - ? null - : IconButton( - key: const ValueKey('server-search-clear'), - tooltip: l.prototypeClear, - onPressed: controller.search.clear, - icon: const Icon(LucideIcons.x, size: 18), - ), - border: border, - enabledBorder: border, - focusedBorder: border.copyWith( - borderSide: BorderSide(color: palette.primary), - ), - ), + ], ), ), - const SizedBox(width: 8), - ServerSortButton(controller: controller), - if (controller.catalog.manualGroups.length > 1) - ManualGroupOrderButton(controller: controller), - ], - ), - ); - } - - Widget _subscriptionList( - BuildContext context, - List groups, - List favorites, - ) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final current = controller.currentSelectionSummary(l); - final activeGroup = _activeGroup(groups); - return Material( - color: palette.card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.card), - side: BorderSide(color: palette.border), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _automaticRow(context), - if (current != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Color.lerp(palette.card, palette.selectedSurface, .45), - border: Border(top: BorderSide(color: palette.border)), - ), - child: Row( + ), + if (count == 0) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: SettingSection( + title: '', + padding: EdgeInsets.zero, children: [ - Icon( - LucideIcons.circleCheck, - size: 20, - color: palette.running, - ), - const SizedBox(width: 12), - Expanded( + Padding( + padding: const EdgeInsets.all(24), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l.prototypeCurrentSelection, - style: AppTypography.serverSelectionLabel.copyWith( - color: palette.mutedForeground, - ), - ), - const SizedBox(height: 2), - Text( - current.title, - style: AppTypography.serverSelectionTitle.copyWith( - color: palette.foreground, - ), + filtered + ? l.serverNoMatchingResources + : complete + ? l.prototypeNoRawJson + : l.prototypeNoServersYet, + textAlign: TextAlign.center, ), - if (current.detail.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - current.detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverSelectionDetail.copyWith( - color: palette.mutedForeground, + const SizedBox(height: 8), + if (filtered) + TextButton( + key: const ValueKey('server-search-reset'), + onPressed: () { + controller.search.clear(); + setState(() { + sourceId = -1; + favoritesOnly = false; + }); + }, + child: Text(l.prototypeClear), + ) + else + TextButton( + onPressed: () => complete + ? controller.editRaw(context) + : controller.addServers(context), + child: Text( + complete + ? l.prototypeAddRawJson + : l.prototypeAddServer, ), ), - ], ], ), ), ], ), ), - if (favorites.isNotEmpty) ...[ - _listHeading(context, l.prototypeFavorites), - for (final row in favorites) - ServerNodeRow(controller: controller, row: row), - ], - if (controller.servers.any(ServerCatalog.isManual)) - _allManualRow(context), - if (groups.any((group) => group.manual)) - _listHeading(context, l.manualGroupsTitle), - for (final group in groups.where((group) => group.manual)) - _groupRow(context, group, active: group.id == activeGroup?.id), - if (groups.any((group) => !group.manual)) - _listHeading(context, l.prototypeBySubscription), - for (final group in groups.where((group) => !group.manual)) - _groupRow(context, group, active: group.id == activeGroup?.id), - if (groups.isEmpty && controller.search.text.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28), - child: Column( - children: [ - Text( - l.prototypeNoMatchingSubscriptions, - textAlign: TextAlign.center, - style: AppTypography.settingsInput.copyWith( - color: palette.mutedForeground, - ), - ), - TextButton( - key: const ValueKey('server-search-reset'), - onPressed: controller.search.clear, - child: Text(l.prototypeClear), - ), - ], + ), + if (count > 0) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: DecoratedSliver( + decoration: BoxDecoration( + color: palette.card, + border: Border.all(color: palette.border), + borderRadius: BorderRadius.circular(AppRadii.card), ), - ), - if (mobile) - InkWell( - onTap: () => controller.openSources(context), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 52), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - AppActivityBuilder( - builder: (context, activity) => activity.downloading - ? const ButtonProgressIndicator() - : Icon( - LucideIcons.refreshCw, - size: 17, - color: palette.primary, - ), - ), - const SizedBox(width: 9), - Expanded( - child: Text( - l.prototypeUpdatesAndSources, - style: AppTypography.serverBody.copyWith( - color: palette.primary, - ), - ), + sliver: SliverList.builder( + itemCount: count, + itemBuilder: (context, index) { + final first = index == 0, last = index == count - 1; + final child = index < ordinary.length + ? ServerNodeRow( + key: ValueKey(('server', ordinary[index].id)), + controller: controller, + row: ordinary[index], + showDivider: !last, + ) + : Column( + children: [ + ServerRawRow( + key: ValueKey(( + 'raw', + raw[index - ordinary.length].id, + )), + controller: controller, + row: raw[index - ordinary.length], + ), + if (!last) + Divider(height: 1, color: palette.border), + ], + ); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular(first ? AppRadii.card - 1 : 0), + bottom: Radius.circular(last ? AppRadii.card - 1 : 0), ), - ], - ), - ), + child: child, + ), + ); + }, ), ), - ], - ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 28)), + ], ); } - Widget _listHeading(BuildContext context, String title) => Padding( - padding: const EdgeInsetsDirectional.fromSTEB(16, 17, 16, 8), - child: Text( - title.toUpperCase(), - style: AppTypography.serverSectionTitle.copyWith( - color: ColorManager.palette(context).mutedForeground, - ), + Widget _subscriptions(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _search(context), + const SizedBox(height: 18), + ServerSourcesView(controller: controller), + ], ), ); +} - Widget _manualMenu(BuildContext context, ServerGroup group) => IconButton( - tooltip: - '${AppLocalizations.of(context)!.prototypeMoreActions}: ${group.name}', - onPressed: controller.manualGroupBusy(group) - ? null - : () => controller.openManualGroupMenu(context, group), - icon: const Icon(LucideIcons.ellipsis, size: 18), - ); - - Widget _allManualRow(BuildContext context) { - final l = AppLocalizations.of(context)!; - const selection = ServerSelection.source(0); - final rows = controller.servers.where(ServerCatalog.isManual); - final enabled = controller.canUseSelection(selection, rows); - final selected = controller.selected(selection); - return ListTile( - leading: const Icon(LucideIcons.server), - title: Text(l.prototypeManualAdditions), - selected: selected, - trailing: OutlinedButton.icon( - onPressed: controller.busy || !enabled - ? null - : () => controller.choose(context, selection), - icon: Icon( - selected ? LucideIcons.circleCheck : LucideIcons.zap, - size: 16, - ), - label: Text(l.prototypeUse), - ), - ); - } - - Widget _automaticRow(BuildContext context) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final selected = controller.selected(const ServerSelection.automatic()); - final result = controller.automaticResult(l); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - return Semantics( - selected: selected, - button: true, - child: Material( - color: selected - ? Color.lerp(palette.card, palette.selectedSurface, .72) - : palette.card, - child: InkWell( - onTap: controller.busy - ? null - : () => controller.choose( - context, - const ServerSelection.automatic(), - ), - child: Container( - constraints: const BoxConstraints(minHeight: 82), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Row( +class ServerManualGroupsPage extends StatelessWidget { + const ServerManualGroupsPage({super.key, required this.controller}); + final ServersController controller; + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + bloc: controller, + builder: (context, state) { + final l = AppLocalizations.of(context)!; + final groups = controller + .groups(l, filter: false) + .where((group) => group.manual) + .toList(); + return Scaffold( + appBar: PageAppBar(title: Text(l.manualGroupsTitle)), + body: SafeArea( + child: ResponsiveContent( + child: ListView( + padding: const EdgeInsets.all(16), children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: palette.selectedSurface, - borderRadius: BorderRadius.circular(9), - ), - child: AppActivityBuilder( - builder: (context, activity) => - activity.pinging || - activity.downloading || - controller.selectingGroup( - const ServerSelection.automatic(), - ) - ? const Center(child: ButtonProgressIndicator()) - : Icon( - LucideIcons.zap, - size: 20, - color: palette.primary, - ), - ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: () => controller.createManualGroup(context), + icon: const Icon(LucideIcons.folderPlus, size: 17), + label: Text(l.manualGroupNew), + ), + ManualGroupOrderButton(controller: controller), + ], ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.prototypeAutomaticRecommended, - style: AppTypography.serverTitle.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 4), - Text( - l.prototypeChooseBySpeedAvailability, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverDetail.copyWith( - color: palette.mutedForeground, - ), + const SizedBox(height: 18), + SettingSection( + title: '', + padding: EdgeInsets.zero, + dividerIndent: 0, + children: [ + if (groups.isEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text(l.manualGroupEmpty), ), - if (mobile && result != null) ...[ - const SizedBox(height: 5), - Text( - result, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverSelectionHealth.copyWith( - color: palette.running, - ), - ), - ], - ], - ), - ), - if (!mobile && result != null) ...[ - const SizedBox(width: 12), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), - child: Text( - result, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverSelectionHealth.copyWith( - color: palette.running, + for (final group in groups) + SettingRow( + title: group.name, + subtitle: l.prototypeServerCount(group.rows.length), + leading: const Icon(LucideIcons.folder, size: 19), + trailing: group.manualGroupId == null + ? null + : IconButton( + tooltip: + '${l.prototypeMoreActions}: ${group.name}', + onPressed: controller.manualGroupBusy(group) + ? null + : () => controller.openManualGroupMenu( + context, + group, + ), + icon: const Icon( + LucideIcons.ellipsis, + size: 18, + ), + ), + onTap: () => + controller.browse(context, group, mobile: true), ), - ), - ), - ], - const SizedBox(width: 12), - Icon( - selected - ? LucideIcons.circleCheck - : LucideIcons.chevronRightDir, - size: selected ? 21 : 18, - color: selected ? palette.running : palette.foreground, + ], ), ], ), ), ), - ), - ); - } + ); + }, + ); +} - Widget _groupRow( - BuildContext context, - ServerGroup group, { - required bool active, - }) { +class ServerRawRow extends StatelessWidget { + const ServerRawRow({super.key, required this.controller, required this.row}); + final ServersController controller; + final CoreConfigData row; + @override + Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final package = group.source == null - ? null - : SubscriptionUserInfo.fromSubscription(group.source!); - return Material( - color: active ? palette.muted : palette.card, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Row( - children: [ - Expanded( - child: InkWell( - onTap: () => controller.browse( - context, - group, - mobile: - MediaQuery.sizeOf(context).width <= - AppLayout.mobileBreakpoint, - ), - child: Container( - constraints: const BoxConstraints(minHeight: 66), - padding: const EdgeInsetsDirectional.fromSTEB(16, 10, 6, 10), - child: Row( - children: [ - Container( - width: 38, - height: 38, - alignment: Alignment.center, - decoration: BoxDecoration( - color: palette.surfaceHover, - borderRadius: BorderRadius.circular(9), - ), - child: Icon( - LucideIcons.layers, - size: 17, - color: palette.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - group.name, - style: AppTypography.serverTitle.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 4), - Text( - controller.summary(l, group), - style: AppTypography.serverGroupDetail.copyWith( - color: palette.mutedForeground, - ), - ), - if (package != null) ...[ - const SizedBox(height: 4), - SubscriptionPackageSummary(info: package), - ], - ], - ), - ), - const SizedBox(width: 12), - Icon( - LucideIcons.chevronRightDir, - size: 18, - color: palette.foreground, - ), - ], - ), - ), - ), - ), - if (group.selection != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: ServerUseButton( - controller: controller, - group: group, - inline: true, - ), - ), - if (group.manualGroupId != null) _manualMenu(context, group), - if (group.source != null) - SizedBox( - width: 42, - child: SourceMenu( - controller: controller, - source: group.source!, - ), - ), - ], + final selected = + controller.configuration.connection.expert && + controller.configuration.connection.rawId == row.id; + final running = + controller.connectionView.phase == ConnectionPhase.connected && + controller.connectionView.runtime?.configuration.connection.expert == + true && + controller.connectionView.runtime?.configuration.connection.rawId == + row.id; + return SettingRow( + title: row.name, + subtitle: l.xrayRawPageTitle, + leading: const Icon(LucideIcons.fileJson, size: 19), + titleTrailing: selected || running + ? Icon( + running ? LucideIcons.activity : LucideIcons.circleCheck, + size: 16, + color: ColorManager.palette(context).primary, + ) + : null, + onTap: () => context.pushScoped( + AppPageDestination.serverResource, + extra: ServerResourceParams( + controller: controller, + resourceId: row.id, + raw: true, ), ), ); @@ -1143,228 +1087,103 @@ class ServerNodeRow extends StatelessWidget { required String protocol, }) { final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final conflict = controller.exitConflict(row); - final rowDetail = detail ?? controller.sourceName(l, row); - return Container( - foregroundDecoration: running - ? BoxDecoration( - border: BorderDirectional( - start: BorderSide(color: palette.primary, width: 3), + final p = ColorManager.palette(context); + return Column( + children: [ + Material( + color: chosen || running ? p.selectedSurface : p.card, + child: InkWell( + onTap: () => context.pushScoped( + AppPageDestination.serverResource, + extra: ServerResourceParams( + controller: controller, + resourceId: row.id, ), - ) - : null, - child: Material( - color: running || chosen - ? Color.lerp(palette.card, palette.selectedSurface, .72) - : palette.card, - child: Column( - children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 62), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: 42, - child: Opacity( - opacity: enabled ? 1 : .65, - child: IconButton( - tooltip: row.favorite - ? l.prototypeRemoveFavorite - : l.prototypeAddFavorite, - onPressed: controller.serverBusy(row) - ? null - : () => controller.toggleFavorite(context, row), - isSelected: row.favorite, - style: IconButton.styleFrom( - foregroundColor: palette.restarting, - iconSize: 17, - padding: EdgeInsets.zero, - minimumSize: const Size(42, 42), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: const RoundedRectangleBorder(), + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 8, 12), + child: Row( + children: [ + Icon( + controller.providerRow(row) + ? LucideIcons.fileJson + : LucideIcons.server, + size: 20, + color: p.foreground, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.serverName(row), + style: AppTypography.serverNodeTitle.copyWith( + color: p.foreground, ), - icon: controller.favoritingIds.contains(row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.star), - selectedIcon: - controller.favoritingIds.contains(row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.star600), ), - ), - ), - Expanded( - child: Opacity( - opacity: enabled ? 1 : .65, - child: Semantics( - selected: chosen, - child: InkWell( - onTap: controller.busy || conflict - ? null - : enabled - ? () => controller.chooseRow(context, row) - : () => - ServerMenu.open(context, controller, row), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 0, - 9, - 4, - 9, - ), - child: Row( - children: [ - if (controller.selectingGroup( - ServerSelection.server(row.id), - )) - const ButtonProgressIndicator() - else - Icon( - LucideIcons.server, - size: 19, - color: palette.foreground, - ), - const SizedBox(width: 11), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - controller.serverName(row), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverNodeTitle - .copyWith( - color: palette.foreground, - ), - ), - for (final label in [ - row.type, - if (protocol.isNotEmpty) protocol, - ]) ...[ - const SizedBox(height: 3), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: palette.surfaceHover, - border: Border.all( - color: palette.border, - ), - borderRadius: - BorderRadius.circular(4), - ), - child: Text( - label, - textDirection: TextDirection.ltr, - style: AppTypography - .serverProtocol - .copyWith( - color: palette.mutedStrong, - ), - ), - ), - ], - if (!controller.providerRow(row)) ...[ - const SizedBox(height: 3), - Text.rich( - TextSpan( - text: '$rowDetail · ', - children: [ - TextSpan( - text: controller.health( - l, - row, - ), - style: TextStyle( - color: - ColorManager.nodeLatency( - context, - row.delay, - ), - ), - ), - ], - ), - style: AppTypography.metadata - .copyWith( - color: - palette.mutedForeground, - ), - ), - ], - if (conflict) ...[ - const SizedBox(height: 3), - Text( - l.prototypeFinalExitEntryConflict, - style: AppTypography.metadata - .copyWith( - color: - palette.mutedForeground, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 11), - if (running) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.circleCheck, - size: 15, - color: palette.primary, - ), - const SizedBox(width: 4), - Text( - l.prototypeConnected, - style: AppTypography - .serverConnectedBadge - .copyWith(color: palette.primary), - ), - ], - ) - else if (chosen) - Icon( - LucideIcons.circleCheck, - size: 20, - color: palette.running, - ) - else - Icon( - LucideIcons.chevronRightDir, - size: 17, - color: palette.foreground, - ), - ], + const SizedBox(height: 4), + Text( + [ + if (protocol.isNotEmpty) protocol, + detail ?? controller.sourceName(l, row), + ].join(' · '), + style: AppTypography.metadata.copyWith( + color: p.mutedForeground, + ), + ), + if (!controller.providerRow(row)) ...[ + const SizedBox(height: 3), + Text( + controller.health(l, row), + style: AppTypography.metadata.copyWith( + color: ColorManager.nodeLatency( + context, + row.delay, ), ), ), - ), + ], + ], + ), + ), + if (running || chosen) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 8, + end: 4, + ), + child: Icon( + running + ? LucideIcons.activity + : LucideIcons.circleCheck, + size: 17, + color: p.primary, ), ), - SizedBox( - width: 42, - child: ServerMenu(controller: controller, row: row), + if (!controller.providerRow(row)) + IconButton( + tooltip: row.favorite + ? l.prototypeRemoveFavorite + : l.prototypeAddFavorite, + onPressed: controller.serverBusy(row) + ? null + : () => controller.toggleFavorite(context, row), + isSelected: row.favorite, + icon: controller.favoritingIds.contains(row.id) + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.star, size: 17), + selectedIcon: const Icon(LucideIcons.star600, size: 17), ), - ], - ), + ServerMenu(controller: controller, row: row), + ], ), ), - if (showDivider) - Divider(height: 1, thickness: 1, color: palette.border), - ], + ), ), - ), + if (showDivider) Divider(height: 1, color: p.border), + ], ); } } diff --git a/lib/pages/theme/font.dart b/lib/pages/theme/font.dart index 4e0740153..1870bd2e7 100644 --- a/lib/pages/theme/font.dart +++ b/lib/pages/theme/font.dart @@ -61,7 +61,7 @@ abstract final class AppTypography { static final settingsDetailNote = _style(12, 400); static final settingsSelect = _style(14, 400); static final settingsInput = _style(13, 400); - static final routingCardTitle = _style(15, 700); + static final routingCardTitle = _style(16, 600); static final routingCardDescription = _style(12, 400); static final routingRowTitle = _style(13, 700); static final routingRowTitleMobile = _style(12, 700); @@ -96,7 +96,7 @@ abstract final class AppTypography { static final actionHelp = _style(12, 400); static final ruleAdd = _style(12, 600); static final configurationTool = _style(12, 600); - static final rawField = _style(12, 600); + static final rawField = _style(14, 600); static final rawNote = _style(12, 400); static final runtimeCodePill = _style(12, 600); static final runtimeCodeAction = _style(12, 600); @@ -239,8 +239,8 @@ abstract final class AppTypography { static final runtimeLabel = _style(12, 400); static final runtimeDesktopLabel = _style(12, 400); - static final runtimeValue = _style(12, 700); - static final runtimeDesktopValue = _style(13, 700); + static final runtimeValue = _style(14, 600); + static final runtimeDesktopValue = _style(14, 600); static final runtimeNavigationHint = _style(12, 400); static final runtimePushHint = _style(12, 400); static final runtimeDesktopPushHint = _style(12, 400); diff --git a/test/pages/connect/routing/custom_layout_test.dart b/test/pages/connect/routing/custom_layout_test.dart index 019473e50..9084be72c 100644 --- a/test/pages/connect/routing/custom_layout_test.dart +++ b/test/pages/connect/routing/custom_layout_test.dart @@ -93,20 +93,23 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('desktop settings use two columns without reserving an editor', ( + testWidgets('all content widths keep rules and settings in one column', ( tester, ) async { - for (final width in [1220.0, 876.0, 743.0, 720.0]) { + for (final width in [1220.0, 876.0, 743.0, 720.0, 680.0]) { await pumpSections(tester, width: width); final rules = tester.getRect(find.byKey(_rules)); final dns = tester.getRect(find.byType(RoutingDnsSettingsForm)); final options = tester.getRect(find.byKey(_options)); final sniffing = tester.getRect(find.byType(RoutingSniffingSettingsForm)); expect(rules.width, width); - expect(dns.top, options.top); - expect(dns.right, lessThan(options.left)); - expect(sniffing.left, options.left); - expect(sniffing.top, greaterThan(options.bottom)); + expect(options.top, greaterThan(rules.bottom)); + expect(dns.top, greaterThan(options.bottom)); + expect(sniffing.top, greaterThan(dns.bottom)); + for (final rect in [options, dns, sniffing]) { + expect(rect.width, width); + expect(rect.left, rules.left); + } expect(tester.takeException(), isNull); } @@ -163,7 +166,7 @@ void main() { }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); testWidgets( - 'rule editing and settings adapt independently with scaled text', + 'rule draft keeps text, focus and selection in a full-width form', (tester) async { final editor = CustomRoutingRuleController(); addTearDown(editor.close); @@ -197,14 +200,10 @@ void main() { ); final rules = tester.getRect(find.byKey(_rules)); final form = tester.getRect(find.byType(CustomRoutingRuleForm)); - if (width >= 876) { - expect(form.top, rules.top); - expect(form.left, greaterThan(rules.right)); - expect(form.width, rules.width); - expect(form.width, (width - 16) / 2); - } else { - expect(form.top, greaterThan(rules.bottom)); - } + expect(form.top, greaterThan(rules.bottom)); + expect(form.left, rules.left); + expect(form.width, rules.width); + expect(form.width, width); expect(editor.name.text, 'Edited rule'); expect(editor.name.selection, selection); expect( diff --git a/test/pages/connect/routing/direct_regions_test.dart b/test/pages/connect/routing/direct_regions_test.dart index 2bc17e69d..79c9cd897 100644 --- a/test/pages/connect/routing/direct_regions_test.dart +++ b/test/pages/connect/routing/direct_regions_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/l10n/localizations/app_localizations_en.dart'; @@ -12,10 +13,13 @@ import 'package:onexray/l10n/localizations/app_localizations_zh.dart'; import 'package:onexray/pages/launch/setup/selectors.dart'; import 'package:onexray/pages/connect/routing/smart/controller.dart'; import 'package:onexray/pages/connect/routing/smart/regions.dart'; +import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/routing/region_catalog.dart'; import 'package:onexray/service/connect/routing/smart/editor.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme; void main() { test('Smart preview retains direct sources after merging rules', () async { @@ -74,7 +78,7 @@ void main() { expect(controller.directPreview(l), l.prototypeNone); }); - test('direct regions use installed categories, allow one selection and stay draft-only', () async { + test('direct regions select one installed category and leave historical input unchanged', () async { final selected = ['CN', 'RU']; var available = ['CN', 'RU', 'US', 'JP']; final controller = DirectRegionsController( @@ -97,10 +101,13 @@ void main() { await controller.load(); expect(controller.state.codes, ['CN', 'JP', 'RU', 'US']); expect(controller.state.selected, {'CN'}); - for (final code in ['RU', 'US', 'JP']) { - controller.select(code); - expect(controller.state.selected, {code}); - } + controller.select('US'); + controller.select('JP'); + expect(controller.state.selected, {'JP'}); + controller.select('RU'); + expect(controller.state.selected, {'RU'}); + controller.select('JP'); + expect(controller.state.selected, {'JP'}); controller.select('JP'); expect(controller.state.selected, {'JP'}); controller.select('IR'); @@ -116,10 +123,105 @@ void main() { expect(controller.state.selected, isEmpty); expect(selected, ['CN', 'RU']); available = ['US']; + controller.select('CN'); + controller.select('US'); await controller.load(); expect(controller.state.codes, ['US']); + expect(controller.state.selected, {'US'}); }); + testWidgets( + 'region cancel preserves historical input; Done returns one region or explicit empty', + (tester) async { + final initial = ['CN', 'RU']; + List? saved; + final catalog = RegionCatalog.fromJson( + { + 'geosite': {}, + 'geoip': { + for (final code in ['CN', 'RU', 'US', 'JP']) code: [code], + }, + }, + geositeCodes: [], + geoipCodes: ['CN', 'RU', 'US', 'JP'], + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (context, child) => + ShadTheme(data: AppTheme.shad(Brightness.light), child: child!), + home: Builder( + builder: (context) => Scaffold( + body: FilledButton( + onPressed: () async { + saved = await Navigator.of(context).push>( + MaterialPageRoute( + builder: (_) => DirectRegionsPage( + selectedCodes: initial, + loadRegions: () async => catalog, + ), + ), + ); + }, + child: const Text('Open regions'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open regions')); + await tester.pumpAndSettle(); + Iterable regionChoices() => tester.widgetList( + find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.inMutuallyExclusiveGroup == true, + ), + ); + expect( + regionChoices().where((widget) => widget.properties.checked == true), + hasLength(1), + ); + await tester.tap(find.text('United States')); + await tester.pumpAndSettle(); + expect( + regionChoices().where((widget) => widget.properties.checked == true), + hasLength(1), + ); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(saved, isNull); + expect(initial, ['CN', 'RU']); + + await tester.tap(find.text('Open regions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Japan')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Japan')); + await tester.pumpAndSettle(); + expect( + regionChoices().where((widget) => widget.properties.checked == true), + hasLength(1), + ); + await tester.tap(find.text(AppLocalizationsEn().prototypeDone)); + await tester.pumpAndSettle(); + expect(saved, ['JP']); + expect(initial, ['CN', 'RU']); + + await tester.tap(find.text('Open regions')); + await tester.pumpAndSettle(); + await tester.tap(find.text(AppLocalizationsEn().prototypeClearAll)); + await tester.pumpAndSettle(); + await tester.tap(find.text(AppLocalizationsEn().prototypeDone)); + await tester.pumpAndSettle(); + expect(saved, isEmpty); + expect(initial, ['CN', 'RU']); + }, + ); + test('every bundled region has a name in every supported language', () { final mapping = jsonDecode( File(RegionCatalog.assetPath).readAsStringSync(), diff --git a/test/pages/connect/routing/root_page_test.dart b/test/pages/connect/routing/root_page_test.dart new file mode 100644 index 000000000..f236b9c8b --- /dev/null +++ b/test/pages/connect/routing/root_page_test.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/connect/routing/controller.dart'; +import 'package:onexray/pages/connect/routing/page.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/connect/routing/custom/state.dart'; +import 'package:onexray/service/connect/routing/custom/state_db.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' + show ShadButton, ShadTheme, ShadToaster; + +const _waitTimeout = Duration(seconds: 5); + +void main() { + late AppDatabase db; + late ConnectionCoordinator coordinator; + RoutingPageController? controller; + late int goodId, brokenId; + late RoutingProfileData originalGood; + + setUp(() async { + final bus = AppEventBus(); + addTearDown(bus.close); + db = AppDatabase.forTesting(NativeDatabase.memory()); + goodId = await db.routingProfileDao.insertRow( + RoutingProfileState(name: 'Good route').insertCompanion, + ); + brokenId = await db.routingProfileDao.insertRow( + RoutingProfileCompanion.insert( + name: 'Broken route', + data: 'invalid-base64!', + advanced: const Value(true), + ), + ); + originalGood = (await db.routingProfileDao.searchRow(goodId))!; + await db.connectionConfigDao.commit( + configurationJson: ConnectionConfiguration( + connection: ConnectionSettings(trafficMode: TrafficMode.allVpn), + ).encode(), + ); + coordinator = ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + prepare: (_, _) async => + throw StateError('Changing a stored preference must not prepare VPN'), + start: (_) async => + throw StateError('Changing a stored preference must not start VPN'), + stop: () async => + throw StateError('Changing a stored preference must not stop VPN'), + ); + await coordinator + .initialize(observe: false, registerReferences: false) + .timeout(_waitTimeout); + }); + + tearDown(() async { + await controller?.close().timeout(_waitTimeout); + controller = null; + coordinator.dispose(); + await db.close().timeout(_waitTimeout); + }); + + Future waitFor(bool Function(RoutingPageState state) predicate) async { + if (!predicate(controller!.state)) { + await controller!.stream.firstWhere(predicate).timeout(_waitTimeout); + } + } + + Future pumpPage(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1000, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + addTearDown(() => tester.pumpWidget(const SizedBox.shrink())); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + home: RoutingPage(controller: controller), + ), + ); + await tester.pumpAndSettle().timeout(_waitTimeout); + } + + ShadButton useButton(WidgetTester tester, String key) => + tester.widget(find.byKey(ValueKey(key))); + + testWidgets( + 'broken and readable routes coexist while Smart and All remain usable', + (tester) async { + await tester.runAsync(() async { + controller = RoutingPageController( + database: db, + coordinator: coordinator, + ); + await controller!.load().timeout(_waitTimeout); + }); + await pumpPage(tester); + final state = controller!.state; + expect(state.failed, isFalse); + expect(state.profiles.map((row) => row.id), [goodId, brokenId]); + expect(state.profileErrors.keys, [brokenId]); + expect(controller!.configurationFor(originalGood), isNotNull); + expect(find.text('Good route'), findsOneWidget); + expect(find.text('Broken route'), findsOneWidget); + expect( + useButton(tester, 'routing-use-profile-$brokenId').enabled, + isFalse, + ); + expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isTrue); + expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + final l = AppLocalizations.of(tester.element(find.byType(RoutingPage)))!; + final broken = find.byKey(ValueKey('routing-profile-$brokenId')); + expect( + find.descendant( + of: broken, + matching: find.text(l.prototypeTemporarilyUnavailable), + ), + findsOneWidget, + ); + for (final label in [l.prototypeEdit, l.prototypeDelete]) { + final action = find.descendant( + of: broken, + matching: find.widgetWithText(ShadButton, label), + ); + expect(action, findsOneWidget); + expect(tester.widget(action).enabled, isTrue); + } + await tester.runAsync(() async { + await tester.tap(find.byKey(const ValueKey('routing-use-smart'))); + await waitFor( + (s) => + !s.busy && + s.configuration?.connection.trafficMode == TrafficMode.smart, + ); + }); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect( + (await tester.runAsync( + () => coordinator.configuration.timeout(_waitTimeout), + ))!.connection.trafficMode, + TrafficMode.smart, + ); + expect(useButton(tester, 'routing-use-all').enabled, isTrue); + await tester.runAsync(() async { + await tester.tap(find.byKey(const ValueKey('routing-use-all'))); + await waitFor( + (s) => + !s.busy && + s.configuration?.connection.trafficMode == TrafficMode.allVpn, + ); + }); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect( + (await tester.runAsync( + () => coordinator.configuration.timeout(_waitTimeout), + ))!.connection.trafficMode, + TrafficMode.allVpn, + ); + expect(controller!.state.profileErrors.keys, [brokenId]); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'stream parse failures stay on their row and repair restores Use', + (tester) async { + await tester.runAsync(() async { + controller = RoutingPageController( + database: db, + coordinator: coordinator, + ); + await controller!.load().timeout(_waitTimeout); + }); + await pumpPage(tester); + await tester.runAsync(() async { + await db.routingProfileDao.updateRow( + originalGood.copyWith(data: 'also-broken'), + ); + await waitFor((s) => s.profileErrors.containsKey(goodId)); + }); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect(controller!.state.failed, isFalse); + expect(controller!.state.profiles, hasLength(2)); + expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isFalse); + expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + await tester.runAsync(() async { + await db.routingProfileDao.updateRow(originalGood); + await waitFor((s) => !s.profileErrors.containsKey(goodId)); + }); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isTrue); + expect(controller!.state.profileErrors.keys, [brokenId]); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('database load failure retains an explicit retry and recovers', ( + tester, + ) async { + var fail = true; + await tester.runAsync(() async { + controller = RoutingPageController( + database: db, + coordinator: coordinator, + readProfiles: () => fail + ? Future.error(StateError('Profile storage unavailable')) + : db.routingProfileDao.allRows, + ); + await controller!.load().timeout(_waitTimeout); + }); + await pumpPage(tester); + final l = AppLocalizations.of(tester.element(find.byType(RoutingPage)))!; + expect(controller!.state.failed, isTrue); + expect(find.text(l.prototypeRetry), findsOneWidget); + fail = false; + await tester.runAsync(() async { + await tester.tap(find.text(l.prototypeRetry)); + await waitFor((s) => !s.failed && s.profiles.length == 2); + }); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect(find.text('Good route'), findsOneWidget); + expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + expect(tester.takeException(), isNull); + }); + + test( + 'an older load cannot replace newer streamed rows or diagnostics', + () async { + final first = Completer>(), + entered = Completer(); + var reads = 0; + controller = RoutingPageController( + database: db, + coordinator: coordinator, + readProfiles: () { + if (reads++ == 0) { + entered.complete(); + return first.future.timeout(_waitTimeout); + } + return db.routingProfileDao.allRows; + }, + ); + await entered.future.timeout(_waitTimeout); + await controller!.load().timeout(_waitTimeout); + expect(controller!.state.profiles, hasLength(2)); + first.complete([]); + await Future.delayed(Duration.zero); + expect(controller!.state.profiles.map((row) => row.id), [ + goodId, + brokenId, + ]); + expect(controller!.state.profileErrors.keys, [brokenId]); + expect(controller!.state.failed, isFalse); + }, + ); + + testWidgets('a delayed reload cannot replace a newer Smart selection', ( + tester, + ) async { + late Completer> delayedRows; + var delay = false; + var reloadFinished = false; + late Future reload; + await tester.runAsync(() async { + // Both barriers belong to the real async zone. A Completer created in + // the widget test's FakeAsync zone can be completed here while its + // listeners remain queued until the next pump, deadlocking this await. + delayedRows = Completer>(); + final entered = Completer(); + controller = RoutingPageController( + database: db, + coordinator: coordinator, + readProfiles: () { + if (delay) { + entered.complete(); + return delayedRows.future.timeout(_waitTimeout); + } + return db.routingProfileDao.allRows; + }, + ); + await controller!.load().timeout(_waitTimeout); + expect( + (await coordinator.configuration).connection.trafficMode, + TrafficMode.allVpn, + ); + // The older load captures the SQLite All preference before Smart saves. + delay = true; + reload = controller!.load().whenComplete(() => reloadFinished = true); + await entered.future.timeout(_waitTimeout); + expect(reloadFinished, isFalse); + }); + await pumpPage(tester); + final context = tester.element(find.byType(RoutingPage)); + expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + await tester.runAsync(() async { + try { + await controller! + .select(context, TrafficMode.smart) + .timeout(_waitTimeout); + expect( + controller!.state.configuration!.connection.trafficMode, + TrafficMode.smart, + ); + expect( + (await coordinator.configuration).connection.trafficMode, + TrafficMode.smart, + ); + expect(reloadFinished, isFalse); + } finally { + if (!delayedRows.isCompleted) delayedRows.complete([]); + await reload.timeout(_waitTimeout); + } + }); + expect(reloadFinished, isTrue); + await tester.pumpAndSettle().timeout(_waitTimeout); + expect( + controller!.state.configuration!.connection.trafficMode, + TrafficMode.smart, + ); + expect(controller!.state.profiles.map((row) => row.id), [goodId, brokenId]); + expect(controller!.state.profileErrors.keys, [brokenId]); + expect(controller!.state.failed, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/pages/connect/routing/smart/exit_picker_test.dart b/test/pages/connect/routing/smart/exit_picker_test.dart index 047fb9cef..2547a049f 100644 --- a/test/pages/connect/routing/smart/exit_picker_test.dart +++ b/test/pages/connect/routing/smart/exit_picker_test.dart @@ -96,14 +96,15 @@ void main() { controller.selectDraft( controller.servers.singleWhere((row) => row.id == 4), ); - expect(controller.selectedId, 2); + expect(controller.selectedId, 4); expect( controller.exitRowDetail( l, controller.servers.singleWhere((row) => row.id == 4), ), - l.prototypeTemporarilyUnavailable, + l.serverLatencyFailed, ); + expect(controller.canFinish, isTrue); controller.selectDraft( controller.servers.singleWhere((row) => row.id == 3), ); @@ -122,6 +123,36 @@ void main() { }, ); + test('unmeasured and timed-out exits remain eligible without changing saved settings', () async { + final before = (await coordinator.configuration).encode(); + for (final (delay, label) in [ + (PingDelayConstants.unknown, l.prototypeNotTested), + (PingDelayConstants.error, l.serverLatencyFailed), + (PingDelayConstants.timeout, l.serverLatencyFailed), + ]) { + final exit = _server(4, delay: delay); + controller.emit( + controller.state.copyWith( + catalog: controller.catalog.copyWith( + servers: [ + for (final row in controller.servers) row.id == 4 ? exit : row, + ], + ), + ), + ); + expect(controller.canSelect(exit), isTrue); + expect(controller.exitRowDetail(l, exit), label); + controller.selectDraft(exit); + expect(controller.selectedId, 4); + expect(controller.canFinish, isTrue); + final entry = controller.servers.singleWhere((row) => row.id == 1); + expect(controller.canSelect(entry), isFalse); + controller.selectDraft(entry); + expect(controller.selectedId, 4); + } + expect((await coordinator.configuration).encode(), before); + }); + Future open( WidgetTester tester, List results, { @@ -189,6 +220,50 @@ void main() { expect(tester.takeException(), isNull); }); + for (final delay in [ + PingDelayConstants.unknown, + PingDelayConstants.timeout, + ]) { + testWidgets( + 'exit picker can return an unmeasured or timed-out node: $delay', + (tester) async { + final before = (await coordinator.configuration).encode(); + final exit = _server(4, delay: delay); + controller.emit( + controller.state.copyWith( + catalog: controller.catalog.copyWith( + servers: [ + for (final row in controller.servers) row.id == 4 ? exit : row, + ], + ), + ), + ); + final results = []; + await open(tester, results); + await tester.tap(find.text('Server 1')); + await tester.pumpAndSettle(); + expect(controller.selectedId, 2); + await tester.tap(find.text('Server 4')); + await tester.pumpAndSettle(); + expect(controller.selectedId, 4); + expect(results, isEmpty); + expect( + find.text( + delay == PingDelayConstants.unknown + ? l.prototypeNotTested + : l.serverLatencyFailed, + ), + findsOneWidget, + ); + await tester.tap(find.text(l.prototypeDone)); + await tester.pumpAndSettle(); + expect(results.single?.id, 4); + expect((await coordinator.configuration).encode(), before); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('desktop selector keeps a 760 card and a matching footer scope', ( tester, ) async { diff --git a/test/pages/connect/routing/smart/layout_test.dart b/test/pages/connect/routing/smart/layout_test.dart index 3feff2e97..e0673b646 100644 --- a/test/pages/connect/routing/smart/layout_test.dart +++ b/test/pages/connect/routing/smart/layout_test.dart @@ -161,7 +161,7 @@ void main() { } testWidgets( - 'default iPad and desktop groups balance both columns', + 'iPad and desktop keep every routing group in one content column', (tester) async { final iPad = defaultTargetPlatform == TargetPlatform.iOS; final contentWidth = iPad ? 743.0 : 1000.0; @@ -170,38 +170,37 @@ void main() { windowWidth: iPad ? 1024 : 1440, contentWidth: contentWidth, ); - final primary = tester.getRect(find.byKey(_primary)); - final secondary = tester.getRect(find.byKey(_secondary)); - expect(primary.top, closeTo(secondary.top, 0.01)); - expect(primary.width, closeTo((contentWidth - 16) / 2, 0.01)); - expect(primary.width, closeTo(secondary.width, 0.01)); - final taller = primary.height > secondary.height - ? primary.height - : secondary.height; - expect( - (primary.height - secondary.height).abs() / taller, - lessThan(0.25), - reason: - 'Content $contentWidth: left ${primary.height}, ' - 'right ${secondary.height}', - ); + expect(find.byKey(_primary), findsNothing); + expect(find.byKey(_secondary), findsNothing); final l = AppLocalizations.of( tester.element(find.byType(SmartRoutingEditorContent)), )!; - expect( - find.descendant( - of: find.byKey(_primary), - matching: header(l.routingDnsSettings), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byKey(_secondary), - matching: header(l.prototypeRoutingPreview), - ), - findsOneWidget, - ); + final cards = + [ + l.smartRoutingDirectAndBlock, + l.smartRoutingProxyConnection, + l.routingDnsSettings, + l.routingSniffingSettings, + l.prototypeRoutingPreview, + ] + .map( + (title) => tester.getRect( + find + .ancestor( + of: header(title), + matching: find.byType(RoutingCard), + ) + .first, + ), + ) + .toList(); + for (var index = 0; index < cards.length; index++) { + expect(cards[index].width, closeTo(contentWidth, .01)); + expect(cards[index].left, closeTo(cards.first.left, .01)); + if (index > 0) { + expect(cards[index].top, greaterThan(cards[index - 1].bottom)); + } + } expect(tester.takeException(), isNull); }, variant: TargetPlatformVariant({TargetPlatform.iOS, TargetPlatform.macOS}), @@ -322,8 +321,15 @@ void main() { locale: const Locale('fa'), textScale: 1.6, ); - expect(find.byKey(_primary), findsOneWidget); - expect(find.byKey(_secondary), findsOneWidget); + expect(find.byKey(_primary), findsNothing); + expect(find.byKey(_secondary), findsNothing); + final fullWidthCards = find.byType(RoutingCard); + for (final card in fullWidthCards.evaluate()) { + expect( + tester.getSize(find.byWidget(card.widget)).width, + closeTo(1000, .01), + ); + } expect(controller.state.draft.toJson(), draft); expect(controller.state.original!.configuration.encode(), original); expect(coordinator.state.value.phase, ConnectionPhase.disconnected); diff --git a/test/pages/launch/setup_controller_test.dart b/test/pages/launch/setup_controller_test.dart index 29d4059a7..58a4fc8a9 100644 --- a/test/pages/launch/setup_controller_test.dart +++ b/test/pages/launch/setup_controller_test.dart @@ -261,6 +261,7 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Russia')); await tester.tap(find.text('Mainland China')); + await tester.tap(find.text('Mainland China')); await tester.tap(find.text('Done')); await tester.pumpAndSettle(); expect(controller.state.regions, ['CN']); diff --git a/test/pages/servers/controller_test.dart b/test/pages/servers/controller_test.dart index ff54d4395..910cc9538 100644 --- a/test/pages/servers/controller_test.dart +++ b/test/pages/servers/controller_test.dart @@ -244,7 +244,7 @@ void main() { ]; expect( controller.summary(l, controller.groups(l).single), - l.prototypeGroupAvailability(4, 5, 0), + l.prototypeGroupAvailability(5, 5, 0), ); }, ); @@ -285,70 +285,76 @@ void main() { expect(controller.entryCount(const ServerSelection.source(0)), 1); }); - test('delay alone distinguishes untested, successful, failed and timed-out nodes', () async { - final row = await server('one'); - for (final (delay, label, selectable) in [ - (PingDelayConstants.unknown, l.prototypeNotTested, true), - (0, l.prototypeFastLatency(0), true), - (-1, l.prototypeTemporarilyUnavailable, false), - (499, l.prototypeFastLatency(499), true), - (500, l.prototypeFastLatency(500), true), - (501, l.prototypeSlowLatency(501), true), - (999, l.prototypeSlowLatency(999), true), - (1000, l.prototypeSlowLatency(1000), true), - (1001, l.prototypeAvailableLatency(1001), true), - (PingDelayConstants.error, l.prototypeTemporarilyUnavailable, false), - (PingDelayConstants.timeout, l.prototypeTemporarilyUnavailable, false), - ]) { - final candidate = row.copyWith(delay: delay); - expect(controller.health(l, candidate), label); - expect(controller.canChoose(candidate), selectable); - controller.servers = [candidate]; - expect( - controller.automaticResult(l), - PingDelayConstants.isSuccessful(delay) - ? l.prototypeCurrentServerLatency('one', delay) - : isNull, - ); - } - }); + test( + 'latency reports measurement status without limiting selection', + () async { + final row = await server('one'); + for (final (delay, label, selectable) in [ + (PingDelayConstants.unknown, l.prototypeNotTested, true), + (0, l.prototypeFastLatency(0), true), + (-1, l.serverLatencyFailed, true), + (499, l.prototypeFastLatency(499), true), + (500, l.prototypeFastLatency(500), true), + (501, l.prototypeSlowLatency(501), true), + (999, l.prototypeSlowLatency(999), true), + (1000, l.prototypeSlowLatency(1000), true), + (1001, l.prototypeAvailableLatency(1001), true), + (PingDelayConstants.error, l.serverLatencyFailed, true), + (PingDelayConstants.timeout, l.serverLatencyFailed, true), + ]) { + final candidate = row.copyWith(delay: delay); + expect(controller.health(l, candidate), label); + expect(controller.canChoose(candidate), selectable); + controller.servers = [candidate]; + expect( + controller.automaticResult(l), + PingDelayConstants.isSuccessful(delay) + ? l.prototypeCurrentServerLatency('one', delay) + : l.prototypeCurrentServerLatency('one', '—'), + ); + } + }, + ); - test('custom count uses empty outbounds; measured failure is not an untested candidate', () async { - final one = await server('one'); - final two = await server('two'); - await db.routingProfileDao.insertRow( - RoutingProfileCompanion.insert( - name: 'custom', - data: base64Encode( - utf8.encode( - jsonEncode({ - 'outbounds': [{}, {}], - 'routing': {'rules': []}, - }), + test( + 'custom count uses empty outbounds and measured failures remain candidates', + () async { + final one = await server('one'); + final two = await server('two'); + await db.routingProfileDao.insertRow( + RoutingProfileCompanion.insert( + name: 'custom', + data: base64Encode( + utf8.encode( + jsonEncode({ + 'outbounds': [{}, {}], + 'routing': {'rules': []}, + }), + ), ), ), - ), - ); - controller.customRoutes = (await db.routingProfileDao.allRows) - .map(CustomRoutingService.read) - .toList(); - controller.configuration = ConnectionConfiguration( - connection: ConnectionSettings( - trafficMode: TrafficMode.custom, - customId: controller.customRoutes.single.id, - ), - ); - controller.servers = [one, two.copyWith(delay: PingDelayConstants.error)]; - final group = controller.groups(l).single; - expect(controller.entryCount(const ServerSelection.source(0)), 2); - expect( - controller.canUseSelection(const ServerSelection.source(0), group.rows), - false, - ); - expect(controller.health(l, one), l.prototypeNotTested); - expect( - controller.health(l, controller.servers.last), - l.prototypeTemporarilyUnavailable, - ); - }); + ); + controller.customRoutes = (await db.routingProfileDao.allRows) + .map(CustomRoutingService.read) + .toList(); + controller.configuration = ConnectionConfiguration( + connection: ConnectionSettings( + trafficMode: TrafficMode.custom, + customId: controller.customRoutes.single.id, + ), + ); + controller.servers = [one, two.copyWith(delay: PingDelayConstants.error)]; + final group = controller.groups(l).single; + expect(controller.entryCount(const ServerSelection.source(0)), 2); + expect( + controller.canUseSelection(const ServerSelection.source(0), group.rows), + true, + ); + expect(controller.health(l, one), l.prototypeNotTested); + expect( + controller.health(l, controller.servers.last), + l.serverLatencyFailed, + ); + }, + ); } diff --git a/test/pages/servers/editor_dismissal_test.dart b/test/pages/servers/editor_dismissal_test.dart index 72875a3f5..656f3a0df 100644 --- a/test/pages/servers/editor_dismissal_test.dart +++ b/test/pages/servers/editor_dismissal_test.dart @@ -9,7 +9,6 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/servers/editor/page.dart'; -import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; @@ -77,13 +76,10 @@ void main() { builder: (context) => TextButton( onPressed: () async { final savedId = await Navigator.of(context).push( - DialogRoute( - context: context, - builder: (_) => AppDialogFrame( - child: ServerEditorPage( - serverId: 7, - createController: () => controller, - ), + MaterialPageRoute( + builder: (_) => ServerEditorPage( + serverId: 7, + createController: () => controller, ), ), ); @@ -113,13 +109,13 @@ void main() { )!; switch (exit) { case _Exit.close: - await tester.tap(find.byTooltip(l.prototypeCloseDialog)); + await tester.tap(find.byType(BackButton)); case _Exit.cancel: await tester.tap( find.widgetWithText(ConnectDialogButton, l.prototypeCancel), ); case _Exit.barrier: - await tester.tapAt(const Offset(10, 10)); + await tester.tapAt(const Offset(1185, 120)); case _Exit.back: await tester.binding.handlePopRoute(); case _Exit.escape: @@ -182,6 +178,14 @@ void main() { final l = AppLocalizations.of(session.context)!; await requestExit(tester, exit); + if (exit == _Exit.barrier) { + // A full page has no dismissible barrier. Outside its bounded content + // remains inert; explicit Cancel still uses the same dirty guard. + expect(find.byType(AppConfirmationDialog), findsNothing); + expect(session.result.isCompleted, isFalse); + expect(controller.text.text, _edited); + await requestExit(tester, _Exit.cancel); + } expect(find.byType(AppConfirmationDialog), findsOneWidget); expect(session.result.isCompleted, isFalse); await tester.tap(find.text(l.jsonEditorKeepEditing)); @@ -193,6 +197,7 @@ void main() { expect(controller.state.dirty, isTrue); await requestExit(tester, exit); + if (exit == _Exit.barrier) await requestExit(tester, _Exit.cancel); await tester.tap(find.text(l.jsonEditorDiscardChanges)); await tester.pumpAndSettle(); expect(find.byType(ServerEditorPage), findsNothing); diff --git a/test/pages/servers/fixtures/server_test_router.dart b/test/pages/servers/fixtures/server_test_router.dart new file mode 100644 index 000000000..eca6873f2 --- /dev/null +++ b/test/pages/servers/fixtures/server_test_router.dart @@ -0,0 +1,44 @@ +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/servers/page.dart'; +import 'package:onexray/pages/servers/view.dart'; + +/// Uses the production branch paths and typed extras without startup services. +GoRouter serverTestRouter({ + required WidgetBuilder home, + List extraRoutes = const [], +}) => GoRouter( + initialLocation: '/servers', + routes: [ + GoRoute( + path: '/servers', + builder: (context, _) => home(context), + routes: [ + GoRoute( + path: 'server-resource', + builder: (_, state) => + ServerResourcePage(params: state.extra! as ServerResourceParams), + ), + GoRoute( + path: 'source-details', + builder: (_, state) => + ServerSourcePage(params: state.extra! as ServerSourceParams), + ), + GoRoute( + path: 'server-group', + builder: (_, state) => + ServerGroupPage(params: state.extra! as ServerGroupParams), + ), + GoRoute( + path: 'server-manual-groups', + builder: (_, state) => ServerManualGroupsPage( + controller: state.extra! as ServersController, + ), + ), + ...extraRoutes, + ], + ), + ], +); diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index 0c8b6c05b..c9e079c3e 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -710,9 +710,8 @@ void main() { Builder( builder: (context) => TextButton( onPressed: () async { - result = await showAppDialog( - context, - (_) => const ServersImportPage(), + result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ServersImportPage()), ); completed = true; }, @@ -732,7 +731,7 @@ void main() { expect(controller.text.text, isEmpty); expect(clipboardReads, 0); - await tester.tap(find.text('Back')); + await tester.tap(find.byType(BackButton)); await tester.pumpAndSettle(); expect(find.text('Add servers to OneXray'), findsOneWidget); expect(completed, isFalse); diff --git a/test/pages/servers/manual_groups_test.dart b/test/pages/servers/manual_groups_test.dart index 550fee281..acf6eef99 100644 --- a/test/pages/servers/manual_groups_test.dart +++ b/test/pages/servers/manual_groups_test.dart @@ -2,10 +2,12 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/manual_group_dialogs.dart'; import 'package:onexray/pages/servers/page.dart'; import 'package:onexray/pages/servers/sources.dart'; @@ -22,11 +24,14 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; +import 'fixtures/server_test_router.dart'; + void main() { late AppDatabase db; late ConnectionCoordinator coordinator; late ServersController controller; late ScrollController scroll; + GoRouter? router; setUp(() async { SharedPreferencesAsyncPlatform.instance = @@ -41,12 +46,22 @@ void main() { }); tearDown(() async { + router?.dispose(); + router = null; scroll.dispose(); - await controller.close(); + await controller.close().timeout(const Duration(seconds: 5)); coordinator.dispose(); - await db.close(); + await db.close().timeout(const Duration(seconds: 5)); }); + Future settle(WidgetTester tester) async { + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + } + Future settleUntil(WidgetTester tester, bool Function() ready) async { for (var attempt = 0; attempt < 100 && !ready(); attempt++) { await tester.pump(const Duration(milliseconds: 20)); @@ -88,13 +103,36 @@ void main() { Locale locale = const Locale('en'), Size size = const Size(390, 844), bool fullPage = false, + double textScale = 1, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = size; addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); + router = serverTestRouter( + home: (_) => fullPage + ? ServersPage(controller: controller) + : Builder( + builder: (context) => Scaffold( + appBar: AppBar( + actions: [ + TextButton( + onPressed: () => controller.createManualGroup(context), + child: const Text('Create'), + ), + ], + ), + body: BlocBuilder( + bloc: controller, + builder: (_, _) => + ServerBrowser(controller: controller, scroll: scroll), + ), + ), + ), + ); await tester.pumpWidget( - MaterialApp( + MaterialApp.router( + routerConfig: router, theme: AppTheme.material( Brightness.light, mobile: size.width <= AppLayout.mobileBreakpoint, @@ -102,36 +140,25 @@ void main() { locale: locale, localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => ShadTheme( - data: AppTheme.shad(Brightness.light), - child: ShadToaster(child: child!), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), ), - home: fullPage - ? ServersPage(controller: controller) - : Builder( - builder: (context) => Scaffold( - appBar: AppBar( - actions: [ - TextButton( - onPressed: () => controller.createManualGroup(context), - child: const Text('Create'), - ), - ], - ), - body: BlocBuilder( - bloc: controller, - builder: (_, _) => - ServerBrowser(controller: controller, scroll: scroll), - ), - ), - ), ), ); - await tester.pumpAndSettle(); + // The real page schedules initialization after its first frame. Drain that + // SQLite read while both the fake widget clock and real IO can advance, + // before teardown cancels its query subscriptions. + await databaseOperation(tester, () => db.connectionConfigDao.read()); + await settle(tester); } testWidgets( - 'mobile source management stays reachable above a long group list', + 'mobile subscriptions stay reachable independently of a long group list', (tester) async { await databaseOperation(tester, () async { await db.subscriptionDao.insertRow( @@ -157,18 +184,22 @@ void main() { await pump(tester, fullPage: true); final context = tester.element(find.byType(ServersPage)); final l = AppLocalizations.of(context)!; - final sources = find.byTooltip(l.prototypeUpdatesAndSources); + final sources = find.text(l.prototypeSubscriptions).first; + await tester.ensureVisible(sources); expect(sources.hitTestable(), findsOneWidget); await tester.tap(sources); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsOneWidget); + await settle(tester); + expect(find.byType(ServerSourcesView), findsOneWidget); expect(find.text('Provider'), findsOneWidget); + await tester.tap(find.text('Provider')); + await settle(tester); + expect(find.byType(ServerSourcePage), findsOneWidget); expect(controller.catalog.manualGroups, hasLength(30)); expect(coordinator.state.value.phase, ConnectionPhase.disconnected); expect(tester.takeException(), isNull); - await tester.tap(find.byTooltip(l.prototypeCloseDialog)); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsNothing); + router!.pop(); + await settle(tester); + expect(find.byType(ServerSourcePage), findsNothing); expect(sources.hitTestable(), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); }, @@ -191,10 +222,10 @@ void main() { await pump(tester, size: const Size(1200, 900), fullPage: true); final context = tester.element(find.byType(ServersPage)); final l = AppLocalizations.of(context)!; - expect(find.text(l.prototypeUpdatesAndSources), findsOneWidget); + expect(find.text(l.prototypeSubscriptions), findsOneWidget); await tester.tap(find.byTooltip(l.manualGroupNew)); - await tester.pumpAndSettle(); + await settle(tester); await tester.enterText( find.byKey(const ValueKey('manual-group-name')), 'Empty folder', @@ -205,14 +236,19 @@ void main() { tester, () => controller.catalog.manualGroups.length == 1, ); - await tester.pumpAndSettle(); + await settle(tester); expect(controller.servers, isEmpty); - expect(find.text('Empty folder'), findsWidgets); - final sources = find.text(l.prototypeUpdatesAndSources); + await tester.tap(find.text(l.manualGroupsTitle)); + await settle(tester); + expect(find.byType(ServerManualGroupsPage), findsOneWidget); + expect(find.text('Empty folder'), findsOneWidget); + router!.pop(); + await settle(tester); + final sources = find.text(l.prototypeSubscriptions).first; expect(sources, findsOneWidget); await tester.tap(sources); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsOneWidget); + await settle(tester); + expect(find.byType(ServerSourcesView), findsOneWidget); expect(find.text('Provider'), findsOneWidget); expect(coordinator.state.value.phase, ConnectionPhase.disconnected); expect(tester.takeException(), isNull); @@ -225,7 +261,7 @@ void main() { (tester) async { await pump(tester); await tester.tap(find.text('Create')); - await tester.pumpAndSettle(); + await settle(tester); await tester.enterText( find.byKey(const ValueKey('manual-group-name')), 'Cloud', @@ -238,17 +274,23 @@ void main() { (group) => group.name == 'Cloud', ), ); - await tester.pumpAndSettle(); + await settle(tester); expect(find.byType(ManualGroupNameDialog), findsNothing); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + await tester.tap(find.text(l.manualGroupsTitle)); + await settle(tester); + expect(find.byType(ServerManualGroupsPage), findsOneWidget); expect(find.text('Cloud'), findsOneWidget); expect(find.text('Ungrouped'), findsOneWidget); await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.byTooltip('More actions: Cloud')); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.text('Rename group')); - await tester.pumpAndSettle(); + await settle(tester); await tester.enterText( find.byKey(const ValueKey('manual-group-name')), 'Home', @@ -261,19 +303,19 @@ void main() { (group) => group.name == 'Home', ), ); - await tester.pumpAndSettle(); + await settle(tester); expect(find.text('Home'), findsOneWidget); expect(find.text('Cloud'), findsNothing); await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.byTooltip('More actions: Home')); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.text('Dissolve group')); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.text('Dissolve group').last); await settleUntil(tester, () => controller.catalog.manualGroups.isEmpty); - await tester.pumpAndSettle(); + await settle(tester); expect(find.byType(ManualGroupActionsDialog), findsNothing); expect(find.text('Home'), findsNothing); expect(tester.takeException(), isNull); @@ -302,9 +344,9 @@ void main() { ); await pump(tester); await tester.tap(find.byType(ServerMenu).first); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap(find.text('Move to group')); - await tester.pumpAndSettle(); + await settle(tester); await tester.tap( find.descendant( of: find.byType(ManualGroupMoveDialog), @@ -317,7 +359,7 @@ void main() { (row) => row.id == node && row.manualGroupId == group, ), ); - await tester.pumpAndSettle(); + await settle(tester); expect(controller.servers.single.manualGroupId, group); expect(coordinator.state.value.phase, ConnectionPhase.disconnected); expect(tester.takeException(), isNull); @@ -342,12 +384,12 @@ void main() { ), ), ); - await tester.pumpAndSettle(); + await settle(tester); expect(find.byType(ServerGroupView), findsOneWidget); controller.manualGroups = const [ ManualServerGroupData(id: 9, name: 'Home', sortOrder: 0), ]; - await tester.pumpAndSettle(); + await settle(tester); expect(find.text('Home'), findsWidgets); expect(find.byType(ServerGroupView), findsOneWidget); expect(controller.query, 'Cloud'); @@ -355,6 +397,37 @@ void main() { }); for (final locale in const [Locale('ru'), Locale('fa')]) { + testWidgets('mobile add stays reachable with a large $locale title', ( + tester, + ) async { + await pump( + tester, + locale: locale, + size: const Size(320, 844), + fullPage: true, + textScale: 1.5, + ); + final l = AppLocalizations.of(tester.element(find.byType(ServersPage)))!; + final add = find.byKey(const ValueKey('servers-add')); + expect(add.hitTestable(), findsOneWidget); + expect(tester.widget(add).tooltip, l.prototypeAddServer); + expect(find.byTooltip(l.manualGroupNew), findsNothing); + expect( + tester.getSize(find.text(l.prototypeServers)).width, + greaterThan(0), + ); + expect(tester.takeException(), isNull); + await tester.tap(find.widgetWithText(TextButton, l.manualGroupsTitle)); + await settle(tester); + expect(find.byType(ServerManualGroupsPage), findsOneWidget); + expect( + find.widgetWithText(FilledButton, l.manualGroupNew), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('group actions fit a compact $locale display', (tester) async { await databaseOperation( tester, @@ -370,8 +443,10 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(ServerBrowser)), )!; + await tester.tap(find.text(l.manualGroupsTitle)); + await settle(tester); await tester.tap(find.byTooltip('${l.prototypeMoreActions}: Cloud')); - await tester.pumpAndSettle(); + await settle(tester); expect(find.text(l.manualGroupDelete), findsOneWidget); expect(tester.takeException(), isNull); }); diff --git a/test/pages/servers/provider_ui_test.dart b/test/pages/servers/provider_ui_test.dart index 3b6d1e6f5..438c68020 100644 --- a/test/pages/servers/provider_ui_test.dart +++ b/test/pages/servers/provider_ui_test.dart @@ -16,6 +16,7 @@ import 'package:onexray/pages/advanced/xray/config/controller.dart'; import 'package:onexray/pages/advanced/xray/config/page.dart'; import 'package:onexray/pages/advanced/xray/config/params.dart'; import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/editor/page.dart'; import 'package:onexray/pages/servers/page.dart'; import 'package:onexray/pages/servers/view.dart'; @@ -192,6 +193,12 @@ void main() { builder: (_, state) => ServerGroupPage(params: state.extra! as ServerGroupParams), ), + GoRoute( + path: 'server-resource', + builder: (_, state) => ServerResourcePage( + params: state.extra! as ServerResourceParams, + ), + ), GoRoute( path: 'config-file-viewer', builder: (_, state) => ConfigFileViewerPage( @@ -227,7 +234,9 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(ServerBrowser)), )!; - await tester.tap(find.byTooltip(l.providerChooseConfiguration)); + final tab = find.text(l.serverCompleteConfigurationsTab).first; + await tester.ensureVisible(tab); + await tester.tap(tab); await tester.pumpAndSettle(); } } @@ -243,10 +252,10 @@ void main() { (tester) async { final before = (await configuration(tester)).encode(); await open(tester); - final context = tester.element(find.byType(ServerGroupView)); + final context = tester.element(find.byType(ServerBrowser)); final l = AppLocalizations.of(context)!; expect((await configuration(tester)).encode(), before); - expect(find.text('Remnawave'), findsNWidgets(2)); + expect(find.byType(ServerNodeRow), findsNWidgets(2)); expect(find.text(l.prototypeTestServers), findsNothing); expect(find.text(l.prototypeNotTested), findsNothing); expect(find.text(l.prototypeTemporarilyUnavailable), findsNothing); @@ -254,8 +263,12 @@ void main() { await controller.test(context, profiles); expect(AppEventBus.instance.state.pinging, isFalse); expect(controller.state.serverTests, isEmpty); + await tester.tap(find.text('Profile B')); + await tester.pumpAndSettle(); + expect(find.byType(ServerResourcePage), findsOneWidget); + expect((await configuration(tester)).encode(), before); await tester.runAsync(() async { - await tester.tap(find.text('Profile B')); + await tester.tap(find.widgetWithText(FilledButton, l.prototypeUse)); await controller.selectionComplete.future.timeout( const Duration(seconds: 5), ); @@ -274,7 +287,7 @@ void main() { (tester) async { await open(tester); final l = AppLocalizations.of( - tester.element(find.byType(ServerGroupView)), + tester.element(find.byType(ServerBrowser)), )!; await tester.runAsync(() => tester.tap(find.byType(ServerMenu).first)); await tester.pumpAndSettle(); @@ -316,7 +329,7 @@ void main() { final before = (await configuration(tester)).encode(); await open(tester); final l = AppLocalizations.of( - tester.element(find.byType(ServerGroupView)), + tester.element(find.byType(ServerBrowser)), )!; await tester.tap(find.byType(ServerMenu).first); await tester.pumpAndSettle(); @@ -385,9 +398,7 @@ void main() { tester, ) async { await open(tester); - final l = AppLocalizations.of( - tester.element(find.byType(ServerGroupView)), - )!; + final l = AppLocalizations.of(tester.element(find.byType(ServerBrowser)))!; await tester.tap(find.byType(ServerMenu).first); await tester.pumpAndSettle(); await tester.tap(find.text(l.prototypeShare)); diff --git a/test/pages/servers/raw_selection_test.dart b/test/pages/servers/raw_selection_test.dart new file mode 100644 index 000000000..954957bd1 --- /dev/null +++ b/test/pages/servers/raw_selection_test.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/share/configuration_transfer.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + late AppDatabase db; + late _Coordinator coordinator; + late ServersController controller; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + coordinator = _Coordinator(db); + controller = ServersController(database: db, coordinator: coordinator); + addTearDown(() async { + await controller.close(); + coordinator.dispose(); + await db.close(); + }); + }); + + Future open(WidgetTester tester) async { + await tester.runAsync( + () => coordinator.initialize(observe: false, registerReferences: false), + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + home: const Scaffold(body: Text('Raw selection host')), + ), + ); + return tester.element(find.text('Raw selection host')); + } + + Future raw() async { + final id = await db.coreConfigDao.insertRow( + CoreConfigCompanion.insert( + name: 'Personal Raw', + type: 'raw', + tags: '', + delay: 9000, + subId: 0, + data: Value( + base64Encode( + utf8.encode( + '{"outbounds":[{"protocol":"freedom"}],"future":{"kept":true}}', + ), + ), + ), + ), + ); + return (await db.coreConfigDao.searchRow(id))!; + } + + testWidgets( + 'a readable Raw is explicitly selected without starting or validating Core', + (tester) async { + final context = await open(tester); + await tester.runAsync(() async { + final row = await raw(); + final before = await coordinator.configuration; + await controller.chooseRaw(context, row); + final saved = await coordinator.configuration; + expect(saved.connection.expert, isTrue); + expect(saved.connection.rawId, row.id); + expect(saved.policy.toJson(), before.policy.toJson()); + expect(coordinator.applies, 1); + expect(controller.selecting, isNull); + }); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'reselecting the same active Raw does not request reconnect or another commit', + (tester) async { + final context = await open(tester); + await tester.runAsync(() async { + final row = await raw(); + final current = ConnectionConfiguration( + connection: ConnectionSettings(expert: true, rawId: row.id), + ); + await db.connectionConfigDao.commit( + configurationJson: current.encode(), + ); + coordinator.state.value = const ConnectionView( + phase: ConnectionPhase.connected, + ); + await controller.chooseRaw(context, row); + expect(coordinator.applies, 0); + expect((await coordinator.configuration).encode(), current.encode()); + expect(controller.selecting, isNull); + }); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'deleted, changed-type and unreadable Raw snapshots cannot replace the saved plan', + (tester) async { + final context = await open(tester); + for (final change in ['deleted', 'type', 'encoding', 'json', 'array']) { + await tester.runAsync(() async { + final row = await raw(); + final before = await coordinator.configuration; + if (change == 'deleted') { + await db.coreConfigDao.deleteRow(row); + } else if (change == 'type') { + // The typed asset DAO deliberately refuses changing asset type. + // Bypass it here to represent a stale row after data replacement. + await (db.update(db.coreConfig)..where((t) => t.id.equals(row.id))) + .write(const CoreConfigCompanion(type: Value('outbound'))); + expect( + (await db.coreConfigDao.searchRow(row.id))!.type, + 'outbound', + ); + } else { + final changed = await db.coreConfigDao.updateRow( + row.copyWith( + data: Value( + change == 'encoding' + ? 'not base64' + : base64Encode( + utf8.encode( + change == 'json' + ? '{' + : change == 'array' + ? '[]' + : '{}', + ), + ), + ), + ), + ); + expect(changed, isTrue, reason: change); + } + await controller.chooseRaw(context, row); + expect( + (await coordinator.configuration).encode(), + before.encode(), + reason: change, + ); + expect(coordinator.applies, 0, reason: change); + expect(controller.selecting, isNull); + }); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + } + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'Raw deletion after precommit validation is caught inside the asset transaction', + (tester) async { + final context = await open(tester); + await tester.runAsync(() async { + final row = await raw(); + final before = await coordinator.configuration; + coordinator.afterValidation = () async { + await db.coreConfigDao.deleteRow(row); + }; + await controller.chooseRaw(context, row); + expect(coordinator.applies, 1); + expect(await db.coreConfigDao.searchRow(row.id), isNull); + expect((await coordinator.configuration).encode(), before.encode()); + expect(controller.selecting, isNull); + }); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} + +class _Coordinator extends ConnectionCoordinator { + _Coordinator(AppDatabase db) + : super( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + prepare: (_, _) async => + throw StateError('Raw selection must not prepare Core'), + start: (_) async => + throw StateError('Raw selection must not start VPN'), + stop: () async => throw StateError('Raw selection must not stop VPN'), + disposeStatus: () {}, + ); + int applies = 0; + Future Function()? afterValidation; + + @override + Future apply( + ConnectionConfiguration next, { + bool connect = false, + bool disconnect = false, + bool affectsRuntime = true, + bool allowReconnect = true, + String? expectedConfiguration, + Future Function()? writeAssets, + Future Function()? validateAssets, + ConfigurationImportDraft? imported, + PrepareConnection? prepare, + }) async { + applies++; + await super.apply( + next, + connect: connect, + disconnect: disconnect, + affectsRuntime: affectsRuntime, + allowReconnect: allowReconnect, + expectedConfiguration: expectedConfiguration, + writeAssets: writeAssets, + validateAssets: () async { + await validateAssets?.call(); + await afterValidation?.call(); + }, + imported: imported, + prepare: prepare, + ); + } +} diff --git a/test/pages/servers/subscription_open_test.dart b/test/pages/servers/subscription_open_test.dart index a84eb2b37..055140729 100644 --- a/test/pages/servers/subscription_open_test.dart +++ b/test/pages/servers/subscription_open_test.dart @@ -9,7 +9,6 @@ import 'package:onexray/core/network/client.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/import/controller.dart'; import 'package:onexray/pages/servers/import/page.dart'; -import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; @@ -145,9 +144,10 @@ Future _openMethods( home: Scaffold( body: Builder( builder: (context) => TextButton( - onPressed: () => showAppDialog( - context, - (_) => ServersImportPage(controller: controller), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ServersImportPage(controller: controller), + ), ), child: const Text('Open'), ), @@ -160,7 +160,7 @@ Future _openMethods( } Future _backAndReopen(WidgetTester tester) async { - await tester.tap(find.text('Back')); + await tester.tap(find.byType(BackButton)); await tester.runAsync(() => Future.delayed(Duration.zero)); await tester.pumpAndSettle(); expect(find.byType(ServerImportFormPage, skipOffstage: false), findsNothing); diff --git a/test/pages/servers/view_test.dart b/test/pages/servers/view_test.dart index 2a67dc17e..3249f42e1 100644 --- a/test/pages/servers/view_test.dart +++ b/test/pages/servers/view_test.dart @@ -6,12 +6,14 @@ import 'package:drift/native.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/constants.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/menus.dart'; import 'package:onexray/pages/servers/subscription/user_info.dart'; import 'package:onexray/pages/servers/view.dart'; @@ -36,6 +38,8 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster, ShadToast; +import 'fixtures/server_test_router.dart'; + class _Controller extends ServersController { _Controller({ required super.database, @@ -77,9 +81,27 @@ CoreConfigData _server(int id, {bool favorite = false, String? name}) => data: base64Encode( utf8.encode( jsonEncode({ - 'tag': name ?? 'Node $id', - 'protocol': 'vless', - 'streamSettings': {'network': 'xhttp', 'security': 'tls'}, + 'outbounds': [ + { + 'tag': name ?? 'Node $id', + 'protocol': 'vless', + 'settings': { + 'vnext': [ + { + 'address': 'example.test', + 'port': 443, + 'users': [ + { + 'id': '00000000-0000-4000-8000-000000000001', + 'encryption': 'none', + }, + ], + }, + ], + }, + 'streamSettings': {'network': 'xhttp', 'security': 'tls'}, + }, + ], }), ), ), @@ -94,6 +116,7 @@ void main() { late ConnectionCoordinator coordinator; late _Controller controller; late ScrollController scroll; + GoRouter? router; setUp(() { SharedPreferencesAsyncPlatform.instance = @@ -108,6 +131,8 @@ void main() { }); tearDown(() async { + router?.dispose(); + router = null; scroll.dispose(); await controller.close(); coordinator.dispose(); @@ -120,6 +145,7 @@ void main() { Locale locale = const Locale('en'), Brightness brightness = Brightness.light, bool groupPage = false, + bool routed = false, Size? groupViewport, double height = 900, ValueNotifier? pageVisibility, @@ -147,42 +173,54 @@ void main() { ); }, ); - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(brightness, mobile: mobile), - locale: locale, - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => ShadTheme( - data: AppTheme.shad(brightness), - child: ShadToaster(child: child!), - ), - home: Scaffold( - body: Row( - children: [ - if (!mobile) - SizedBox( - width: width <= AppLayout.compactDesktopBreakpoint - ? AppLayout.compactSidebarWidth - : AppLayout.desktopSidebarWidth, - ), - Expanded( - child: ResponsiveContent( - child: pageVisibility == null - ? browser - : ValueListenableBuilder( - valueListenable: pageVisibility, - child: browser, - builder: (_, active, child) => - TickerMode(enabled: active, child: child!), - ), - ), - ), - ], + final home = Scaffold( + body: Row( + children: [ + if (!mobile) + SizedBox( + width: width <= AppLayout.compactDesktopBreakpoint + ? AppLayout.compactSidebarWidth + : AppLayout.desktopSidebarWidth, + ), + Expanded( + child: ResponsiveContent( + child: pageVisibility == null + ? browser + : ValueListenableBuilder( + valueListenable: pageVisibility, + child: browser, + builder: (_, active, child) => + TickerMode(enabled: active, child: child!), + ), + ), ), - ), + ], ), ); + Widget themed(BuildContext context, Widget? child) => ShadTheme( + data: AppTheme.shad(brightness), + child: ShadToaster(child: child!), + ); + if (routed) router = serverTestRouter(home: (_) => home); + await tester.pumpWidget( + routed + ? MaterialApp.router( + routerConfig: router, + theme: AppTheme.material(brightness, mobile: mobile), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: themed, + ) + : MaterialApp( + theme: AppTheme.material(brightness, mobile: mobile), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: themed, + home: home, + ), + ); await tester.pumpAndSettle(); } @@ -288,14 +326,14 @@ void main() { await tester.enterText(find.byType(TextField).first, 'missing server'); await tester.pumpAndSettle(); - expect(find.text(l.prototypeNoMatchingSubscriptions), findsOneWidget); + expect(find.text(l.serverNoMatchingResources), findsOneWidget); expect(controller.groups(l), isEmpty); await tester.tap(find.byKey(const ValueKey('server-search-reset'))); await tester.pumpAndSettle(); expect(controller.search.text, isEmpty); expect(controller.query, isEmpty); - expect(find.text(l.prototypeNoMatchingSubscriptions), findsNothing); + expect(find.text(l.serverNoMatchingResources), findsNothing); expect(find.byKey(const ValueKey('server-search-clear')), findsNothing); expect(controller.groups(l), isNotEmpty); expect(controller.catalog.sortOrder, order); @@ -677,12 +715,28 @@ void main() { ), ]; controller.servers = [_server(1).copyWith(subId: 1)]; - await pumpBrowser(tester, width, locale: locale); + await pumpBrowser(tester, width, locale: locale, routed: true); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + final subscriptions = find.text(l.prototypeSubscriptions).first; + await tester.ensureVisible(subscriptions); + await tester.tap(subscriptions); + await tester.pumpAndSettle(); expect(find.byType(SubscriptionPackageSummary), findsWidgets); expect(tester.takeException(), isNull); - await pumpBrowser(tester, width, locale: locale, groupPage: true); + await tester.tap(find.text(controller.sources.single.name)); + await tester.pumpAndSettle(); + expect(find.byType(ServerSourcePage), findsOneWidget); expect(find.byType(SubscriptionPackageDetails), findsOneWidget); - expect(find.byType(SourceMenu), findsOneWidget); + expect( + find.widgetWithText(FilledButton, l.prototypeCheckForUpdates), + findsOneWidget, + ); + expect( + find.widgetWithText(OutlinedButton, l.prototypeEditSubscription), + findsOneWidget, + ); expect(tester.takeException(), isNull); }); } @@ -752,71 +806,80 @@ void main() { for (final brightness in Brightness.values) { for (final width in [427.0, 1160.0]) { - testWidgets( - 'node latency colors preserve metadata ($brightness, $width)', - (tester) async { - final palette = AppColorTokens.fallback(brightness).palette; - final samples = [ - (0, palette.runningBadge), - (500, palette.runningBadge), - (501, palette.restartingText), - (1000, palette.restartingText), - (1001, palette.primaryHover), - (PingDelayConstants.unknown, palette.mutedForeground), - (PingDelayConstants.error, palette.mutedForeground), - (PingDelayConstants.timeout, palette.mutedForeground), - ]; - controller.servers = [ - for (final (index, (delay, _)) in samples.indexed) - _server(index + 1).copyWith(delay: delay), - ]; - await pumpBrowser( - tester, - width, - brightness: brightness, - groupPage: true, + testWidgets('node latency colors preserve metadata ($brightness, $width)', ( + tester, + ) async { + final palette = AppColorTokens.fallback(brightness).palette; + final samples = [ + (0, palette.runningBadge), + (500, palette.runningBadge), + (501, palette.restartingText), + (1000, palette.restartingText), + (1001, palette.primaryHover), + (PingDelayConstants.unknown, palette.mutedForeground), + (PingDelayConstants.error, palette.mutedForeground), + (PingDelayConstants.timeout, palette.mutedForeground), + ]; + controller.servers = [ + for (final (index, (delay, _)) in samples.indexed) + _server(index + 1).copyWith(delay: delay), + ]; + await pumpBrowser( + tester, + width, + brightness: brightness, + groupPage: true, + ); + final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; + for (final (index, (delay, color)) in samples.indexed) { + final rowFinder = find.byKey(ValueKey(index + 1)); + await tester.scrollUntilVisible( + rowFinder, + 120, + scrollable: find.descendant( + of: find.byType(ServerGroupView), + matching: find.byType(Scrollable), + ), ); - final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; - for (final (index, (delay, color)) in samples.indexed) { - final rowFinder = find.byKey(ValueKey(index + 1)); - await tester.scrollUntilVisible( - rowFinder, - 120, - scrollable: find.descendant( - of: find.byType(ServerGroupView), - matching: find.byType(Scrollable), - ), - ); - final label = controller.health(l, controller.servers[index]); - final prefix = '${l.manualGroupUngrouped} · '; - final text = tester.widget( - find.descendant( - of: find.byKey(ValueKey(index + 1)), - matching: find.text('$prefix$label'), - ), - ); - final span = text.textSpan! as TextSpan; - expect(span.text, prefix); - expect(text.style!.color, palette.mutedForeground); - expect(span.children!.single.style!.color, color); - if (PingDelayConstants.isSuccessful(delay)) { - for (final background in [ - palette.card, - palette.selectedSurface, - ]) { - final foregroundLuminance = color.computeLuminance() + 0.05; - final backgroundLuminance = - background.computeLuminance() + 0.05; - final contrast = foregroundLuminance > backgroundLuminance - ? foregroundLuminance / backgroundLuminance - : backgroundLuminance / foregroundLuminance; - expect(contrast, greaterThanOrEqualTo(4.5)); - } + final label = controller.health(l, controller.servers[index]); + final text = tester.widget( + find.descendant( + of: find.byKey(ValueKey(index + 1)), + matching: find.text(label), + ), + ); + expect(text.style!.color, color); + final metadataFinder = find.descendant( + of: rowFinder, + matching: find.text( + '${controller.protocol(controller.servers[index])} · ${l.manualGroupUngrouped}', + ), + ); + expect( + metadataFinder, + findsOneWidget, + reason: tester + .widgetList( + find.descendant(of: rowFinder, matching: find.byType(Text)), + ) + .map((text) => text.data ?? text.textSpan?.toPlainText()) + .join(' | '), + ); + final metadata = tester.widget(metadataFinder); + expect(metadata.style!.color, palette.mutedForeground); + if (PingDelayConstants.isSuccessful(delay)) { + for (final background in [palette.card, palette.selectedSurface]) { + final foregroundLuminance = color.computeLuminance() + 0.05; + final backgroundLuminance = background.computeLuminance() + 0.05; + final contrast = foregroundLuminance > backgroundLuminance + ? foregroundLuminance / backgroundLuminance + : backgroundLuminance / foregroundLuminance; + expect(contrast, greaterThanOrEqualTo(4.5)); } } - expect(tester.takeException(), isNull); - }, - ); + } + expect(tester.takeException(), isNull); + }); } } @@ -915,7 +978,7 @@ void main() { runBatch: (_, _) async { batches++; if (!started.isCompleted) started.complete(); - await release.future; + await release.future.timeout(const Duration(seconds: 15)); return const [PingBatchResult(true, 20, '')]; }, ); @@ -942,13 +1005,8 @@ void main() { expect(ping.isPinging, isTrue); expect(AppEventBus.instance.state.pinging, isTrue); expect(controller.state.serverTests, isEmpty); - expect( - find.byType(ButtonProgressIndicator), - width > AppLayout.mobileBreakpoint && !groupPage - ? findsNWidgets(2) - : findsOneWidget, - ); - if (groupPage || width > AppLayout.mobileBreakpoint) { + expect(find.byType(ButtonProgressIndicator), findsOneWidget); + { expect( tester .widget( @@ -960,11 +1018,21 @@ void main() { .onPressed, isNotNull, ); - await tester.runAsync(() async { - await tester.tap( - find.widgetWithText(OutlinedButton, l.prototypeTestServers), + if (groupPage) { + await tester.runAsync(() async { + await tester.tap( + find.widgetWithText(OutlinedButton, l.prototypeTestServers), + ); + }); + } else { + await tester.runAsync( + () => tester.tap(find.byType(ServerMenu).first), ); - }); + await tester.pump(const Duration(seconds: 1)); + await tester.runAsync( + () => tester.tap(find.text(l.prototypeTestAgain)), + ); + } await tester.pump(); expect(find.byType(ShadToast), findsNothing); expect(controller.state.serverTests, hasLength(1)); @@ -1007,14 +1075,7 @@ void main() { }); } await tester.pumpAndSettle(); - expect( - batches, - groupPage - ? 3 - : width > AppLayout.mobileBreakpoint - ? 2 - : 1, - ); + expect(batches, groupPage ? 3 : 2); expect(controller.state.serverTests, isEmpty); expect(find.byType(ButtonProgressIndicator), findsNothing); expect(tester.takeException(), isNull); @@ -1023,14 +1084,23 @@ void main() { for (final locale in const [Locale('en'), Locale('ru'), Locale('fa')]) { testWidgets( - 'desktop subscriptions use equal-width cards and independent scrolls: $locale', + 'desktop resources keep three tabs and one content column: $locale', (tester) async { await pumpBrowser(tester, 1160, locale: locale); final browser = find.byType(ServerBrowser); - final group = find.byType(ServerGroupView); - const columnWidth = (1160 - 225 - 56 - 16) / 2; - expect(group, findsOneWidget); - expect(tester.getSize(group).width, closeTo(columnWidth, 1)); + expect(find.byType(ServerGroupView), findsNothing); + expect( + find.byKey(const ValueKey('server-resource-tabs')), + findsOneWidget, + ); + final rows = find.byType(ServerNodeRow); + expect(rows, findsNWidgets(2)); + final first = tester.getRect(rows.first), + last = tester.getRect(rows.last); + expect(first.width, closeTo(last.width, .01)); + expect(first.left, closeTo(last.left, .01)); + expect(first.bottom, lessThanOrEqualTo(last.top)); + expect(first.width, greaterThan(700)); expect( find.descendant( of: browser, @@ -1040,9 +1110,8 @@ void main() { widget.axisDirection == AxisDirection.down, ), ), - findsNWidgets(2), + findsOneWidget, ); - expect(find.byType(ServerNodeRow), findsNWidgets(3)); expect(find.byType(TabBar), findsNothing); expect(find.byType(VerticalDivider), findsNothing); expect(find.byType(PopupMenuButton), findsNothing); @@ -1053,7 +1122,7 @@ void main() { } testWidgets( - 'compact desktop stacks cards and browsing updates the shared detail', + 'compact desktop opens a full resource detail without changing selection', (tester) async { controller.sources = [ SubscriptionData( @@ -1079,35 +1148,45 @@ void main() { _server(1).copyWith(subId: 1), _server(2).copyWith(subId: 2), ]; - await pumpBrowser(tester, 900); - final group = find.byType(ServerGroupView); - expect(tester.getSize(group).width, closeTo(900 - 190 - 56, 1)); - await tester.tap(find.text('Second provider')); + await pumpBrowser(tester, 900, routed: true); + expect(find.byType(ServerGroupView), findsNothing); + final before = controller.configuration.encode(); + await tester.tap(find.text('Node 2')); await tester.pumpAndSettle(); - expect(controller.browsedOnMobile, isFalse); - expect(tester.widget(group).group.source!.id, 2); + expect(find.byType(ServerResourcePage), findsOneWidget); + expect(find.text('Second provider'), findsOneWidget); + expect(controller.configuration.encode(), before); + expect(controller.browsedOnMobile, isNull); expect(tester.takeException(), isNull); }, ); - testWidgets('mobile shows subscriptions without location tabs or labels', ( + testWidgets('mobile resources open details and keep selection explicit', ( tester, ) async { - await pumpBrowser(tester, 427); + await pumpBrowser(tester, 427, routed: true); expect(find.byType(TabBar), findsNothing); + expect(find.byKey(const ValueKey('server-resource-tabs')), findsOneWidget); expect(find.byType(ServerGroupView), findsNothing); final l = AppLocalizations.of(tester.element(find.byType(ServerBrowser)))!; - expect(find.text('outbound'), findsOneWidget); + expect( + find.textContaining(controller.protocol(controller.servers.first)), + findsWidgets, + ); expect(find.text(l.countryRegionName('JP')), findsNothing); expect(find.text(l.countryRegionName('SG')), findsNothing); - await tester.tap(find.text(l.manualGroupUngrouped)); + final before = controller.configuration.encode(); + await tester.tap(find.text('Node 1')); await tester.pumpAndSettle(); - expect(controller.browsedOnMobile, isTrue); + expect(find.byType(ServerResourcePage), findsOneWidget); + expect(find.widgetWithText(FilledButton, l.prototypeUse), findsOneWidget); + expect(controller.configuration.encode(), before); + expect(controller.browsedOnMobile, isNull); expect(tester.takeException(), isNull); }); for (final width in [427.0, 900.0, 1160.0]) { - testWidgets('node lists build lazily and scroll independently at $width', ( + testWidgets('node lists build lazily in one content scroll at $width', ( tester, ) async { final mobile = width <= AppLayout.mobileBreakpoint; @@ -1117,45 +1196,60 @@ void main() { _server(201 + index).copyWith(subId: index + 1), ]; await pumpBrowser(tester, width, groupPage: mobile); - final group = find.byType(ServerGroupView); + final group = mobile + ? find.byType(ServerGroupView) + : find.byType(ServerBrowser); final rows = find.descendant( of: group, matching: find.byType(ServerNodeRow), ); final groupScroll = find.descendant( of: group, - matching: find.byType(Scrollable), - ); - final list = tester.widget( - find.descendant(of: group, matching: find.byType(ListView)), + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + widget.axisDirection == AxisDirection.down, + ), ); - expect(list.childrenDelegate, isA()); - expect(list.shrinkWrap, isFalse); + expect(groupScroll, findsOneWidget); + if (mobile) { + final list = tester.widget( + find.descendant(of: group, matching: find.byType(ListView)), + ); + expect(list.childrenDelegate, isA()); + expect(list.shrinkWrap, isFalse); + } else { + final list = tester.widget( + find.descendant(of: group, matching: find.byType(SliverList)), + ); + expect(list.delegate, isA()); + } expect(rows.evaluate().length, inInclusiveRange(1, 25)); - expect(find.byKey(const ValueKey(200)), findsNothing); + final targetId = mobile ? 200 : 220; + final target = find.byKey( + mobile ? ValueKey(targetId) : ValueKey(('server', targetId)), + ); + expect(target, findsNothing); await tester.scrollUntilVisible( - find.byKey(const ValueKey(200)), + target, 1000, scrollable: groupScroll, maxScrolls: 40, ); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey(200)), findsOneWidget); + expect(target, findsOneWidget); expect(rows.evaluate().length, lessThan(25)); - final last = tester.widget( - find.byKey(const ValueKey(200)), - ); + final last = tester.widget(target); expect(last.showDivider, isFalse); if (!mobile) { - expect(scroll.offset, 0); final position = tester.state(groupScroll).position; - final offset = position.pixels; - expect(offset, greaterThan(0)); + expect(scroll.offset, greaterThan(0)); + expect(position, same(scroll.position)); scroll.jumpTo(200); await tester.pumpAndSettle(); expect(scroll.offset, 200); - expect(position.pixels, offset); + expect(position.pixels, 200); } expect(tester.takeException(), isNull); }); @@ -1186,7 +1280,10 @@ void main() { card.width, 1160 - AppLayout.desktopSidebarWidth - AppSpacing.page * 2, ); - expect(card.height, AppLayout.emptyStateDesktopMinHeight); + expect( + card.height, + greaterThanOrEqualTo(AppLayout.emptyStateDesktopMinHeight), + ); expect(find.text(l.prototypeNoServersYet), findsOneWidget); expect(find.byType(TextField), findsNothing); expect(find.byType(ServerGroupView), findsNothing); From d263c7ef95e73c8e0643057d7416dfef9ceb6647 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:48:59 +0800 Subject: [PATCH 08/60] refactor(ui): align tunnel and Xray tools with the shared page layout Expose read-only runtime diagnostics, preserve platform capability gates, keep mobile viewer controls touchable and improve muted text contrast. Validation: 402 advanced/settings/shared/data tests passed with one existing platform-dependent skip; layer dependencies and flutter analyze passed. Native acceptance is deferred to the final stage. --- lib/pages/advanced/tunnel/apple/page.dart | 2 +- lib/pages/advanced/tunnel/apple/wifi.dart | 2 +- lib/pages/advanced/tunnel/interface/page.dart | 2 +- lib/pages/advanced/tunnel/page.dart | 7 +- lib/pages/advanced/tunnel/windows/page.dart | 2 +- lib/pages/advanced/xray/data_update/page.dart | 4 +- lib/pages/advanced/xray/dialing/page.dart | 2 +- lib/pages/advanced/xray/geodata/detail.dart | 2 +- lib/pages/advanced/xray/geodata/page.dart | 33 +--- lib/pages/advanced/xray/lan_proxy/page.dart | 4 +- lib/pages/advanced/xray/page.dart | 20 ++- lib/pages/advanced/xray/ping/page.dart | 6 +- .../advanced/xray/runtime_code_view.dart | 41 ++++- lib/pages/main/advanced.dart | 2 + lib/pages/theme/color.dart | 2 +- .../advanced/xray/runtime_viewer_test.dart | 154 ++++++++++++------ 16 files changed, 180 insertions(+), 105 deletions(-) diff --git a/lib/pages/advanced/tunnel/apple/page.dart b/lib/pages/advanced/tunnel/apple/page.dart index 52040c230..ffb6062e4 100644 --- a/lib/pages/advanced/tunnel/apple/page.dart +++ b/lib/pages/advanced/tunnel/apple/page.dart @@ -126,7 +126,7 @@ class AppleVpnView extends StatelessWidget { return Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 14 : 48, + mobile ? 16 : 24, gutter, mobile ? 18 : 24, ), diff --git a/lib/pages/advanced/tunnel/apple/wifi.dart b/lib/pages/advanced/tunnel/apple/wifi.dart index 98dd35ca2..0355c98cb 100644 --- a/lib/pages/advanced/tunnel/apple/wifi.dart +++ b/lib/pages/advanced/tunnel/apple/wifi.dart @@ -53,7 +53,7 @@ class AppleWifiView extends StatelessWidget { return Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 17 : 48, + mobile ? 16 : 24, gutter, mobile ? 18 : 24, ), diff --git a/lib/pages/advanced/tunnel/interface/page.dart b/lib/pages/advanced/tunnel/interface/page.dart index 7020868df..4098af46a 100644 --- a/lib/pages/advanced/tunnel/interface/page.dart +++ b/lib/pages/advanced/tunnel/interface/page.dart @@ -92,7 +92,7 @@ class OutboundInterfaceView extends StatelessWidget { body: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 12 : 48, + mobile ? 16 : 24, gutter, mobile ? 18 : 24, ), diff --git a/lib/pages/advanced/tunnel/page.dart b/lib/pages/advanced/tunnel/page.dart index 63bd59a91..15551c90f 100644 --- a/lib/pages/advanced/tunnel/page.dart +++ b/lib/pages/advanced/tunnel/page.dart @@ -16,7 +16,7 @@ import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; import 'package:onexray/service/connect/settings.dart'; -/// Embedded below Advanced's tabs; the footer belongs to this full-page body. +/// The tunnel destination owns its platform draft and fixed action bar. class VpnTunnelPane extends StatelessWidget { final OpenTunnelPage? openTunnel; final PolicyEditorController Function()? createController; @@ -55,12 +55,13 @@ class VpnTunnelPane extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 22 : 54, + mobile ? 16 : 24, gutter, mobile ? 24 : 28, ), child: Column( - spacing: mobile ? 25 : 28, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, children: [ _section( icon: LucideIcons.activity, diff --git a/lib/pages/advanced/tunnel/windows/page.dart b/lib/pages/advanced/tunnel/windows/page.dart index ba7e1c452..511f53c9e 100644 --- a/lib/pages/advanced/tunnel/windows/page.dart +++ b/lib/pages/advanced/tunnel/windows/page.dart @@ -66,7 +66,7 @@ class WindowsVpnView extends StatelessWidget { body: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 12 : 48, + mobile ? 16 : 24, gutter, mobile ? 18 : 24, ), diff --git a/lib/pages/advanced/xray/data_update/page.dart b/lib/pages/advanced/xray/data_update/page.dart index e0d64af08..8fcf5b552 100644 --- a/lib/pages/advanced/xray/data_update/page.dart +++ b/lib/pages/advanced/xray/data_update/page.dart @@ -87,13 +87,13 @@ class AutoUpdatePage extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 12 : 48, + mobile ? 16 : 24, gutter, mobile ? 26 : 24, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: mobile ? 25 : 28, + spacing: 16, children: [ SettingSection( title: l.prototypeSubscriptions, diff --git a/lib/pages/advanced/xray/dialing/page.dart b/lib/pages/advanced/xray/dialing/page.dart index bfb84b790..1eed5b8cd 100644 --- a/lib/pages/advanced/xray/dialing/page.dart +++ b/lib/pages/advanced/xray/dialing/page.dart @@ -55,7 +55,7 @@ class ServerDialingPage extends StatelessWidget { : Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 14 : 48, + mobile ? 16 : 24, gutter, 24, ), diff --git a/lib/pages/advanced/xray/geodata/detail.dart b/lib/pages/advanced/xray/geodata/detail.dart index 4079bef20..f60461cb9 100644 --- a/lib/pages/advanced/xray/geodata/detail.dart +++ b/lib/pages/advanced/xray/geodata/detail.dart @@ -76,7 +76,7 @@ class _GeoDataFilePageState extends State { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 12 : 54, + mobile ? 16 : 24, gutter, 12, ), diff --git a/lib/pages/advanced/xray/geodata/page.dart b/lib/pages/advanced/xray/geodata/page.dart index 68eb6fa85..d0f0730a7 100644 --- a/lib/pages/advanced/xray/geodata/page.dart +++ b/lib/pages/advanced/xray/geodata/page.dart @@ -438,35 +438,10 @@ class _GeoDataPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: mobile ? 10 : 12, children: [ - if (mobile) ...[ - typeField, - nameField, - urlField, - Align(alignment: AlignmentDirectional.centerEnd, child: actions), - ] else if (viewport <= AppLayout.compactDesktopBreakpoint) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox(width: 120, child: typeField), - const SizedBox(width: 12), - Expanded(child: nameField), - ], - ), - urlField, - Align(alignment: AlignmentDirectional.centerEnd, child: actions), - ] else - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - SizedBox(width: 120, child: typeField), - const SizedBox(width: 12), - SizedBox(width: 170, child: nameField), - const SizedBox(width: 12), - Expanded(child: urlField), - const SizedBox(width: 12), - actions, - ], - ), + typeField, + nameField, + urlField, + Align(alignment: AlignmentDirectional.centerEnd, child: actions), if (state.formError != null) _error(context, state.formError!), ], ), diff --git a/lib/pages/advanced/xray/lan_proxy/page.dart b/lib/pages/advanced/xray/lan_proxy/page.dart index 8cf1eb265..73db3b663 100644 --- a/lib/pages/advanced/xray/lan_proxy/page.dart +++ b/lib/pages/advanced/xray/lan_proxy/page.dart @@ -55,13 +55,13 @@ class LanProxyPage extends StatelessWidget { : Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 14 : 48, + mobile ? 16 : 24, gutter, 24, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: mobile ? 25 : 28, + spacing: 16, children: [ Text( l.lanProxyNextStartNotice, diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index ea249c1de..c57ca4c7f 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -19,7 +19,7 @@ import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -/// Embedded in the root Advanced tab. Child details use the root router. +/// The Xray destination groups runtime facts and independent tool pages. class XrayRuntimePage extends StatelessWidget { const XrayRuntimePage({ super.key, @@ -28,6 +28,7 @@ class XrayRuntimePage extends StatelessWidget { required this.onSpeedTest, required this.onLog, required this.onConfig, + this.onDiagnostics, this.onLocalApi, this.onLanProxy, this.onServerDialing, @@ -38,6 +39,7 @@ class XrayRuntimePage extends StatelessWidget { final void Function(BuildContext) onSpeedTest; final void Function(BuildContext, LogFileViewerParams) onLog; final void Function(BuildContext, ConfigFileViewerParams) onConfig; + final void Function(BuildContext)? onDiagnostics; final void Function(BuildContext)? onLocalApi; final Future Function(BuildContext)? onLanProxy; final void Function(BuildContext)? onServerDialing; @@ -115,19 +117,31 @@ class XrayRuntimePage extends StatelessWidget { child: Padding( padding: EdgeInsetsDirectional.fromSTEB( gutter, - mobile ? 22 : 54, + mobile ? 16 : 24, gutter, mobile ? 24 : 28, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: mobile ? 25 : 28, + spacing: 16, children: [ _section( icon: LucideIcons.activity, title: l.prototypeRuntimeStatus, children: [ _runtimeCard(context, controller, l, mobile), + if (onDiagnostics != null) + _pushRow( + context, + mobile: mobile, + title: l.xrayDiagnostics, + subtitle: state.connection.failed + ? l.prototypeConnectionFailed + : l.xrayDiagnosticsHint, + icon: LucideIcons.stethoscope, + enabled: true, + onTap: () => onDiagnostics!(context), + ), ], ), _section( diff --git a/lib/pages/advanced/xray/ping/page.dart b/lib/pages/advanced/xray/ping/page.dart index afccbd77f..10892ea2c 100644 --- a/lib/pages/advanced/xray/ping/page.dart +++ b/lib/pages/advanced/xray/ping/page.dart @@ -98,7 +98,7 @@ class PingPage extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 12 : 48, + mobile ? 16 : 24, gutter, mobile ? 26 : 24, ), @@ -141,7 +141,7 @@ class PingPage extends StatelessWidget { ), ], ), - SizedBox(height: mobile ? 25 : 28), + const SizedBox(height: 16), SettingSection( title: l.prototypeSpeedTestUrl, icon: LucideIcons.globe, @@ -256,7 +256,7 @@ class PingPage extends StatelessWidget { ), ], ), - SizedBox(height: mobile ? 25 : 28), + const SizedBox(height: 16), Text( l.pingVpnAccuracyHint, style: AppTypography.settingsDetailNote.copyWith( diff --git a/lib/pages/advanced/xray/runtime_code_view.dart b/lib/pages/advanced/xray/runtime_code_view.dart index 1b2e1dcf5..58e6e33fc 100644 --- a/lib/pages/advanced/xray/runtime_code_view.dart +++ b/lib/pages/advanced/xray/runtime_code_view.dart @@ -37,8 +37,10 @@ class RuntimeCodeScaffold extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final horizontal = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); - final top = mobile ? 12.0 : 48.0; + final horizontal = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); + final top = mobile ? AppSpacing.mobilePage : AppSpacing.desktopPageTop; final bottom = mobile ? 18.0 : 24.0; final pill = Material( color: palette.surfaceHover, @@ -46,12 +48,30 @@ class RuntimeCodeScaffold extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onStatusPressed, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), - child: Text( - status, - style: AppTypography.runtimeCodePill.copyWith( - color: palette.mutedStrong, + child: ConstrainedBox( + constraints: onStatusPressed == null + ? const BoxConstraints() + : BoxConstraints( + minWidth: AppLayout.touchExtent( + 36, + platform: Theme.of(context).platform, + ), + minHeight: AppLayout.touchExtent( + 28, + platform: Theme.of(context).platform, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + child: Align( + widthFactor: 1, + heightFactor: 1, + child: Text( + status, + style: AppTypography.runtimeCodePill.copyWith( + color: palette.mutedStrong, + ), + ), ), ), ), @@ -60,7 +80,10 @@ class RuntimeCodeScaffold extends StatelessWidget { final export = OutlinedButton( onPressed: onExport, style: OutlinedButton.styleFrom( - minimumSize: const Size(0, 36), + minimumSize: Size( + 0, + AppLayout.touchExtent(36, platform: Theme.of(context).platform), + ), padding: const EdgeInsets.symmetric(horizontal: 11), textStyle: AppTypography.runtimeCodeAction, tapTargetSize: MaterialTapTargetSize.shrinkWrap, diff --git a/lib/pages/main/advanced.dart b/lib/pages/main/advanced.dart index dfca4ff93..a9e33546f 100644 --- a/lib/pages/main/advanced.dart +++ b/lib/pages/main/advanced.dart @@ -42,6 +42,8 @@ class XrayRootPage extends StatelessWidget { ), ), body: XrayRuntimePage( + onDiagnostics: (context) => + context.pushScoped(AppPageDestination.xrayDiagnostics), onGeodata: (context) => context.pushScoped(AppPageDestination.routingData), onUpdates: (context) => context.pushScoped(AppPageDestination.autoUpdate), diff --git a/lib/pages/theme/color.dart b/lib/pages/theme/color.dart index a53c4b47e..e0482a1d2 100644 --- a/lib/pages/theme/color.dart +++ b/lib/pages/theme/color.dart @@ -85,7 +85,7 @@ class AppPalette { secondary: Color(0xFFFFFFFF), secondaryForeground: Color(0xFF202735), muted: Color(0xFFF2F4F8), - mutedForeground: Color(0xFF697386), + mutedForeground: Color(0xFF606B7E), mutedStrong: Color(0xFF565D6D), accent: Color(0xFFEEF5FF), accentForeground: Color(0xFF3268EF), diff --git a/test/pages/advanced/xray/runtime_viewer_test.dart b/test/pages/advanced/xray/runtime_viewer_test.dart index d4c55f871..0de0cdca9 100644 --- a/test/pages/advanced/xray/runtime_viewer_test.dart +++ b/test/pages/advanced/xray/runtime_viewer_test.dart @@ -48,51 +48,57 @@ AppLocalizations _strings(WidgetTester tester) => AppLocalizations.of(tester.element(find.byType(RuntimeCodeScaffold)))!; void main() { - testWidgets('desktop runtime viewers share Advanced width and top spacing', ( - tester, - ) async { - _viewport(tester, size: const Size(1160, 688)); - for (final view in [ - LogFileViewerView( - state: const LogFileViewerPageState( - title: 'Access log', - fileExists: true, - lines: ['Desktop log line'], + testWidgets( + 'desktop runtime viewers use the shared content width and top spacing', + (tester) async { + _viewport(tester, size: const Size(1160, 688)); + for (final view in [ + LogFileViewerView( + state: const LogFileViewerPageState( + title: 'Access log', + fileExists: true, + lines: ['Desktop log line'], + ), + onExport: () {}, + onFollowTail: (_) {}, ), - onExport: () {}, - onFollowTail: (_) {}, - ), - ConfigFileViewerView( - state: const ConfigFileViewerPageState( - title: 'Runtime JSON', - text: _originalJson, - loading: false, + ConfigFileViewerView( + state: const ConfigFileViewerPageState( + title: 'Runtime JSON', + text: _originalJson, + loading: false, + ), + onExport: () {}, ), - onExport: () {}, - ), - ]) { - await tester.pumpWidget(_app(view, mobile: false)); - await tester.pumpAndSettle(); - final panel = tester.getRect(find.byKey(_panel)); - final export = tester.getRect(find.byType(OutlinedButton)); - final gutter = AppSpacing.advancedDesktopGutter(1160); - expect( - panel.left, - closeTo((1160 - AppLayout.advancedMaxWidth) / 2 + gutter, .01), - ); - expect( - panel.width, - closeTo(AppLayout.advancedMaxWidth - gutter * 2, .01), - ); - expect( - export.top, - closeTo(tester.getRect(find.byType(AppBar)).bottom + 48, .01), - ); - expect(export.bottom + 12, closeTo(panel.top, .01)); - expect(panel.height, greaterThanOrEqualTo(420)); - expect(tester.takeException(), isNull); - } - }); + ]) { + await tester.pumpWidget(_app(view, mobile: false)); + await tester.pumpAndSettle(); + final panel = tester.getRect(find.byKey(_panel)); + final export = tester.getRect(find.byType(OutlinedButton)); + final gutter = AppSpacing.advancedDesktopGutter(1160); + expect( + panel.left, + closeTo((1160 - AppLayout.advancedMaxWidth) / 2 + gutter, .01), + ); + expect( + panel.width, + closeTo(AppLayout.advancedMaxWidth - gutter * 2, .01), + ); + expect( + export.top, + closeTo( + tester.getRect(find.byType(AppBar)).bottom + + AppSpacing.desktopPageTop, + .01, + ), + ); + expect(export.bottom + 12, closeTo(panel.top, .01)); + expect(panel.height, greaterThanOrEqualTo(420)); + expect(tester.takeException(), isNull); + } + }, + variant: TargetPlatformVariant.only(TargetPlatform.macOS), + ); for (final locale in const [ Locale('en'), @@ -123,9 +129,12 @@ void main() { var l = _strings(tester); final panel = tester.getRect(find.byKey(_panel)); final export = find.widgetWithText(OutlinedButton, l.prototypeExport); - expect(tester.getSize(export).height, 36); - expect(tester.getRect(export).left, 14); - expect(panel.width, 399); + expect( + tester.getSize(export).height, + greaterThanOrEqualTo(AppLayout.touchExtent(36)), + ); + expect(tester.getRect(export).left, AppSpacing.mobilePage); + expect(panel.width, 427 - AppSpacing.mobilePage * 2); expect(panel.height, greaterThanOrEqualTo(470)); expect( tester.getRect(find.text(l.prototypeFollowing)).top, @@ -140,7 +149,10 @@ void main() { expect(line.textDirection, TextDirection.ltr); expect(line.style!.fontSize, 12); expect(line.style!.height, isNull); - expect(tester.widget(find.byType(AppBar)).actions, isNull); + expect( + tester.widget(find.byType(AppBar)).actions ?? const [], + isEmpty, + ); expect(tester.takeException(), isNull); await tester.pumpWidget( @@ -178,6 +190,54 @@ void main() { }); } + for (final (platform, minimum) in [ + (TargetPlatform.iOS, 44.0), + (TargetPlatform.android, 48.0), + ]) { + testWidgets( + 'runtime viewer actions retain $minimum touch targets on a wide $platform window', + (tester) async { + _viewport(tester, size: const Size(956, 500)); + var exports = 0; + var resumes = 0; + await tester.pumpWidget( + _app( + RuntimeCodeScaffold( + title: 'Runtime diagnostics', + status: 'Resume following', + exportLabel: 'Export', + note: 'Read only', + code: const SizedBox.shrink(), + onExport: () => exports++, + onStatusPressed: () => resumes++, + ), + mobile: false, + ), + ); + await tester.pumpAndSettle(); + final export = find.widgetWithText(OutlinedButton, 'Export'); + final resume = find + .ancestor( + of: find.text('Resume following'), + matching: find.byType(InkWell), + ) + .first; + for (final control in [export, resume]) { + expect(tester.getSize(control).width, greaterThanOrEqualTo(minimum)); + expect(tester.getSize(control).height, greaterThanOrEqualTo(minimum)); + await tester.tapAt( + tester.getRect(control).bottomCenter - const Offset(0, 2), + ); + await tester.pumpAndSettle(); + } + expect(exports, 1); + expect(resumes, 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant({platform}), + ); + } + testWidgets( 'missing and empty logs remain distinct; exporting disables only export', (tester) async { From 991e760090d6fdb86adbd5244b11ec600833ac3b Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:49:07 +0800 Subject: [PATCH 09/60] feat(settings): separate connection-resource cleanup from full data reset Keep local preferences, permissions and backup bindings during resource cleanup. Retain stop confirmation, producer suspension and transactional file/database rollback; document the new single-column navigation and data contracts. Validation: advanced/settings/shared/data suite passed 402 tests with the existing Linux arm64 locale skip; cleanup, backup and preference regressions passed; flutter analyze and layer dependencies passed. --- docs/app.md | 123 ++++++--- docs/data-management.md | 15 +- docs/validation.md | 44 ++- docs/xray-configuration.md | 3 +- lib/pages/settings/controller.dart | 40 ++- lib/pages/settings/page.dart | 27 +- lib/service/settings/data_cleanup.dart | 110 +++++++- test/pages/settings/cleanup_test.dart | 176 ++++++++++++ test/service/settings/data_cleanup_test.dart | 269 ++++++++++++++++++- 9 files changed, 737 insertions(+), 70 deletions(-) create mode 100644 test/pages/settings/cleanup_test.dart diff --git a/docs/app.md b/docs/app.md index 0d2b41e69..eea7eb70c 100644 --- a/docs/app.md +++ b/docs/app.md @@ -5,11 +5,15 @@ ## 导航与交互 -- 四个根 Tab 依次为连接、服务器、高级、设置,独立保留导航栈。紧凑布局使用底部导航, - 宽布局使用侧栏;完整业务页面均可在当前 Tab 内作为二级页面打开,包括其他根页面。 +- 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。 + 手机根页使用前五项底部导航,设置由顶部按钮进入;设置页和二级页面隐藏底部根导航。 + 平板和桌面宽布局保留带文字的左侧导航,设置位于侧栏底部;不再提供“高级”的二级 Tab 容器。 + 完整业务页面均可在当前分支内作为二级页面打开,包括其他根页面。 页面入口使用 `pushScoped`,不因打开服务器或设置而切换根 Tab。 -- 移动端二级页面隐藏底部根导航;宽布局侧栏允许主动切换 Tab,并保留各栈。Setup 独立于业务导航。 - 页面保留默认转场和 iOS 侧滑返回,标题及高级页 TabBar 固定,不随内容滚动。 +- 宽布局侧栏允许主动切换分支,并保留各栈。Setup 独立于业务导航。 + 页面保留默认转场和 iOS 侧滑返回,标题固定,不随内容滚动。 +- 业务内容在手机、平板和桌面均沿一条纵向内容流呈现,不使用左右主从详情或宽屏双列工作区。 + 服务器、规则和资源列表进入独立全宽详情;同一会话的并列统计值与紧凑动作不构成第二内容面。 - 宽布局先避让横向安全区,再分配侧栏内容宽度。侧栏高度不足时可滚动访问全部入口与更新提醒, 品牌保持单行;横竖屏切换不重置当前页面或各 Tab 的导航栈。 - 完整页面的共享 `PageActionBar` 固定在底部,保留安全区、键盘避让和动作换行;弹窗保留自己的操作区。 @@ -38,7 +42,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 填写内容后仍保留字段身份、编辑动作和错误语义;Age 密钥默认遮蔽。 macOS 原生输入桥接不公开字段名称,因此保留相邻可见标题供辅助阅读;这不替代真实可编辑节点。 选中背景上的小号强调正文和提示使用独立的不透明文字色,浅深色实际组合均满足 4.5:1 对比。 -高级页标签按实际文字宽度切换横向滚动并适应字号,隧道状态和只读值在空间不足时改为上下排列。 +资源类别标签按实际文字宽度滚动并适应字号,隧道状态和只读值在空间不足时改为上下排列。 UI-only 改动保持字段、语义、平台可见性、持久化和验证不变。 页面优先显示操作、当前值和状态,不重复解释标题与按钮。配置原理、平台边界和较长的使用说明 @@ -68,18 +72,34 @@ macOS 应用菜单“设置…”及 ⌘, 恢复并聚焦既有主窗口,进 ### 连接 App 管理的普通模式选择自动、订阅、全部手动节点或固定节点,以及智能路由、全部使用 VPN 或自定义路由。 -提供方方式逐项选择完整配置,不需要开启专家模式;连接页显示“提供方配置”和路由摘要, -App 流量方式切换不可用,Smart/Custom 偏好继续保存。模式改变或缓存隐藏后,运行名称仍来自 metadata 快照。 -专家模式用 Raw JSON 区域替换整个服务器区域,保留普通选择但不混用。 +连接页按状态与主操作、连接计划、实际运行、本次会话纵向呈现。连接计划的服务器行打开专用选择器, +按自动范围、固定节点、完整配置分类浏览;切换标签和搜索不提交选择,点击资源才申请变更。 +选中按资源种类和 ID 判断,不以同名资源或相同数字 ID 混淆来源、节点与 Raw;管理入口另开服务器页。 +提供方完整配置与个人 Raw 同处完整配置类别,不要求先切换专家浏览开关。 +完整配置使用自带路由,连接页相应行只读;普通 Smart/Custom 偏好继续保存,独立路由页可编辑备用偏好。 +计划与实际运行身份分开:运行名称、入口、最终出口、Raw 和提供方名称来自 metadata 快照, +不因资产重命名、来源方式改变、缓存隐藏或新的浏览选择而改写。自动跨范围回退单独提示实际结果。 固定提供方完整配置前的普通选择由本机可选记忆保留,与选择一起事务提交;提供方之间切换不覆盖旧普通意图。 返回普通计划先读取并确认,取消不修改选择或记忆;旧用户无记忆、引用缺失或来源方式不兼容时回到自动并要求重新选择。 普通路由偏好继续保留;记忆不进入资产备份,恢复/清理连接资产时清空。Raw 沿用原普通选择保留行为。 -仅打开 Raw 浏览区、尚未选择 Raw 时,不立即修改持久化连接目标;无关路由或平台偏好更新保留该本地浏览状态。 -其他页面或托盘保存新的专家模式、Raw 或服务器选择后,连接页同步对应视图,避免旧 Raw 浏览状态阻挡普通节点或提供方配置连接。 +选择器的标签和搜索属于本地浏览状态,取消不修改持久化目标或运行输入;无关偏好更新不代替一次资源选择。 +其他页面或托盘保存新的 Raw、节点或提供方选择后,连接页同步连接计划。 +提交前复核所选资产与活动来源方式;等待确认期间被删除、替换或切换为停用缓存的资源不能继续按旧列表提交。 配置类型、数量、节点链路和规则语义见 [Xray 配置](xray-configuration.md)。 -页面展示原生连接状态、实时速率和本次上下行;流量卡片无点击入口,不展示累计流量或单出口 IP。 +页面展示原生连接状态、实时速率、本次上下行与连接时长;未知指标显示未知,不伪造零速率、历史图或单出口 IP。 +本次会话为只读摘要,空库且没有进行中的连接时直接提供导入和恢复入口。 启停期间立即显示操作等待,不提前宣称已连接/断开;准备或连接阶段可取消,避免重复提交。 编辑正在使用的配置经确认重连;未启用资产或仅改名称不重连。 +启动不等待连接前测速,也不要求已有成功延迟;活动、可读取的候选足量即可进入准备。 +已有成功延迟优先,未测、失败和超时排后按 ID 稳定选取;结构、数量、最终出口、权限与停止确认仍须通过。 +测速与运行中的最低延迟观察继续独立保留,选择与回退边界见 [普通配置编译](xray-configuration.md#普通配置编译)。 + +### 路由 + +路由有独立首页,展示智能路由、全部使用 VPN 以及常规/高级自定义资产;使用与编辑为不同动作。 +Raw 或提供方完整图生效时,这里只保存普通路由偏好,不覆盖运行图或要求无效重连。 +自定义资产共用三份配额,删除、有效变更比较、依赖校验与确认重连继续交给生产服务; +损坏资产逐项隔离,不能阻断智能/全部模式和其他路由资产的管理。Geodata 入口复用 Xray 的同一资源库。 智能与常规路由复用 DNS、嗅探和代理选路表单。DNS 主代理/直连 resolver 与各自有序备用独立保存, 每项只编辑地址;全局缓存开关直接显示,默认开启缓存,其余 resolver 参数沿用 Core 默认值。 @@ -94,12 +114,14 @@ FakeDNS 开启时不能关闭嗅探,先关闭 FakeDNS 才能停用。 普通表单不提供域名/IP 排除,高级/Raw 原生配置与 JSON 字段提示保留。 所有设置只改草稿,经保存沿用现有校验和已连接确认重连流程;取消不持久化。 -智能路由按直连与拦截、代理连接、DNS 和流量嗅探分组;直连 DNS 开关归入 DNS, -FakeDNS 归入流量嗅探。内容宽度至少 720 时,平板和桌面共用双列:左侧直连与拦截及 DNS, -右侧代理连接、嗅探及预览。窄窗口按上述分组顺序单列显示,整页统一滚动。 +智能路由依次展示直连与拦截、代理连接、DNS、流量嗅探和预览,整页始终单列滚动。 +直连地区最多选择一个,也可显式清空,候选来自已安装索引;重复选择同项不取消选择。 +底层 `directRegions` 仍为 List 以读取历史配置,选择页仅投影首项;打开/取消不裁剪原列表。 +地区页“完成”只返回单项或空列表至父级草稿,整个 Smart 保存后才持久化;取消地区页或 Smart 编辑不提交更改。 +直连 DNS 开关归入 DNS,FakeDNS 归入流量嗅探。 常规路由的规则列表、FakeDNS/Fragment、DNS 和嗅探使用同级卡片,避免重复嵌套外框。 -规则区未打开编辑器时占满内容宽度;打开后,内容宽度至少 876 时规则列表与编辑器等宽并排,否则上下排列。 -下方设置区独立适配:内容宽度至少 720 时 DNS 居左,FakeDNS/Fragment 与嗅探居右;窄窗口保留单列顺序。 +所有宽度均从有序规则列表进入独立规则页,不保留常驻右侧编辑器;取消规则页不修改父草稿。 +FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、删除、拖动和上下移动。 调整窗口宽度保留各设置区的输入草稿、焦点、选区和展开状态。 路由分组标题保持相同最小高度;数量、规则条件与动作控件统一绘制前景外框,保留圆角与分隔线。 @@ -112,6 +134,11 @@ FakeDNS 归入流量嗅探。内容宽度至少 720 时,平板和桌面共用 ### 服务器 +首页按节点、完整配置、订阅三类资源浏览。节点与完整配置使用单列列表,普通节点、个人 Raw 和提供方配置 +保留明确类型;订阅来源有独立详情,展示当前方式、套餐、缓存状态及更新、编辑和分享入口。 +浏览资源或打开详情不修改连接目标;详情中的“使用”才提交选择,编辑、测速、分享和删除分别执行各自动作。 +完整配置类别包含个人 Raw 与提供方配置,原图查看、复制编辑与选择不互相替代。 + 服务器的订阅按 ID 升序,普通手动节点可整理为一层自定义分组;未分组固定第一,空自定义组保留。 支持新建、重命名和通过节点菜单移组;解散分组保留节点,删除分组及节点需确认数量与运行影响。 自定义组仅用于浏览和管理,不创建连接范围;全部手动节点入口仍覆盖所有组,节点可以单独选择。 @@ -119,19 +146,24 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 服务器列表及分组详情提供默认、名称和延迟排序,默认顺序不因测速结果更新而变化。 选择作为本机偏好立即保存,同步服务器浏览、最终出口选择与桌面托盘;搜索和收藏筛选保留所选顺序。 搜索非空时提供清除按钮,零匹配使用通用结果提示并提供清除搜索操作,不改写排序或收藏偏好。 -移动端根页保留固定的来源管理入口,无需滚动到列表末尾,空列表及空手动组不影响入口可用。 +订阅标签位于列表上方,来源管理不依赖滚动到节点末尾;空手动组不影响来源入口可用。 自定义分组和组内节点分别进入整理页面,手机长按手柄、桌面拖动手柄,也可用上下移动按钮。 整理使用独立草稿,保存后提交,取消或返回不写入;搜索期间不可整理,不支持跨组拖放。 整理页使用手动顺序,退出保留原显示排序偏好;默认排序展示保存的位置。成员变化时需重新加载草稿,测速和收藏更新不打断整理。 -显示排序不改变自动选择最快可用接入节点的规则,具体比较与刷新边界见 [数据管理](data-management.md#数据显示顺序)。 +显示排序不改变连接候选规则,未测或测速失败不禁用节点与最终出口选择;具体比较与刷新边界见 +[数据管理](data-management.md#数据显示顺序)及 [普通配置编译](xray-configuration.md#普通配置编译)。 普通节点显示 type=outbound、协议/传输/安全和延迟标签;成功延迟 ≤500 ms 为快、≤1000 ms 为慢,其余为可用。 +未测与测速失败使用独立状态文案,不把失败诊断当作延迟数值或不可连接结论。 提供方行显示 type=Remnawave,不展示普通延迟、不参加单 outbound 测速;来源组保持浏览及逐项固定选择入口。 原有自动选择在普通候选不足时可运行一份提供方配置,连接页按运行快照显示实际名称与提供方管理的路由; 断开后仍保留原选择意图,具体策略见 [配置合同](xray-configuration.md#普通配置编译)。 完整配置可选择、收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。 -普通节点仍用 JSON 编辑。连接 Tab 内打开的服务器页不改变服务器根 Tab,选择后保留当前页面并按打开顺序返回。 -独立节点 JSON 编辑按已加载或成功保存的原文判断未保存修改;光标移动不算修改,手动还原原文恢复干净状态。 -已修改时,关闭、取消、返回、遮罩和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。 +普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。 +节点、Raw 与高级 JSON 使用全宽表单和原有 `re_editor`,保留诊断位置、补全、撤销及固定保存区。 +按已加载或成功保存的原文判断未保存修改,Raw/高级同时比较名称;光标移动不算修改,手动还原原文和名称恢复干净状态。 +已修改时,关闭、取消、系统返回和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。 +侧滑受同一可退出状态约束,干净稿保留平台手势,不能越过未保存或保存中的退出限制。 +旧弹窗入口的遮罩关闭同样遵守节点编辑守卫,全宽页面不另提供遮罩退出路径。 明确放弃不写库,成功保存直接返回;保存期间产生的新草稿和迟到结果保护保持。订阅向导继续沿用原有草稿保留流程。 确认框按钮在文字无法完整横排时改为纵向全宽,保留系统字号、原有确认行为及可滚动正文。 @@ -149,9 +181,11 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 等待确认不入队、不增加共享 loading,连续点击不叠弹窗;取消、关弹窗或离开页面不提交。 继续测速不改变 VPN;自动测速不弹窗,同样受当前 VPN 路径影响。 -### 高级 +### VPN 隧道 -第三个根入口分为 VPN 隧道和 Xray,平台配置打开详情页: +VPN 隧道有独立主入口,公共参数与平台策略沿一条内容流呈现,平台配置打开全宽详情。 +公共页和子页继续使用同一策略草稿、字段诊断和固定操作栏;恢复默认不保存,取消不写入, +只有当前平台的有效变化经原服务确认后才重连。 - 隧道页展示只读地址,可编辑 IPv4/IPv6 DNS 和 Apple DoT 服务器域名,IPv6 使用独立开关。 Windows/Linux 才显示出口网卡,必须明确选择。 @@ -166,37 +200,45 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 广播自动化在独立详情页配置,立即保存,不重连,见 [外部接口](external-interfaces.md)。 - Windows 系统 VPN 入口仅 MSIX 显示,提供自动连接、局域网绕过及排除网段。 EXE 不应用或校验停用的 MSIX 策略,两种模式均保留公共设置和出口网卡。 -- Xray 提供路由数据、更新周期、测速 URL/超时、服务器拨号、日志、局域网代理共享和只读实际运行配置。 - 路由数据、数据更新、测速与服务器拨号集中为紧凑的设置导航,保留当前值摘要、加载反馈及原有详情入口。 - 服务器拨号在独立页面编辑域名解析策略与 Happy Eyeballs,默认系统解析、开关关闭;较长原理说明放在帮助中。 - Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用有效变更的确认重连流程, - 停用配置只保存偏好,作用模式与 DNS 边界见 [服务器拨号](xray-configuration.md#服务器拨号)。 - 共享开关在 Xray 区域,端口在独立页面;保存只保留偏好,下次 App 编译连接才生效,不弹重连。 - 说明 HTTP/SOCKS5、SOCKS5 UDP、可信网络无认证使用,以及个人 Raw/提供方方式不应用共享, - 后台 saved-start 和平台限制见 [LAN 共享](xray-configuration.md#局域网代理共享)。 - 桌面端另有本地 HTTP API;移动端不注册该入口或路由。 - System Extension 隐藏整个日志区和对应保存/恢复按钮,不提供原生日志读取;其他入口不受影响。 - 日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存; - 保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。 + +### Xray + +Xray 有独立主入口,依次展示真实运行摘要、诊断入口和工具分组,不与隧道共用二级标签。 +只读诊断页显示当前阶段、原错误、版本、运行时长及实际运行 JSON/日志入口;刷新只重新读取状态, +不启停 VPN、不发起网络测试,也不清除或宣称解决连接错误。无运行记录使用空状态,不能用部分规则预览代替实际 Core 输入。 + +路由数据、数据更新、节点延迟设置与服务器拨号集中为紧凑的设置导航,保留当前值摘要、加载反馈及原有详情入口。 +服务器拨号在独立页面编辑域名解析策略与 Happy Eyeballs,默认系统解析、开关关闭;较长原理说明放在帮助中。 +Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用有效变更的确认重连流程, +停用配置只保存偏好,作用模式与 DNS 边界见 [服务器拨号](xray-configuration.md#服务器拨号)。 +共享开关在 Xray 区域,端口在独立页面;保存只保留偏好,下次 App 编译连接才生效,不弹重连。 +说明 HTTP/SOCKS5、SOCKS5 UDP、可信网络无认证使用,以及个人 Raw/提供方方式不应用共享, +后台 saved-start 和平台限制见 [LAN 共享](xray-configuration.md#局域网代理共享)。 +桌面端另有本地 HTTP API,继续沿用即时开关、端口提交与 Token 重置语义;移动端不注册该入口或路由, +服务合同见 [外部接口](external-interfaces.md)。 +System Extension 隐藏整个日志区和对应保存/恢复按钮,诊断和深链同样限制日志访问;其他入口不受影响。 +日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存; +保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。 数据更新页仅管理订阅与 Geodata 的自动更新开关和周期。User-Agent 在每个订阅表单单独管理,默认填入兼容 UA, 用户可自定义;清空后仍使用订阅默认值,规则见 [订阅合同](subscriptions-and-sharing.md)。 -高级页没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运行边界](xray-configuration.md#raw-json)。 +App 没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运行边界](xray-configuration.md#raw-json)。 测速设置只提供延迟 URL 和超时,并说明 VPN 运行时结果可能不准确、建议关闭 VPN 后测速。 保存影响后续手动和自动任务,无需重连;取消不保存,正在执行的任务使用开始时的快照。 -出口地区探测与位置缓存已移除,不影响目标 GeoIP/Geosite、Smart 直连地区或 Setup 地区配置。 +服务器出口地区探测、地区筛选/选择范围与位置缓存已移除,不恢复地区字段;这不影响目标 GeoIP/Geosite、Smart 直连地区或 Setup 地区配置。 ### 设置 -设置管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。 +设置以单列分组管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。 首页主题选项按当前字号、标签实际宽度和可用空间选择横排或纵排,不按语言硬编码,也不缩小系统字号。 选中状态具有语义标记,点击后立即保存;Apple 图标行空间不足时将当前值移至标题下方自然换行。 Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢复见 [备份](backup.md)。 自动备份开关位于分区标题旁,只显示一次标题,并保留开关名称、状态及操作中的禁用和进度反馈。 周期仍在同一分区编辑,Apple 位置选择保留 iCloud 主动作,敏感数据提醒及恢复、覆盖确认保持原规则。 关于展示 App/Xray-core 版本;有新版本时入口显示圆点,桌面侧栏保留更新提醒。 -清理成功留在业务页面,不重新进入 Setup;清理失败阶段与数据保护见 [数据管理](data-management.md)。 +清理连接资源与清理全部数据是不同操作;成功留在业务页面,不重新进入 Setup, +保留范围、失败阶段与数据保护见 [清理数据](data-management.md#清理数据)。 ## 启动与首次初始化 @@ -209,7 +251,8 @@ Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢 4. 辅助通知、托盘、本地 API 等各自捕获失败,不统一阻断首页;关键准备失败显示重试并打开主窗口。 待下载 Geodata、云端错误或后台测速不阻断初始化,恢复边界见 [数据管理](data-management.md)。 -Setup 地区复用智能路由单选页:识别仅预选候选,清空经“完成”提交,取消不改草稿。 +Setup 地区复用智能路由单选页:识别仅预选候选,单选替换或显式清空经“完成”返回父稿,取消不改草稿。 +历史列表只在选择界面投影首项,不在打开/取消时改写已有配置。 Windows/Linux 网卡不默认选第一张,必须存在;其他平台隐藏。 “进入首页”将地区与网卡一起保存,成功才写完成标记;失败保留草稿。 已完成标记只决定入口,不跳过已进入 Setup 的必要检查。 diff --git a/docs/data-management.md b/docs/data-management.md index 5431b7f8a..1b1252b8c 100644 --- a/docs/data-management.md +++ b/docs/data-management.md @@ -13,7 +13,8 @@ v1–v2 创建新结构时不新增退休地区列,v3–v6 删除已有列, 手动分组独立存储名称与顺序,普通手动节点保存可空的分组引用和组内顺序; 未分组为虚拟分组,空自定义组保留。旧节点升级后归入未分组,同序时按 ID 保持原有次序,不改写配置原文。 未知连接选择 kind 在普通读取和运行 metadata 中统一回退 automatic,不为旧地区选择增加专门迁移或改写原文。 -目标 GeoIP/Geosite、Smart 直连地区、Setup 地区与语言国家代码仍属于有效功能。 +服务器地区筛选/选择范围随节点出口地区列退役,不恢复该字段;它与目标 GeoIP/Geosite、Smart 直连地区、Setup 地区及语言国家代码不同。 +后者仍为有效功能;Smart/Setup UI 保持单选/清空,底层历史 `directRegions` List 不迁移或因打开/取消页面而裁剪。 迁移不联网、不生成标识、不更新订阅,不在运行时复制全表验证或制作数据库快照。 `StoragePreparation` 在首次业务访问前打开数据库并完成迁移,不依赖 VPN 或 Setup。 @@ -132,15 +133,21 @@ macOS System Extension 因跨容器传输是唯一例外:清单纳入根目录 ## 清理数据 -`AppDataCleanupService` 只协调清理与备份恢复:先暂停接收相关模块新操作,再等待必要在途操作; +`AppDataCleanupService` 协调清理连接资源、清理全部数据与备份恢复:先暂停接收相关模块新操作,再等待必要在途操作; 订阅导入不继续下一来源,测速完成当前原生批次后保存并取消后续请求,不等待全部排队测速。 取得 Geodata 文件队列后确认原生 VPN 停止,再替换数据;停止失败不继续删除,旧回包不能在替换后写回。 新连接命令在临界区拒绝,未开始命令取消,不在结束后自动执行旧保存/启动。成功或失败都恢复模块受理。 +清理连接资源只清节点/Raw、订阅及派生缓存、手动分组、常规/高级路由、自定义 Geodata 来源与文件及旧运行输入/日志。 +选择与普通计划记忆清空,恢复普通模式、自动节点和智能路由;平台 VPN/拨号/网卡策略、本机偏好、登录项、默认 Geodata、 +API/Android 自动化授权和 Token、备份目标及外部文件均保留,不重连、不更新订阅、不测速或立即回写备份。 +该操作复用 Geodata 局部恢复日志与数据库事务,运行目录同盘暂存到事务完成;文件或提交失败回滚旧资产与运行文件。 +成功后清除会话视图及旧测速错误,默认资源不重建,剩余 API/后台服务恢复受理。 + 清理全部数据取消登录项、清空数据库/运行文件/用户偏好并重建默认资源,不重跑 Setup; 登录项取消失败在删除前停止,具体窗口与退出边界见 [App](app.md)。 备份恢复替换连接资产及匹配平台的 VPN 设置,保留本机绑定、授权和偏好,见 [备份](backup.md#恢复与缺失资源)。 -两者都保留 Android 跨进程 START 准入保护,见 [外部接口](external-interfaces.md#设备授权与数据操作)。 -清理移除本机备份绑定,不删除云文件。 +三者都保留 Android 跨进程 START 准入保护,见 [外部接口](external-interfaces.md#设备授权与数据操作)。 +仅清理全部数据移除本机备份绑定,所有清理均不删除外部云文件。 平台验证与数据隔离遵循 [验证边界](validation.md);真实旧安装包、文件中断恢复和 SE 传输/按需启动须在对应环境核对。 diff --git a/docs/validation.md b/docs/validation.md index 7debb1e57..e61562321 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -11,6 +11,7 @@ | 场景 | 主要结果 | | --- | --- | | 启动与恢复 | 服务就绪后首页可用;缺库或默认资源丢失可离线恢复 | +| 导航与浏览 | 六目的地独立保栈,手机五底栏和顶部设置、宽屏文字侧栏与底部设置可达;业务内容单列 | | 导入与选择 | 普通节点与完整数组入库、活动方式计数、列表 Stream、普通测速与完整配置选择符合类型 | | 订阅更新 | 双缓存事务更新,保留准备/待启动/运行/固定/收藏原行与来源身份;切换不热重连 | | 配置与连接 | 普通/高级/Raw/提供方配置的保存、适配、分享与编译;先停止旧连接再启动 | @@ -36,14 +37,17 @@ 按[界面合同](app.md)选择代表性窗口、语言和系统字号,不以字号缩小或控件存在代替实际可操作性。 -- 短高横屏叠加侧边和底部安全区,检查侧栏品牌、全部导航入口和更新提醒可见或可滚动到达; - 横竖屏往返保留当前详情及不同 Tab 的导航栈。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 +- 代表性手机、平板和桌面窗口分别核对连接/服务器/路由/隧道/Xray/设置六个目的地。 + 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。 + 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。 + 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 - iOS 44×44 pt、Android 48×48 dp 的实际点击区域分别检查,宽横屏仍使用对应平台下限。 点击小图标外围应触发正确动作且只执行一次;关闭、返回、确认及禁用行为不变,键盘和安全区不遮挡动作。 入口数量选择器覆盖三项边缘命中、选中/禁用、端点圆角及 RTL,桌面维持紧凑尺寸。 - 智能与常规路由检查同级卡片、标题高度、宽屏详情右对齐,以及规则条件、动作、建议列表和拖拽行的圆角;选中背景不覆盖外框。 - 常规路由分别检查规则区和设置区的列断点;未打开规则时不预留空编辑列,缩放窗口保留 DNS 焦点、选区与嗅探展开状态。 - 小屏最大辅助字号下,高级页标签保持完整且不覆盖正文,状态和 TUN 值可换行,DNS 输入与底部操作可滚动到达。 + 智能与常规路由检查同级卡片、标题高度,以及规则条件、动作、建议列表和拖拽行的圆角;选中背景不覆盖外框。 + 各宽度按直连/拦截、代理、DNS、嗅探、预览保持阅读顺序;规则始终进入独立全宽页,取消不改父稿。 + 缩放窗口保留 DNS 焦点、选区与嗅探展开状态,不预留空编辑列。 + 小屏最大辅助字号下,资源类别标签保持完整且不覆盖正文,状态和 TUN 值可换行,DNS 输入与底部操作可滚动到达。 - 订阅名称、URL、User-Agent 和 Age 输入在空值/已填/错误/禁用时保留真实可编辑语义名称, 显示/隐藏密钥不丢角色和值保护。分别记录 Widget Semantics、原生 AX 与实际 VoiceOver 播报,不能相互代替。 macOS 检查相邻可见标题仍可遍历,不把这项回退当作原生 AXTextField 命名已修复。 @@ -54,8 +58,14 @@ - 连接阶段依次覆盖断开、准备、连接、已连接、断开过程及失败;静态 VPN 名称和图标不预告成功, 文案与状态色随真实阶段变化。明暗主题分别核对正常小字对比度,运行文本在普通及运行背景上均满足 4.5:1。 智能路由预览正文与多入口提示计算实际颜色、背景及透明度组合,浅深色均达到 4.5:1。 -- 独立节点 JSON 编辑覆盖干净/修改/手动还原、关闭/取消/返回/遮罩/Escape、继续/明确放弃、 - 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;明确放弃不写库,成功保存不循环提示。 +- 连接页检查计划与实际运行分离、Raw/provider 名称及入口/最终出口快照、回退来源与实际目标说明, + 重命名、隐藏缓存或新计划不改写运行身份;未知速率/流量/时长不填假值。空库首屏的导入、恢复和完整配置入口可达。 + 目标选择器覆盖三标签、搜索、长列表按需构建、管理返回与取消不提交;来源/节点同数字 ID、Raw/provider 同名不串选。 + 重连确认取消保留持久化选择和运行快照;确认前资产被删或来源方式改变时不得按旧列表提交。 +- 节点、Raw 和高级 JSON 全宽编辑覆盖干净/修改/手动还原、关闭/取消/系统返回/Escape、继续/明确放弃、 + 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;光标移动不算修改,名称与原文还原恢复干净。 + 旧节点弹窗另覆盖遮罩关闭,全宽页不增加遮罩路径;明确放弃不写库,成功保存不循环提示。 + iOS 干净稿保留原侧滑,未保存或保存中的稿不能经手势绕过退出守卫,不以 Widget 返回替代真机手势验收。 375×667 最大辅助字号下,中俄确认动作完整显示并可点击;按钮纵排时正文仍可滚动。 - 隧道 DNS 覆盖首错离焦/提交、修正、无关修改、IPv6/DoT 生效开关、逐项修正及原始错误复制。 聚焦和滚动仅针对当前页仍有效的错误;没有对应字段的子页保留页级诊断,存储失败不伪装成本地字段错误。 @@ -65,7 +75,15 @@ 弹窗首帧前及窗口恢复期间同样拦截;后台分支弹窗不阻挡当前分支,后台路由变化不覆盖根弹窗状态。 使用真实布尔偏好关闭启动自动连接,现场始终不启动 VPN、不截图,以菜单/AX/窗口状态和日志记录。 - 搜索清除和零结果重置恢复列表,保留排序、收藏偏好;移动端空列表、空手动组和长列表均可进入来源管理。 + 服务器节点/完整配置/订阅三类计数和类型准确;打开资源或来源详情仅浏览,显式“使用”才提交连接选择。 节点测速错误初始折叠,展开后可选择、复制原始诊断,后续成功移除错误,折叠不删除诊断。 +- 独立路由首页分别验证使用、编辑和删除,常规/高级共用配额;一项坏资产不阻断其他模式和资源管理。 + 完整图运行时保存普通路由偏好不覆盖当前图或触发无效重连。Smart 地区验证单选替换、重复选择幂等和显式清空, + 候选仅取已安装索引;历史 List 不迁移,选择页投影首项,打开/取消不裁剪原值。 + 地区 Done 只改父稿,父页保存才提交,取消不写入;Setup 使用同一地区选择行为。服务器地区筛选/选择范围不恢复。 +- VPN 隧道与 Xray 分别进入独立分支,平台权限、能力、MSIX 和移动/桌面入口限制仍由真实平台决定。 + 只读诊断刷新保留 phase 与原错误,不启停、不测速、不假报检查通过;无运行输入有空状态,运行 JSON 来自实际 Core 输入。 + SE 不显示日志区,诊断与直接路由也不能绕过日志能力限制;LAN 下次启动生效、本地 API 即时开关和 Token 语义不随页面迁移改变。 - 主题标签在普通、大字和受限空间下正确切换横排或纵排,选中语义与即时保存一致; App 图标的标题与当前值不因半宽布局碎裂或丢失,窄布局中的值自然换行。 自动备份标题不重复,开关保留可访问名称、状态、忙态与禁用条件,周期编辑及 iCloud 主动作不变, @@ -112,8 +130,10 @@ 桌面不携带来源旧 FD。编辑语义比较使用当前隧道 DNS 设置,与运行补充一致,不改写保存原文。 - 连接页显示 provider 名称快照与完整路由说明,不伪装成 App 多入口或单节点延迟; App Smart/Custom/LAN 偏好保存不影响 provider 会话,平台策略有效变更保持原重连流程。 -- 自动选择覆盖纯普通、纯提供方、混合、接入数量不足及健康不足;提供方按 ID 稳定选取且限定活动缓存和来源范围。 - 读取/测速异常、取消、编译或启动失败不尝试其它完整配置;保存意图与运行 profile 分离,重启仍显示原自动选择。 +- 自动选择覆盖纯普通、纯提供方、混合及活动候选数量不足;提供方按 ID 稳定选取且限定活动缓存和来源范围。 + 未测、测速失败和超时均不构成启动门槛,挂起的手动/后台测速不能阻塞准备,不因缺少成功延迟触发 provider/global 回退。 + 成功延迟优先,其他候选排后按 ID 稳定选取;仍保留活动类型、JSON 可读取、数量、最终出口和取消检查。 + 资产读取异常、取消、编译或启动失败不尝试其它完整配置;保存意图与运行 profile 分离,重启仍显示原自动选择。 删除活动最后一项、仍有提供方可重连、模式切换及非活动缓存分别检查;固定选择和个人 Raw 不获得自动兜底。 - 备份 panel/mode 往返、旧文件缺省与未知字段容忍;订阅缓存排除,恢复不联网、不自动连接,正常刷新重新获取。 `platformSettings` 分别覆盖 iOS/macOS/Android/Windows/Linux 的同平台恢复与跨平台保留,iOS/macOS 不交叉恢复。 @@ -143,13 +163,15 @@ iOS Simulator 不能通过绕过它来验证刷新。设备验收需要检查 VP 显示顺序验证默认顺序在延迟更新后稳定、名称与延迟顺序的确定性、混合普通节点/完整配置, 以及浏览、分组、筛选、最终出口和托盘的一致性。保存/重启/未知值/清理覆盖本机偏好; -自动接入仍按真实成功延迟选取,不能用当前显示顺序替代连接候选。刷新继续沿用资产替换与保护流程。 +自动接入按成功延迟优先、未测/失败/超时按 ID 排后选取,不等待测速;不能用当前显示顺序替代连接候选。 +固定节点与最终出口同样允许未知或失败延迟,排除入口、自引用和无效资产的非测速约束保留; +运行中的 leastPing 仍只观察实际多条完整链,单链退化和高级 direct fallback 例外不变。刷新继续沿用资产替换与保护流程。 手动分组覆盖空组、未分组、创建/重命名/移组、解散保留节点与整组删除;旧库升级保留原顺序。 默认模式共享组序和组内顺序,名称/延迟显示偏好不改写它们;全部手动节点仍是 source:0,文件夹不创建连接范围。 整理草稿保存、取消、返回与重启分别核对;延迟/收藏更新不打断草稿,成员变化使旧草稿保存失败,重新加载后可重试。 空组及组织顺序随备份往返,恢复映射新数据库 ID,旧 v1 恢复为未分组;清理和恢复不遗留旧组。 -代表性长列表检查按需构建、首尾拖动、边缘滚动和桌面双栏独立滚动;手机长按与桌面鼠标拖动在各自平台验收。 +代表性长列表检查按需构建、首尾拖动、边缘滚动及全宽单列详情;手机长按与桌面鼠标拖动在各自平台验收。 拨号验证旧 JSON 的 AsIs 缺省、策略往返、IPv6 关闭后的有效值与偏好保留、有效变更的确认/取消、 同效或停用配置只保存、备份排除,以及等待连接队列后再次核对配置与重连授权。 diff --git a/docs/xray-configuration.md b/docs/xray-configuration.md index db475ad61..df2d20c20 100644 --- a/docs/xray-configuration.md +++ b/docs/xray-configuration.md @@ -50,7 +50,8 @@ Raw 用户字段不适用此删减。 智能路由把直连域名/IP 分别去重合并为一条规则,无条件时省略对应规则。 顺序为广告阻断、Windows 开关对应的 GitHub 代理、合并域名直连、合并 IP 直连。 Windows 服务直连使用 Microsoft/Bing 分类,同时优先代理 GitHub;该开关在所有平台显示。 -直连地区来自安装的官方分类与随包映射。广告、FakeDNS、Fragment 默认关闭,其余智能开关默认开启;保留已保存值。 +直连地区来自安装的官方分类与随包映射,Smart/Setup UI 保持单选/显式清空;底层历史 List 仍可读取和编译,不在本轮迁移中删字段或改写历史配置。 +这项直连规则配置与已退役的服务器地区筛选、节点出口地区缓存无关。广告、FakeDNS、Fragment 默认关闭,其余智能开关默认开启;保留已保存值。 ### DNS diff --git a/lib/pages/settings/controller.dart b/lib/pages/settings/controller.dart index ccc7767ad..c496a98a8 100644 --- a/lib/pages/settings/controller.dart +++ b/lib/pages/settings/controller.dart @@ -29,6 +29,7 @@ class SettingsPageState { final bool saving; final bool checkingUpdate; final bool clearingData; + final String cleanupScope; final bool addingWidget; final Object? failure; const SettingsPageState({ @@ -40,6 +41,7 @@ class SettingsPageState { this.saving = false, this.checkingUpdate = false, this.clearingData = false, + this.cleanupScope = '', this.addingWidget = false, this.failure, }); @@ -53,6 +55,7 @@ class SettingsPageState { bool? saving, bool? checkingUpdate, bool? clearingData, + String? cleanupScope, bool? addingWidget, Object? failure, bool clearFailure = false, @@ -65,6 +68,7 @@ class SettingsPageState { saving: saving ?? this.saving, checkingUpdate: checkingUpdate ?? this.checkingUpdate, clearingData: clearingData ?? this.clearingData, + cleanupScope: cleanupScope ?? this.cleanupScope, addingWidget: addingWidget ?? this.addingWidget, failure: clearFailure ? null : failure ?? this.failure, ); @@ -207,14 +211,27 @@ class SettingsController extends PageCubit { } } - Future clearData(BuildContext context) async { + Future clearData( + BuildContext context, { + bool resourcesOnly = false, + }) async { if (state.clearingData) return; final l10n = AppLocalizations.of(context)!; - emit(state.copyWith(clearingData: true, clearFailure: true)); + emit( + state.copyWith( + clearingData: true, + cleanupScope: resourcesOnly ? 'resources' : 'all', + clearFailure: true, + ), + ); try { if (!await AppConfirmationDialog( - title: l10n.prototypeClearAllDataQuestion, - content: l10n.prototypeClearAllDataWarning, + title: resourcesOnly + ? l10n.clearConnectionResources + : l10n.prototypeClearAllDataQuestion, + content: resourcesOnly + ? l10n.clearConnectionResourcesWarning + : l10n.prototypeClearAllDataWarning, cancelLabel: l10n.prototypeCancel, confirmLabel: l10n.prototypeConfirmClearData, destructive: true, @@ -223,16 +240,23 @@ class SettingsController extends PageCubit { !context.mounted) { return; } - if (await AppDataCleanupService().clearFromSettings()) { - AppStartupService().suppressConnectOnAppLaunch(); - emit(state.copyWith(connectOnLaunch: false)); + if (await (resourcesOnly + ? AppDataCleanupService().clearConnectionResources() + : AppDataCleanupService().clearFromSettings())) { + if (context.mounted) { + ContextAlert.showToast(context, l10n.prototypeDone); + } + if (!resourcesOnly) { + AppStartupService().suppressConnectOnAppLaunch(); + emit(state.copyWith(connectOnLaunch: false)); + } } else if (context.mounted) { _showUnavailable(context); } } catch (error) { if (context.mounted) _showUnavailable(context, error); } finally { - emit(state.copyWith(clearingData: false)); + emit(state.copyWith(clearingData: false, cleanupScope: '')); } } diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index 3ec261c6d..7dfb8db3a 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -196,7 +196,28 @@ class SettingsPage extends StatelessWidget { ), ), SettingRow( - title: l10n.prototypeClearData, + title: l10n.clearConnectionResources, + enabled: !state.clearingData, + trailing: + state.clearingData && + state.cleanupScope == 'resources' + ? const ButtonProgressIndicator(size: 20) + : null, + minHeight: rowHeight, + contentPadding: rowPadding, + titleStyle: AppTypography.settingsDanger.copyWith( + color: palette.destructive, + ), + onTap: state.clearingData + ? null + : () => controller.clearData( + context, + resourcesOnly: true, + ), + ), + SettingRow( + title: l10n.clearAllAppData, + enabled: !state.clearingData, minHeight: rowHeight, contentPadding: rowPadding, titleStyle: @@ -204,7 +225,9 @@ class SettingsPage extends StatelessWidget { ? AppTypography.settingsDanger : AppTypography.desktopSettingsDanger) .copyWith(color: palette.destructive), - trailing: state.clearingData + trailing: + state.clearingData && + state.cleanupScope != 'resources' ? const ButtonProgressIndicator(size: 20) : null, onTap: state.clearingData diff --git a/lib/service/settings/data_cleanup.dart b/lib/service/settings/data_cleanup.dart index 2353c6410..22f769db2 100644 --- a/lib/service/settings/data_cleanup.dart +++ b/lib/service/settings/data_cleanup.dart @@ -9,6 +9,8 @@ import 'package:onexray/core/pigeon/constants.dart'; import 'package:onexray/core/tools/logger.dart'; import 'package:onexray/service/launch/app_startup.dart'; import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/advanced/xray/geodata/service.dart'; import 'package:onexray/service/advanced/local_api/service.dart'; import 'package:onexray/service/advanced/xray/data_update/service.dart'; @@ -41,8 +43,9 @@ final class AppDataCleanupService { this._ping, this._clearOverride, this._backup, - this._automation, - ); + this._automation, { + String? runtimeDirectory, + }) : _runtimeDirectoryOverride = runtimeDirectory; @visibleForTesting factory AppDataCleanupService.forTesting({ @@ -53,6 +56,7 @@ final class AppDataCleanupService { required Future Function() clear, BackupService? backup, AndroidAutomationService? automation, + String? runtimeDirectory, }) => AppDataCleanupService._( coordinator, geodata, @@ -61,6 +65,7 @@ final class AppDataCleanupService { clear, backup, automation ?? AndroidAutomationService(), + runtimeDirectory: runtimeDirectory, ); final ConnectionCoordinator _coordinator; @@ -70,8 +75,109 @@ final class AppDataCleanupService { final Future Function()? _clearOverride; final BackupService? _backup; final AndroidAutomationService _automation; + final String? _runtimeDirectoryOverride; bool _clearing = false; + /// Removes connection assets while keeping this device's settings and grants. + /// The caller owns confirmation; no download, startup action or reconnect runs. + Future clearConnectionResources() async { + if (_clearing) { + return false; + } + _clearing = true; + var blocked = false; + var deleting = false; + try { + await Future.wait([ + _automation.setStartBlocked(true).then((_) => blocked = true), + _pauseProducers(pauseBackup: true), + ]); + await _geodata.withFiles(() async { + await _coordinator.stopForMaintenance(); + deleting = true; + final original = await _coordinator.configuration; + final preview = await _geodata.previewRestore([]); + await _withClearedRuntime( + () => _geodata.restoreSources(preview, (writeMetadata) async { + final db = _coordinator.db; + await db.connectionConfigDao.commit( + configurationJson: ConnectionConfiguration( + connection: ConnectionSettings(), + policy: original.policy, + ).encode(), + writeAssets: () async { + await writeMetadata(); + await db.coreConfigDao.clear(); + await db.manualServerGroupDao.clear(); + await db.subscriptionDao.clear(); + await db.routingProfileDao.clear(); + }, + ); + }), + ); + _coordinator.clearTrafficView(); + AppEventBus.instance.clearPingFailures(); + }); + return true; + } catch (error, stack) { + ygLogger('clear connection resources error: $error\n$stack'); + throw AppFailure( + failureCategory(error), + deleting ? 'cleanup' : 'cleanupBeforeDelete', + cause: error, + ); + } finally { + await _finishDataChange(blocked: blocked, resumeBackup: true); + } + } + + /// Keep the stopped runtime files until the entire asset transaction commits. + /// As with the Geodata journal, post-commit journal removal is best effort. + Future _withClearedRuntime(Future Function() action) async { + final directory = Directory( + _runtimeDirectoryOverride ?? VpnConstants.runDir, + ); + final type = await FileSystemEntity.type( + directory.path, + followLinks: false, + ); + if (type == FileSystemEntityType.notFound) { + return action(); + } + if (type != FileSystemEntityType.directory) { + throw FileSystemException( + 'Runtime directory must be a directory', + directory.path, + ); + } + final journal = await directory.parent.createTemp( + '.onexray-runtime-clear-', + ); + final saved = Directory('${journal.path}/run'); + var moved = false; + var committed = false; + try { + await directory.rename(saved.path); + moved = true; + await directory.create(); + final result = await action(); + committed = true; + return result; + } finally { + if (!committed && moved) { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + await saved.rename(directory.path); + } + try { + await journal.delete(recursive: true); + } catch (error) { + ygLogger('remove runtime cleanup journal error: $error'); + } + } + } + Future clearFromSettings() async { if (_clearing) return false; _clearing = true; diff --git a/test/pages/settings/cleanup_test.dart b/test/pages/settings/cleanup_test.dart new file mode 100644 index 000000000..de43c28ba --- /dev/null +++ b/test/pages/settings/cleanup_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/constants/preferences.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/settings/controller.dart'; +import 'package:onexray/pages/settings/page.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +void main() { + for (final (locale, size) in const [ + (Locale('en'), Size(390, 844)), + (Locale('ru'), Size(1160, 900)), + (Locale('fa'), Size(390, 844)), + ]) { + testWidgets( + 'single-column settings keep cleanup scopes distinct and cancellation inert: $locale', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final previous = SharedPreferencesAsyncPlatform.instance; + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown(() => SharedPreferencesAsyncPlatform.instance = previous); + await PreferencesKey().saveConnectOnAppLaunch(true); + await SharedPreferencesAsync().setString( + 'cleanup-test-marker', + 'retained', + ); + final bus = AppEventBus(); + addTearDown(bus.close); + const iconChannel = BasicMessageChannel( + 'dev.flutter.pigeon.onexray.BridgeHostApi.getCurrentAppIcon', + BridgeHostApi.pigeonChannelCodec, + ); + const invokeChannel = BasicMessageChannel( + 'dev.flutter.pigeon.onexray.BridgeHostApi.invoke', + BridgeHostApi.pigeonChannelCodec, + ); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockDecodedMessageHandler( + iconChannel, + (_) async => ['IconBlue'], + ); + messenger.setMockDecodedMessageHandler( + invokeChannel, + (_) async => [ + '{"apiVersion":3,"success":true,"data":{"version":"v26.10.21"}}', + ], + ); + addTearDown(() { + messenger.setMockDecodedMessageHandler(iconChannel, null); + messenger.setMockDecodedMessageHandler(invokeChannel, null); + }); + addTearDown(() => tester.pumpWidget(const SizedBox.shrink())); + await tester.pumpWidget( + BlocProvider.value( + value: bus, + child: MaterialApp( + theme: AppTheme.light, + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + home: const SettingsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(SettingsPage)), + )!; + Finder section(String title) => find.byWidgetPredicate( + (widget) => widget is SettingSection && widget.title == title, + ); + Finder row(String title) => find.byWidgetPredicate( + (widget) => widget is SettingRow && widget.title == title, + ); + final sections = [ + l.prototypeAppearance, + l.prototypeLanguage, + l.prototypeStartup, + l.prototypeData, + l.prototypeAbout, + ].map((title) => tester.getRect(section(title))).toList(); + for (var index = 0; index < sections.length; index++) { + expect(sections[index].left, closeTo(sections.first.left, .01)); + expect(sections[index].width, closeTo(sections.first.width, .01)); + if (index > 0) { + expect( + sections[index].top, + greaterThan(sections[index - 1].bottom), + ); + } + } + final resources = row(l.clearConnectionResources); + final all = row(l.clearAllAppData); + expect(resources, findsOneWidget); + expect(all, findsOneWidget); + expect( + tester.getRect(all).top, + greaterThanOrEqualTo(tester.getRect(resources).bottom), + ); + final context = tester.element(resources); + final controller = context.read(); + for (final resourcesOnly in [true, false]) { + final entry = resourcesOnly ? resources : all; + await tester.ensureVisible(entry); + await tester.pumpAndSettle(); + await tester.tap(entry); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + final confirmation = tester.widget( + find.byType(AppConfirmationDialog), + ); + expect( + confirmation.title, + resourcesOnly + ? l.clearConnectionResources + : l.prototypeClearAllDataQuestion, + ); + expect( + confirmation.content, + resourcesOnly + ? l.clearConnectionResourcesWarning + : l.prototypeClearAllDataWarning, + ); + expect(confirmation.barrierDismissible, isFalse); + expect(controller.state.clearingData, isTrue); + expect( + controller.state.cleanupScope, + resourcesOnly ? 'resources' : 'all', + ); + expect(tester.widget(resources).onTap, isNull); + expect(tester.widget(all).onTap, isNull); + await controller.clearData(context, resourcesOnly: !resourcesOnly); + expect(find.byType(AppConfirmationDialog), findsOneWidget); + await tester.tap( + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ); + await tester.pumpAndSettle(); + expect(find.byType(AppConfirmationDialog), findsNothing); + expect(controller.state.clearingData, isFalse); + expect(controller.state.cleanupScope, isEmpty); + expect(tester.widget(resources).onTap, isNotNull); + expect(tester.widget(all).onTap, isNotNull); + expect(await PreferencesKey().readConnectOnAppLaunch(), isTrue); + expect( + await SharedPreferencesAsync().getString('cleanup-test-marker'), + 'retained', + ); + expect(find.text(l.prototypeDone), findsNothing); + } + expect(tester.takeException(), isNull); + }, + ); + } +} diff --git a/test/service/settings/data_cleanup_test.dart b/test/service/settings/data_cleanup_test.dart index f5cf7beb5..f247ad42c 100644 --- a/test/service/settings/data_cleanup_test.dart +++ b/test/service/settings/data_cleanup_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:onexray/core/errors/failure.dart'; @@ -6,12 +7,15 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/core/constants/preferences.dart'; +import 'package:onexray/core/backup/storage.dart'; import 'package:onexray/core/db/database/constants.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/service/advanced/xray/geodata/service.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/servers/outbound/state_db.dart'; import 'package:onexray/service/servers/subscription/model.dart'; @@ -31,6 +35,7 @@ void main() { late AppDatabase db; late ConnectionCoordinator coordinator; late GeoDataService geodata; + late Directory root; var stopFails = false; var stops = 0; @@ -58,16 +63,274 @@ void main() { ); await coordinator.initialize(observe: false, registerReferences: false); addTearDown(coordinator.dispose); - // This test exercises the real file queue, but never accesses the filesystem. + final parent = await Directory('../references/onexray-tests').absolute + .create(recursive: true); + root = await parent.createTemp('data-cleanup-'); + addTearDown(() => root.delete(recursive: true)); geodata = GeoDataService.forTesting( database: db, - directory: '../references/onexray-tests/unused-clear-data', + directory: '${root.path}/dat', download: (_, _) async => fail('No download expected'), count: (_, _, _) async => fail('No indexing expected'), copyBundled: (_) async => fail('No asset installation expected'), ); }); + AppDataCleanupService resources( + _AutomationHost native, { + String? runtimeDirectory, + SubscriptionService? subscriptions, + }) => AppDataCleanupService.forTesting( + coordinator: coordinator, + geodata: geodata, + subscriptions: + subscriptions ?? + SubscriptionService.forTesting( + database: db, + loadRows: (_) async => throw StateError('must not download'), + schedulePing: (_) {}, + ), + ping: PingService.forTesting( + database: db, + runBatch: (_, _) async => throw StateError('must not probe'), + ), + automation: AndroidAutomationService(api: native, supported: true), + runtimeDirectory: runtimeDirectory ?? '${root.path}/run', + clear: () async => fail('Narrow cleanup must not invoke full cleanup'), + ); + + test('connection cleanup preserves device preferences, grants, backup binding and platform policy', () async { + final native = _AutomationHost(); + final preferences = PreferencesKey(); + final api = {'enabled': false, 'port': 18587, 'token': 'same-api-token'}; + final external = await File('${root.path}/external-backup.json') + .writeAsString('same backup'); + final backup = { + 'target': BackupTarget(external.path, 'OneXray-backup.json').toJson(), + 'boundAt': 42, + }; + await preferences.saveLocalApi(api); + await preferences.saveBackup(backup); + await preferences.saveThemeCode('dark'); + await preferences.saveLanguageCode('fa'); + await preferences.saveAutomaticBackup(false); + await preferences.saveBackupInterval(168); + await AppEventBus.instance.setServerSort(ServerSortOrder.name); + final node = await db.coreConfigDao.insertRow(_node('Existing')); + await db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: 'source', + url: 'https://example.com/source', + timestamp: DateTime(2026), + ), + ); + await db.manualServerGroupDao.insertRow( + ManualServerGroupCompanion.insert(name: 'group'), + ); + await db.routingProfileDao.insertRow( + RoutingProfileCompanion.insert(name: 'route', data: 'e30='), + ); + await Directory('${root.path}/dat').create(); + for (final (id, name, type) in [ + (-1, 'geoip', 'ip'), + (-2, 'geosite', 'domain'), + ]) { + await db.geoDataDao.insertRow( + GeoDataCompanion.insert( + id: Value(id), + name: name, + type: type, + url: 'https://example.com/default', + timestamp: DateTime(2026), + categoryCount: 0, + ruleCount: 0, + ), + ); + for (final suffix in ['dat', 'json']) { + await File('${root.path}/dat/$name.$suffix') + .writeAsString('same default'); + } + } + await db.geoDataDao.insertRow( + GeoDataCompanion.insert( + name: 'custom', + type: 'domain', + url: 'https://example.com/custom', + timestamp: DateTime(2026), + categoryCount: 0, + ruleCount: 0, + installed: const Value(false), + ), + ); + final original = ConnectionConfiguration.fromJson({ + 'connection': ConnectionSettings( + expert: true, + rawId: node, + selection: ServerSelection.server(node), + rememberedOrdinarySelection: const ServerSelection.source(0), + smart: SmartRoutingSettings(finalExitId: node), + ).toJson(), + 'policy': { + 'ipv6Enabled': false, + 'xrayOutboundInterfaceName': 'kept-interface', + }, + }); + await db.connectionConfigDao.commit(configurationJson: original.encode()); + final run = await Directory('${root.path}/run').create(); + await File('${run.path}/start.json').writeAsString('old runtime'); + expect(await resources(native).clearConnectionResources(), isTrue); + expect(await db.select(db.coreConfig).get(), isEmpty); + expect(await db.subscriptionDao.allRows, isEmpty); + expect(await db.manualServerGroupDao.allRows, isEmpty); + expect(await db.routingProfileDao.allRows, isEmpty); + expect( + (await db.geoDataDao.allSources).map((row) => row.id), + unorderedEquals([-1, -2]), + ); + for (final name in ['geoip', 'geosite']) { + for (final suffix in ['dat', 'json']) { + expect( + await File('${root.path}/dat/$name.$suffix').readAsString(), + 'same default', + ); + } + } + final fresh = await coordinator.configuration; + expect(fresh.policy.toJson(), original.policy.toJson()); + expect(fresh.connection.expert, isFalse); + expect(fresh.connection.rawId, isNull); + expect(fresh.connection.selection.kind, SelectionKind.automatic); + expect(fresh.connection.trafficMode, TrafficMode.smart); + expect(fresh.connection.smart.finalExitId, isNull); + expect(fresh.connection.rememberedOrdinarySelection, isNull); + expect(await File('${run.path}/start.json').exists(), isFalse); + expect(await preferences.readLocalApi(), api); + expect(await preferences.readBackup(), backup); + expect(await external.readAsString(), 'same backup'); + expect(await preferences.readThemeCode(), 'dark'); + expect(await preferences.readLanguageCode(), 'fa'); + expect(await preferences.readAutomaticBackup(), isFalse); + expect(await preferences.readBackupInterval(), 168); + expect(AppEventBus.instance.state.serverSort, ServerSortOrder.name); + expect(native.clears, 0); + expect(native.blocked, isFalse); + expect(stops, 1); + }); + + test('connection cleanup blocks producers and conflicts without revoking automation authorization', () async { + final native = _AutomationHost()..blockBarrier = Completer(); + final lateSource = SubscriptionService.forTesting( + database: db, + loadRows: (_) async => throw StateError('must not download'), + schedulePing: (_) {}, + ); + final cleanup = resources(native, subscriptions: lateSource); + final cleaning = cleanup.clearConnectionResources(); + addTearDown(() async { + if (!native.blockBarrier!.isCompleted) native.blockBarrier!.complete(); + await cleaning; + }); + await Future.delayed(Duration.zero); + expect(native.blocked, isTrue); + expect(await cleanup.clearConnectionResources(), isFalse); + expect(await cleanup.clearFromSettings(), isFalse); + await expectLater(cleanup.runForRestore(() async {}), throwsStateError); + await expectLater( + lateSource.insertSubscription( + const SubscriptionInput(name: 'late', url: 'https://example.com/late'), + ), + throwsStateError, + ); + native.blockBarrier!.complete(); + expect(await cleaning, isTrue); + expect(native.clears, 0); + expect(native.blocked, isFalse); + }); + + test( + 'admission, stopping and runtime path failures keep connection assets', + () async { + final node = await db.coreConfigDao.insertRow(_node('Existing')); + final native = _AutomationHost()..failBlock = true; + final cleanup = resources(native); + await expectLater( + cleanup.clearConnectionResources(), + throwsA(isA()), + ); + expect(stops, 0); + expect(await db.coreConfigDao.searchRow(node), isNotNull); + native.failBlock = false; + stopFails = true; + await expectLater( + cleanup.clearConnectionResources(), + throwsA(isA()), + ); + expect(await db.coreConfigDao.searchRow(node), isNotNull); + stopFails = false; + final invalid = await File('${root.path}/not-directory') + .writeAsString('keep'); + await expectLater( + resources( + native, + runtimeDirectory: invalid.path, + ).clearConnectionResources(), + throwsA(isA()), + ); + expect(await invalid.readAsString(), 'keep'); + expect(await db.coreConfigDao.searchRow(node), isNotNull); + expect(native.clears, 0); + expect(native.blocked, isFalse); + }, + ); + + test('a failed connection transaction rolls back custom files, runtime and selection memory', () async { + final node = await db.coreConfigDao.insertRow(_node('Existing')); + final original = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(node), + rememberedOrdinarySelection: const ServerSelection.source(0), + ), + ); + await db.connectionConfigDao.commit(configurationJson: original.encode()); + await Directory('${root.path}/dat').create(); + for (final suffix in ['dat', 'json']) { + await File('${root.path}/dat/custom.$suffix') + .writeAsString('same $suffix'); + } + await db.geoDataDao.insertRow( + GeoDataCompanion.insert( + name: 'custom', + type: 'domain', + url: 'https://example.com/custom', + timestamp: DateTime(2026), + categoryCount: 1, + ruleCount: 1, + ), + ); + final run = await Directory('${root.path}/run').create(); + await File('${run.path}/start.json').writeAsString('same runtime'); + await db.customStatement( + "CREATE TRIGGER prevent_clear BEFORE DELETE ON core_config BEGIN SELECT RAISE(ABORT, 'blocked'); END;", + ); + final native = _AutomationHost(); + await expectLater( + resources(native).clearConnectionResources(), + throwsA(isA()), + ); + expect(await db.coreConfigDao.searchRow(node), isNotNull); + expect((await coordinator.configuration).encode(), original.encode()); + expect((await db.geoDataDao.allRows).single.name, 'custom'); + for (final suffix in ['dat', 'json']) { + expect( + await File('${root.path}/dat/custom.$suffix').readAsString(), + 'same $suffix', + ); + } + expect(await File('${run.path}/start.json').readAsString(), 'same runtime'); + expect(native.blocked, isFalse); + expect(native.clears, 0); + }); + test( 'clear cancels queued probes and later sources, then drains active writes', () async { @@ -310,10 +573,12 @@ class _AutomationHost extends AndroidAutomationHostApi { bool blocked = false; bool failBlock = false; int clears = 0; + Completer? blockBarrier; @override Future setStartBlocked(bool value) async { if (value && failBlock) throw StateError('Cannot block native starts'); blocked = value; + if (value) await blockBarrier?.future; } @override From df6cb27968dfc766bd8ddf85a6c38d01968dec9b Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:54:00 +0800 Subject: [PATCH 10/60] fix(routing): isolate damaged unused routes from connection and server catalogs Build auxiliary entry-count catalogs per row. Preserve database stream failures and strict rejection of a selected damaged custom route without silently changing traffic mode. Validation: two real SQLite regression tests passed; flutter analyze reports no issues. --- lib/pages/connect/controller.dart | 16 +- lib/pages/servers/controller.dart | 15 +- .../routing_catalog_isolation_test.dart | 183 ++++++++++++++++++ 3 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 test/pages/connect/routing_catalog_isolation_test.dart diff --git a/lib/pages/connect/controller.dart b/lib/pages/connect/controller.dart index c171749f8..8718d1275 100644 --- a/lib/pages/connect/controller.dart +++ b/lib/pages/connect/controller.dart @@ -232,14 +232,16 @@ class ConnectController extends PageCubit with ServerLabels { ); _subscriptions.add( db.routingProfileDao.allRowsStream.listen((rows) { - try { - customRoutes = rows - .map(CustomRoutingService.readConfiguration) - .toList(); - } catch (error) { - _readFailed(error); - return; + final readable = []; + for (final row in rows) { + try { + readable.add(CustomRoutingService.readConfiguration(row)); + } catch (_) { + // Row diagnostics belong to the routing page. An unrelated + // damaged route must not block ordinary connection choices. + } } + customRoutes = readable; }, onError: _readFailed), ); } diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index 17e70dba9..7202f5de4 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -224,13 +224,16 @@ class ServersController extends PageCubit ); _subscriptions.add( db.routingProfileDao.allRowsStream.listen((rows) { - try { - customRoutes = rows - .map(CustomRoutingService.readConfiguration) - .toList(); - } catch (error) { - _readFailed(error); + final readable = []; + for (final row in rows) { + try { + readable.add(CustomRoutingService.readConfiguration(row)); + } catch (_) { + // Keep the auxiliary entry-count catalog separate from row + // diagnostics and from genuine database stream failures. + } } + customRoutes = readable; }, onError: _readFailed), ); } diff --git a/test/pages/connect/routing_catalog_isolation_test.dart b/test/pages/connect/routing_catalog_isolation_test.dart new file mode 100644 index 000000000..e91022bdc --- /dev/null +++ b/test/pages/connect/routing_catalog_isolation_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/pages/connect/controller.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/routing/custom/editor.dart'; +import 'package:onexray/service/connect/routing/custom/state.dart'; +import 'package:onexray/service/connect/routing/custom/state_db.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/servers/outbound/state_db.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; + +const _waitTimeout = Duration(seconds: 5); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late ConnectionCoordinator coordinator; + late ConnectController connect; + late ServersController servers; + late AppEventBus bus; + late int goodId, brokenId, nodeId; + late RoutingProfileData originalGood; + + Future waitForCatalog(bool Function() predicate) async { + if (predicate()) return; + // Subscribe to both real DB-driven controllers before checking again, so + // either one's next emission can complete the shared readiness barrier. + final changes = StreamController(); + final connectChanges = connect.stream.listen((_) => changes.add(null)); + final serverChanges = servers.stream.listen((_) => changes.add(null)); + try { + if (!predicate()) { + await changes.stream + .firstWhere((_) => predicate()) + .timeout(_waitTimeout); + } + } finally { + await connectChanges.cancel(); + await serverChanges.cancel(); + await changes.close(); + } + } + + setUp(() async { + bus = AppEventBus(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + goodId = await db.routingProfileDao.insertRow( + RoutingProfileState(name: 'Readable route').insertCompanion, + ); + brokenId = await db.routingProfileDao.insertRow( + RoutingProfileCompanion.insert( + name: 'Damaged route', + data: 'invalid-base64!', + advanced: const Value(true), + ), + ); + originalGood = (await db.routingProfileDao.searchRow(goodId))!; + nodeId = await db.coreConfigDao.insertRow( + outboundCompanion({ + 'tag': 'Available node', + 'protocol': 'socks', + 'settings': { + 'servers': [ + {'address': '127.0.0.1', 'port': 18080}, + ], + }, + }), + ); + coordinator = ConnectionCoordinator(database: db); + connect = ConnectController(database: db, coordinator: coordinator); + servers = ServersController(database: db, coordinator: coordinator); + await connect.initialize(); + await servers.initialize(); + await waitForCatalog( + () => + connect.customRoutes.any((row) => row.id == goodId) && + servers.customRoutes.any((row) => row.id == goodId) && + connect.servers.any((row) => row.id == nodeId) && + servers.servers.any((row) => row.id == nodeId), + ); + }); + + tearDown(() async { + await connect.close(); + await servers.close(); + coordinator.dispose(); + await db.close(); + await bus.close(); + }); + + test( + 'bad unused routes do not block Smart/All or retain a newly damaged row', + () async { + const automatic = ServerSelection.automatic(); + for (final mode in [TrafficMode.smart, TrafficMode.allVpn]) { + await db.connectionConfigDao.commit( + configurationJson: ConnectionConfiguration( + connection: ConnectionSettings(trafficMode: mode), + ).encode(), + ); + await waitForCatalog( + () => + connect.configuration.connection.trafficMode == mode && + servers.configuration.connection.trafficMode == mode, + ); + expect(connect.ready && !connect.failed, isTrue); + expect(servers.ready && !servers.failed, isTrue); + expect(connect.customRoutes.map((row) => row.id), [goodId]); + expect(servers.customRoutes.map((row) => row.id), [goodId]); + expect(connect.candidateCount(automatic), 1); + expect(servers.canUseSelection(automatic, servers.servers), isTrue); + } + + expect( + await db.routingProfileDao.updateRow( + originalGood.copyWith(data: 'also-invalid-base64!'), + ), + isTrue, + ); + await waitForCatalog( + () => connect.customRoutes.isEmpty && servers.customRoutes.isEmpty, + ); + expect(connect.failed || servers.failed, isFalse); + expect(servers.canUseSelection(automatic, servers.servers), isTrue); + + expect(await db.routingProfileDao.updateRow(originalGood), isTrue); + await waitForCatalog( + () => + connect.customRoutes.any((row) => row.id == goodId) && + servers.customRoutes.any((row) => row.id == goodId), + ); + expect(connect.customRoutes.map((row) => row.id), [goodId]); + expect(servers.customRoutes.map((row) => row.id), [goodId]); + expect(connect.failed || servers.failed, isFalse); + }, + ); + + test( + 'a selected damaged Custom route stays unavailable without changing mode', + () async { + final selected = ConnectionConfiguration( + connection: ConnectionSettings( + trafficMode: TrafficMode.custom, + customId: brokenId, + ), + ); + await db.connectionConfigDao.commit(configurationJson: selected.encode()); + await waitForCatalog( + () => + connect.configuration.connection.customId == brokenId && + servers.configuration.connection.customId == brokenId, + ); + expect(connect.failed || servers.failed, isFalse); + expect(servers.entryCount(const ServerSelection.automatic()), 0); + expect( + servers.canUseSelection( + const ServerSelection.automatic(), + servers.servers, + ), + isFalse, + ); + await expectLater( + CustomRoutingEditorService( + database: db, + coordinator: coordinator, + ).load((await coordinator.configuration).connection.customId), + throwsFormatException, + ); + expect((await coordinator.configuration).encode(), selected.encode()); + expect(connect.configuration.connection.trafficMode, TrafficMode.custom); + expect(servers.configuration.connection.trafficMode, TrafficMode.custom); + expect(connect.customRoutes.map((row) => row.id), [goodId]); + expect(servers.customRoutes.map((row) => row.id), [goodId]); + }, + ); +} From 489c84f3d29068c1cd6c26be21ca814e94d12620 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 19:58:09 +0800 Subject: [PATCH 11/60] test(ui): align theme and tray regressions with the approved contracts Keep Material/Shad theme mapping, native sizing and typography checks. Allow failed probes in tray selections while preserving unreadable-resource and busy-state rejection. Validation: 19 theme and tray tests passed. --- test/pages/theme/theme_test.dart | 50 +++++++++++++------- test/service/shared/menu/tray/menu_test.dart | 16 +++++-- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index 7314a0d3f..6f561e232 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -30,7 +30,13 @@ void main() { expect(material.appBarTheme.foregroundColor, palette.foreground); expect(material.appBarTheme.toolbarHeight, isNull); expect(material.appBarTheme.centerTitle, isFalse); - expect(material.appBarTheme.titleTextStyle, isNull); + expect( + material.appBarTheme.titleTextStyle, + AppTypography.pageTitle.copyWith( + fontSize: 26, + color: palette.foreground, + ), + ); expect(material.appBarTheme.titleSpacing, AppSpacing.page); expect( material.appBarTheme.actionsPadding, @@ -90,11 +96,14 @@ void main() { expect(shad.background, palette.background); expect(shad.card, palette.card); expect(shad.popover, palette.popover); - expect(shad.border, const Color(0xFF2B3543)); - expect(shad.input, const Color(0xFF2B3543)); - expect(palette.primary, const Color(0xFF69A5FF)); - expect(palette.primarySolid, const Color(0xFF1F6AF9)); - expect(palette.foreground, const Color(0xFFF2F5F8)); + expect(shad.border, palette.border); + expect(shad.input, palette.input); + expect(material.colorScheme.outline, shad.border); + expect(palette.border, const Color(0xFF35363E)); + expect(palette.input, const Color(0xFF35363E)); + expect(palette.primary, const Color(0xFF91B3FF)); + expect(palette.primarySolid, const Color(0xFF3268EF)); + expect(palette.foreground, const Color(0xFFF2F3F5)); }); test( @@ -104,20 +113,21 @@ void main() { final shad = AppTheme.shad(Brightness.light); expect(material.textTheme.bodyMedium?.fontFamily, AppFontFamily.sans); - expect(material.textTheme.bodyMedium?.fontSize, 13); - expect(material.textTheme.bodySmall?.fontSize, 12); + expect(material.textTheme.bodyMedium?.fontSize, 14); + expect(material.textTheme.bodySmall?.fontSize, 13); expect(material.textTheme.labelSmall?.fontSize, 12); expect(shad.textTheme.family, AppFontFamily.sans); - expect(shad.textTheme.p.fontSize, 13); - expect(shad.textTheme.small.fontSize, 13); - expect(shad.textTheme.muted.fontSize, 12); - expect(AppTypography.pageTitle.fontSize, 31); - expect(AppTypography.panelTitle.fontSize, 19); - expect(AppTypography.supporting.fontSize, 12); - expect(AppTypography.navigationLabel.fontSize, 16); + expect(shad.textTheme.p.fontSize, 14); + expect(shad.textTheme.small.fontSize, 14); + expect(shad.textTheme.muted.fontSize, 13); + expect(AppTypography.pageTitle.fontSize, 26); + expect(AppTypography.pageTitle.fontWeight, FontWeight.w600); + expect(AppTypography.panelTitle.fontSize, 16); + expect(AppTypography.supporting.fontSize, 13); + expect(AppTypography.navigationLabel.fontSize, 14); expect(AppTypography.badge.fontSize, 12); expect(AppTypography.code.fontSize, 12); - expect(AppTypography.metric.fontSize, 20); + expect(AppTypography.metric.fontSize, 22); for (final (style, size, weight) in [ (AppTypography.connectStatusTitle, 17, FontWeight.w700), (AppTypography.connectButton, 16, FontWeight.w700), @@ -223,7 +233,13 @@ void main() { test('mobile theme uses native navigation sizes and shared gutters', () { final material = AppTheme.material(Brightness.light, mobile: true); final shad = AppTheme.shad(Brightness.light, mobile: true); - expect(material.appBarTheme.titleTextStyle, isNull); + expect( + material.appBarTheme.titleTextStyle, + AppTypography.pageTitle.copyWith( + fontSize: 24, + color: AppPalette.light.foreground, + ), + ); expect(material.appBarTheme.centerTitle, isFalse); expect(material.appBarTheme.toolbarHeight, isNull); expect( diff --git a/test/service/shared/menu/tray/menu_test.dart b/test/service/shared/menu/tray/menu_test.dart index 65b613529..6d1a2ca9c 100644 --- a/test/service/shared/menu/tray/menu_test.dart +++ b/test/service/shared/menu/tray/menu_test.dart @@ -346,8 +346,13 @@ void main() { final data = _menuData(12); data.catalog = data.catalog.copyWith( servers: [ - data.catalog.servers.first.copyWith(delay: PingDelayConstants.error), - ...data.catalog.servers.skip(1), + for (final row in data.catalog.servers) + if (row.id == 1) + row.copyWith(delay: PingDelayConstants.error) + else if (row.id == 2) + row.copyWith(data: const Value('invalid-base64!')) + else + row, ], ); for (final configuration in [ @@ -376,7 +381,12 @@ void main() { final items = data.selectionItems(l, busy: false); expect(_item(items, 'server:3').checked, isTrue); expect(_item(items, 'server:3').disabled, isFalse); - expect(_item(items, 'server:1').disabled, isTrue); + expect( + _item(items, 'server:1').disabled, + isFalse, + reason: 'A failed measurement does not block an active readable node.', + ); + expect(_item(items, 'server:2').disabled, isTrue); expect( _descendants(data.selectionItems(l, busy: true)) .every((item) => item.disabled), From 80bdb075c6d6ff3ec61baea6ed8a4db87216112a Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 20:04:51 +0800 Subject: [PATCH 12/60] fix(navigation): preserve pending editor stacks and serialize incoming imports Let go_router record a pushed page before switching to Settings, coalesce requests and recheck modal admission. Keep incoming links queued until a manual import/editor workflow closes, then deliver them once in FIFO order. Validation: all 40 main navigation tests passed, including pre-frame branch switching, draft preservation and late popup cancellation. --- lib/pages/main/adaptive_shell.dart | 39 ++++++++-- test/pages/main/app_link_delivery_test.dart | 62 +++++++++++++-- test/pages/main/settings_menu_test.dart | 83 +++++++++++++++++++-- 3 files changed, 165 insertions(+), 19 deletions(-) diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index e27735232..b57a17647 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -54,6 +54,7 @@ class _AdaptiveMainShellState extends State { GoRouter? _router; IncomingAppLink? _activeAppLink; bool _appLinkDrainScheduled = false; + bool _settingsNavigationScheduled = false; StatefulNavigationShell get navigationShell => widget.navigationShell; @@ -79,7 +80,7 @@ class _AdaptiveMainShellState extends State { } Future _drainAppLink() async { - if (_activeAppLink != null || !_canOpenSettings()) return; + if (_activeAppLink != null || !_canOpenAppLink()) return; final event = _shareService.claimNextAppLink(); if (event == null) return; _activeAppLink = event; @@ -145,6 +146,23 @@ class _AdaptiveMainShellState extends State { super.dispose(); } + bool _canOpenAppLink() { + if (!_canOpenSettings()) return false; + final location = GoRouter.of(context) + .routerDelegate + .currentConfiguration + .last + .matchedLocation; + // These workflows used to be routed dialogs. Their full-page presentation + // permits Settings branch changes, but an incoming import must still wait + // rather than push another form over the active manual draft. + return !{ + AppPageDestination.serversImport, + AppPageDestination.serverEditor, + AppPageDestination.subscriptionEdit, + }.any((page) => location.endsWith('/${page.segment}')); + } + bool _canOpenSettings() { if (!mounted || !_menusAttached) return false; final location = GoRouter.of(context) @@ -168,11 +186,20 @@ class _AdaptiveMainShellState extends State { } void _openSettings() { - if (!_canOpenSettings()) return; - navigationShell.goBranch( - AppPrimaryDestination.settings.index, - initialLocation: false, - ); + if (_settingsNavigationScheduled || !_canOpenSettings()) return; + _settingsNavigationScheduled = true; + // go_router saves a branch's latest match list when its Shell widget is + // updated. Switching before a pending push builds would save the old stack + // and discard that page. Let it commit, then recheck popup admission. + WidgetsBinding.instance.addPostFrameCallback((_) { + _settingsNavigationScheduled = false; + if (!_canOpenSettings()) return; + navigationShell.goBranch( + AppPrimaryDestination.settings.index, + initialLocation: false, + ); + }); + WidgetsBinding.instance.ensureVisualUpdate(); } void _retry() { diff --git a/test/pages/main/app_link_delivery_test.dart b/test/pages/main/app_link_delivery_test.dart index 832a2fc2f..e66692089 100644 --- a/test/pages/main/app_link_delivery_test.dart +++ b/test/pages/main/app_link_delivery_test.dart @@ -59,11 +59,13 @@ void main() { routes: [ GoRoute( path: AppPageDestination.serversImport.segment, - builder: (_, state) { - importCount++; - received.add(state.extra! as String); - return Scaffold(body: Text(state.extra! as String)); - }, + builder: (_, state) => _ImportDraftPage( + input: state.extra! as String, + onOpened: (input) { + importCount++; + received.add(input); + }, + ), ), ], ), @@ -110,15 +112,33 @@ void main() { // A manually opened import must finish before the cached link opens. unawaited(router.push('/connect/servers-import', extra: 'manual')); await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('import-draft')), + 'Unsaved manual import', + ); links.add(first); + links.add(second); + links.add(second); await tester.pumpAndSettle(); expect(received.last, 'manual'); + expect(importCount, 3); + expect(find.text('Unsaved manual import'), findsOneWidget); router.pop(); await tester.pumpAndSettle(); expect(received.last, first.toString()); expect(importCount, 4); router.pop(); await tester.pumpAndSettle(); + expect(received, [ + first.toString(), + second.toString(), + 'manual', + first.toString(), + second.toString(), + ]); + expect(importCount, 5); + router.pop(); + await tester.pumpAndSettle(); unawaited( showDialog( context: tester.element(find.text('home').first), @@ -128,13 +148,41 @@ void main() { await tester.pumpAndSettle(); links.add(second); await tester.pumpAndSettle(); - expect(importCount, 4); + expect(importCount, 5); router.pop(); await tester.pumpAndSettle(); expect(received.last, second.toString()); - expect(importCount, 5); + expect(importCount, 6); router.pop(); await tester.pumpAndSettle(); await tester.pumpWidget(const SizedBox.shrink()); }); } + +class _ImportDraftPage extends StatefulWidget { + const _ImportDraftPage({required this.input, required this.onOpened}); + final String input; + final ValueChanged onOpened; + + @override + State<_ImportDraftPage> createState() => _ImportDraftPageState(); +} + +class _ImportDraftPageState extends State<_ImportDraftPage> { + @override + void initState() { + super.initState(); + // Count actual workflow instances, not unrelated router widget rebuilds. + widget.onOpened(widget.input); + } + + @override + Widget build(BuildContext context) => Scaffold( + body: Column( + children: [ + Text(widget.input), + const TextField(key: ValueKey('import-draft')), + ], + ), + ); +} diff --git a/test/pages/main/settings_menu_test.dart b/test/pages/main/settings_menu_test.dart index 6dd0ab8e1..726c459fe 100644 --- a/test/pages/main/settings_menu_test.dart +++ b/test/pages/main/settings_menu_test.dart @@ -122,7 +122,7 @@ void main() { } } - testWidgets('a newly routed editor also blocks Settings before a frame', ( + testWidgets('a full-page editor keeps its stack and draft across Settings', ( tester, ) async { final harness = _MenuHarness(); @@ -132,8 +132,39 @@ void main() { unawaited(harness.router.push('/connect/server-editor')); await harness.menu.receive(); await tester.pumpAndSettle(); + expect(harness.location, '/settings'); + await tester.tap(find.byKey(const ValueKey('primary-navigation-connect'))); + await tester.pumpAndSettle(); + expect(harness.location, '/connect/server-editor'); + await tester.enterText( + find.byKey(const ValueKey('editor-draft')), + 'Unsaved editor draft', + ); + await harness.menu.receive(); + await tester.pumpAndSettle(); + expect(harness.location, '/settings'); + await tester.tap(find.byKey(const ValueKey('primary-navigation-connect'))); + await tester.pumpAndSettle(); expect(harness.location, '/connect/server-editor'); - expect(find.text('Editor modal'), findsOneWidget); + expect(find.text('Unsaved editor draft'), findsOneWidget); + harness.router.pop(); + await tester.pumpAndSettle(); + expect(harness.location, '/connect'); + expect(tester.takeException(), isNull); + }); + + testWidgets('a routed share popup blocks Settings before its first frame', ( + tester, + ) async { + final harness = _MenuHarness(); + addTearDown(harness.dispose); + await tester.pumpWidget(harness.app()); + await tester.pumpAndSettle(); + unawaited(harness.router.push('/connect/share')); + await harness.menu.receive(); + await tester.pumpAndSettle(); + expect(harness.location, '/connect/share'); + expect(find.text('Share modal'), findsOneWidget); harness.router.pop(); await tester.pumpAndSettle(); expect(harness.location, '/connect'); @@ -166,6 +197,35 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets( + 'a popup opened before the admitted Settings frame cancels navigation', + (tester) async { + final harness = _MenuHarness(); + addTearDown(harness.dispose); + await tester.pumpWidget(harness.app()); + await tester.pumpAndSettle(); + final context = tester.element(find.text('connect-content')); + await harness.menu.receive(); + final closed = showDialog( + context: context, + requestFocus: false, + builder: (_) => const AlertDialog(title: Text('Before Settings frame')), + ); + await tester.pumpAndSettle(); + expect(harness.location, '/connect'); + expect(find.text('Before Settings frame'), findsOneWidget); + Navigator.of(tester.element(find.byType(AlertDialog))).pop(); + await tester.pumpAndSettle(); + await closed; + // A rejected switch must not revive when the popup closes. + expect(harness.location, '/connect'); + await harness.menu.receive(); + await tester.pumpAndSettle(); + expect(harness.location, '/settings'); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('a popup in an inactive branch does not block Settings', ( tester, ) async { @@ -292,7 +352,7 @@ void main() { }, ); - testWidgets('a routed editor and nested confirmation are not replaced', ( + testWidgets('an editor confirmation blocks Settings until dismissed', ( tester, ) async { final harness = _MenuHarness(); @@ -301,8 +361,12 @@ void main() { await tester.pumpAndSettle(); unawaited(harness.router.push('/connect/server-editor')); await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const ValueKey('editor-draft')), + 'Draft behind confirmation', + ); final closed = showDialog( - context: tester.element(find.text('Editor modal')), + context: tester.element(find.text('editor-content')), requestFocus: false, useRootNavigator: false, builder: (_) => const AlertDialog(title: Text('Discard changes?')), @@ -319,8 +383,11 @@ void main() { await closed; await harness.menu.receive(); await tester.pumpAndSettle(); - expect(find.text('Editor modal'), findsOneWidget); + expect(harness.location, '/settings'); + await tester.tap(find.byKey(const ValueKey('primary-navigation-connect'))); + await tester.pumpAndSettle(); expect(harness.location, '/connect/server-editor'); + expect(find.text('Draft behind confirmation'), findsOneWidget); harness.router.pop(); await tester.pumpAndSettle(); expect(harness.location, '/connect'); @@ -428,10 +495,14 @@ class _MenuHarness { ), GoRoute( path: AppPageDestination.serverEditor.segment, + builder: (_, _) => const _DraftView(name: 'editor'), + ), + GoRoute( + path: AppPageDestination.share.segment, pageBuilder: (_, state) => AppDialogPage( key: state.pageKey, builder: (_) => - const AlertDialog(title: Text('Editor modal')), + const AlertDialog(title: Text('Share modal')), ), ), ], From 1baf49d8d5ab5bd1ee0d3b5f7146c5e0165b233b Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 20:34:01 +0800 Subject: [PATCH 13/60] fix(ui): avoid router ancestor loops in imperative preview page bars Read the active route match and verify its Page key before adding phone root Settings. Imperative preview routes have no GoRouterState association and must never enter its ancestor lookup. Validation: reproduced the original hang with a bounded 10-second real-router test; PageAppBar 5/5, main 42/42 and import 32/32 passed. Native iPhone replay displayed the same Raw preview, saved it and returned normally; flutter analyze passed. --- lib/pages/shared/widgets/page_app_bar.dart | 24 +++- test/pages/main/page_app_bar_test.dart | 159 +++++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index 93cd23305..bb8dc5b0e 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -50,17 +50,31 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { final effectiveActions = [...?actions]; final router = GoRouter.maybeOf(context); if (router != null && + route != null && + route.isFirst && + route.isCurrent && + TickerMode.valuesOf(context).enabled && MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint) { - final path = GoRouterState.of(context).uri.path; - if (AppPrimaryDestination.values.any( - (p) => p != AppPrimaryDestination.settings && p.rootPath == path, - )) { + final match = router.routerDelegate.currentConfiguration.lastOrNull; + final settings = route.settings; + // Imperative routes (including Page-backed routes not owned by GoRouter) + // have no GoRouterState association. Its ancestor lookup can loop on a + // nested Navigator, so read the active match without walking ancestors. + final path = match?.matchedLocation; + if (settings is Page && + match != null && + settings.key == match.pageKey && + AppPrimaryDestination.values.any( + (p) => p != AppPrimaryDestination.settings && p.rootPath == path, + )) { effectiveActions.add( IconButton( key: const ValueKey('mobile-settings-action'), tooltip: AppLocalizations.of(context)!.prototypeSettings, icon: const Icon(LucideIcons.settings2), - onPressed: () => context.pushScoped(AppPageDestination.settings), + onPressed: () => router.push( + '$path/${AppPageDestination.settings.segment}', + ), ), ); } diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index c36ae6b28..58f26147a 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -1,12 +1,163 @@ +import 'dart:async'; + import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; import 'package:macos_window_utils/window_manipulator.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/main/desktop_window.dart'; +import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/settings/language/locale.dart'; void main() { + testWidgets('imperative routes under GoRouter keep page bars responsive', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var useUnownedPage = false; + final router = GoRouter( + initialLocation: '/connect', + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => shell, + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/connect', + builder: (context, _) => Scaffold( + appBar: const PageAppBar(title: Text('Connection root')), + body: TextButton( + onPressed: () { + final route = useUnownedPage + ? const MaterialPage( + key: ValueKey('unowned-preview'), + child: Scaffold( + appBar: PageAppBar(title: Text('Preview')), + ), + ).createRoute(context) + : MaterialPageRoute( + builder: (_) => const Scaffold( + appBar: PageAppBar(title: Text('Preview')), + ), + ); + unawaited(Navigator.of(context).push(route)); + }, + child: const Text('Open preview'), + ), + ), + ), + ], + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(_routerApp(router)); + await tester.pumpAndSettle(); + expect( + MediaQuery.sizeOf(tester.element(find.byType(PageAppBar))).width, + 390, + ); + expect( + find.byKey(const ValueKey('mobile-settings-action')), + findsOneWidget, + ); + // Neither an ordinary MaterialPageRoute nor a Page-backed route created + // outside GoRouter owns a GoRouterState association. + for (final pageBacked in [false, true]) { + useUnownedPage = pageBacked; + await tester.tap(find.text('Open preview')); + await tester.pumpAndSettle(); + expect(find.widgetWithText(AppBar, 'Preview'), findsOneWidget); + expect( + find.byKey(const ValueKey('mobile-settings-action')), + findsNothing, + ); + expect(router.routerDelegate.currentConfiguration.uri.path, '/connect'); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('mobile-settings-action')), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('only five actual phone roots offer scoped Settings navigation', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final router = GoRouter( + initialLocation: '/connect', + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => shell, + branches: [ + for (final primary in AppPrimaryDestination.values) + StatefulShellBranch( + routes: [ + GoRoute( + path: primary.rootPath, + builder: (_, _) => + Scaffold(appBar: PageAppBar(title: Text(primary.name))), + routes: [ + for (final child in ['settings', 'detail']) + GoRoute( + path: child, + builder: (_, _) => + Scaffold(appBar: PageAppBar(title: Text(child))), + ), + ], + ), + ], + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(_routerApp(router)); + await tester.pumpAndSettle(); + final gear = find.byKey(const ValueKey('mobile-settings-action')); + for (final primary in AppPrimaryDestination.values) { + router.go(primary.rootPath); + await tester.pumpAndSettle(); + if (primary == AppPrimaryDestination.settings) { + expect(gear, findsNothing); + } else { + expect(gear, findsOneWidget); + await tester.tap(gear); + await tester.pumpAndSettle(); + expect( + router.routerDelegate.currentConfiguration.last.matchedLocation, + '${primary.rootPath}/settings', + ); + expect(gear, findsNothing); + router.pop(); + await tester.pumpAndSettle(); + expect(gear, findsOneWidget); + } + unawaited(router.push('${primary.rootPath}/detail')); + await tester.pumpAndSettle(); + expect(gear, findsNothing); + router.pop(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + }); + testWidgets('page bar preserves themed sizing and tabs', (tester) async { final theme = AppTheme.light; await tester.pumpWidget( @@ -194,3 +345,11 @@ void main() { expect(rectangles, isEmpty); }); } + +Widget _routerApp(GoRouter router) => MaterialApp.router( + routerConfig: router, + theme: AppTheme.light.copyWith(platform: TargetPlatform.iOS), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, +); From 951edad9bc769f8b21d767f8fb9440b7c2250d7b Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 21:15:24 +0800 Subject: [PATCH 14/60] fix(ui): expose enabled switch semantics and truthful import feedback Use one shared switch wrapper so native accessibility agrees with actual interaction. Keep import previews neutral until committed, report saved subscription resource types, clarify cleanup scopes and shorten the Chinese empty-state hint. Validation: shared switch semantics reproduced red then passed; 274 related page tests and 39 import/cleanup/switch tests passed. flutter analyze reports no issues. Native accessibility and final wording are rechecked in the final UI stage. --- lib/l10n/app_en.arb | 8 +- lib/l10n/app_fa.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_Hant.arb | 5 +- lib/pages/advanced/local_api/page.dart | 3 +- .../tunnel/android/automation/page.dart | 4 +- lib/pages/advanced/tunnel/apple/widgets.dart | 3 +- lib/pages/advanced/tunnel/widgets.dart | 4 +- lib/pages/advanced/tunnel/windows/page.dart | 4 +- lib/pages/advanced/xray/data_update/page.dart | 3 +- lib/pages/advanced/xray/dialing/page.dart | 7 +- lib/pages/advanced/xray/page.dart | 5 +- lib/pages/connect/routing/options_form.dart | 4 +- lib/pages/connect/routing/smart/page.dart | 4 +- lib/pages/connect/routing/widgets.dart | 6 +- lib/pages/servers/import/controller.dart | 17 +- lib/pages/servers/import/page.dart | 28 ++- lib/pages/servers/subscription/form_view.dart | 3 +- lib/pages/settings/backup/page.dart | 8 +- lib/pages/settings/page.dart | 3 +- lib/pages/shared/widgets/app_switch.dart | 37 ++++ lib/pages/shared/widgets/setting_row.dart | 3 +- test/pages/advanced/xray/dialing_test.dart | 19 +- test/pages/servers/import_test.dart | 46 ++++- .../pages/shared/widgets/app_switch_test.dart | 183 ++++++++++++++++++ 26 files changed, 364 insertions(+), 54 deletions(-) create mode 100644 lib/pages/shared/widgets/app_switch.dart create mode 100644 test/pages/shared/widgets/app_switch_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fd527151c..785f18199 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2783,9 +2783,9 @@ "@prototypeClearAllDataQuestion": { "description": "Approved prototype copy: Clear all app data?" }, - "prototypeClearAllDataWarning": "This removes saved servers, subscriptions, Age keys, routing configurations, Raw JSON, custom Geodata, and app preferences.", + "prototypeClearAllDataWarning": "This deletes all connection resources (servers and groups, subscriptions and Age keys, routing configurations, Raw JSON, and custom Geodata), local runtime records, and caches. It resets connection, startup, update, and backup settings, unlinks the backup location, and revokes API and Android automation access.\n\nThe theme, language, app icon, Dock visibility preference, privacy acceptance, first-run setup state, and system permissions are kept. External backup files will not be deleted.", "@prototypeClearAllDataWarning": { - "description": "Approved prototype copy: This removes saved servers, subscriptions, Age keys, routing configurations, Raw JSON, custom Geodata, and app preferences." + "description": "Full data cleanup confirmation: deleted resources and settings, revoked external access, preserved device preferences and permissions, and external backup files." }, "prototypeConfirmClearData": "Confirm clear data", "@prototypeConfirmClearData": { @@ -2939,6 +2939,10 @@ "@prototypeLocalServer": { "description": "Approved prototype copy: Local server" }, + "importPreviewPending": "Pending confirmation", + "@importPreviewPending": { + "description": "Heading for detected import items that have not been added; the user must confirm before they are saved." + }, "prototypeImportedLinks": "Imported links", "@prototypeImportedLinks": { "description": "Approved prototype copy: Imported links" diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index e5c469318..26859a351 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -843,7 +843,7 @@ "prototypeHomeScreenPreview": "پیش‌نمایش صفحهٔ اصلی دستگاه", "prototypeDockPreview": "پیش‌نمایش Dock", "prototypeClearAllDataQuestion": "همهٔ داده‌های برنامه پاک شوند؟", - "prototypeClearAllDataWarning": "سرورهای ذخیره‌شده، اشتراک‌ها، کلیدهای Age، پیکربندی‌های مسیریابی، JSON خام، Geodata سفارشی و ترجیحات برنامه حذف می‌شوند.", + "prototypeClearAllDataWarning": "همهٔ منابع اتصال (سرورها و گروه‌ها، اشتراک‌ها و کلیدهای Age، پیکربندی‌های مسیریابی، JSON خام و Geodata سفارشی)، سوابق اجرای محلی و حافظهٔ پنهان حذف می‌شوند. تنظیمات اتصال، راه‌اندازی، به‌روزرسانی و پشتیبان‌گیری بازنشانی می‌شوند؛ اتصال به محل پشتیبان‌گیری قطع و مجوز دسترسی API و خودکارسازی Android لغو می‌شود.\n\nپوسته، زبان، نماد برنامه، تنظیم نمایش در Dock، پذیرش حریم خصوصی، وضعیت راه‌اندازی اولیه و مجوزهای سیستم حفظ می‌شوند. فایل‌های پشتیبان خارجی حذف نمی‌شوند.", "prototypeConfirmClearData": "تأیید پاک‌سازی داده‌ها", "prototypeSystemApprovalRequired": "نیازمند تأیید سیستم", "prototypeOpenSystemSettings": "باز کردن تنظیمات سیستم", @@ -861,6 +861,7 @@ "prototypeShareSubscriptionHint": "اشتراک‌گذاری پیوند اشتراک آن", "prototypeExampleService": "سرویس نمونه", "prototypeLocalServer": "سرور محلی", + "importPreviewPending": "در انتظار تأیید", "prototypeImportedLinks": "پیوندهای واردشده", "prototypeItemCount": "{count} مورد", "prototypeLinkFormat": "قالب پیوند", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index ead35c26e..3cf30dc89 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -843,7 +843,7 @@ "prototypeHomeScreenPreview": "Предпросмотр главного экрана", "prototypeDockPreview": "Предпросмотр Dock", "prototypeClearAllDataQuestion": "Удалить все данные приложения?", - "prototypeClearAllDataWarning": "Будут удалены серверы, подписки, ключи Age, настройки маршрутизации, Raw JSON, пользовательские Geodata и настройки приложения.", + "prototypeClearAllDataWarning": "Будут удалены все ресурсы подключения (серверы и группы, подписки и ключи Age, настройки маршрутизации, Raw JSON и пользовательские Geodata), локальные данные запусков и кэш. Настройки подключения, запуска, обновлений и резервного копирования будут сброшены; привязка места хранения резервной копии будет удалена, а доступ через API и автоматизацию Android — отозван.\n\nТема, язык, значок приложения, настройка отображения в Dock, согласие с политикой конфиденциальности, состояние начальной настройки и системные разрешения сохранятся. Внешние файлы резервных копий не удаляются.", "prototypeConfirmClearData": "Удалить данные", "prototypeSystemApprovalRequired": "Требуется разрешение системы", "prototypeOpenSystemSettings": "Открыть настройки системы", @@ -861,6 +861,7 @@ "prototypeShareSubscriptionHint": "Передать ссылку подписки", "prototypeExampleService": "Демонстрационный сервис", "prototypeLocalServer": "Локальный сервер", + "importPreviewPending": "Ожидает подтверждения", "prototypeImportedLinks": "Импортируемые ссылки", "prototypeItemCount": "Элементов: {count}", "prototypeLinkFormat": "Формат ссылки", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3f7f7cfc2..15ee206d8 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -346,7 +346,7 @@ "prototypeAddLater": "稍后添加", "prototypeGoToHome": "进入首页", "prototypeStartUsingOneXray": "开始使用 OneXray", - "prototypeFirstConnectionHint": "添加订阅或导入服务器,即可开始第一次连接。", + "prototypeFirstConnectionHint": "添加订阅或导入服务器,开始第一次连接。", "prototypeUseCompleteRawJson": "使用完整 Raw JSON 配置", "prototypeDisconnected": "未连接", "prototypeConnecting": "正在连接…", @@ -843,7 +843,7 @@ "prototypeHomeScreenPreview": "主屏幕图标预览", "prototypeDockPreview": "Dock 图标预览", "prototypeClearAllDataQuestion": "清除全部 App 数据?", - "prototypeClearAllDataWarning": "这会移除已保存的服务器、订阅、Age 密钥、路由配置、Raw JSON、自定义 Geodata 和 App 偏好设置。", + "prototypeClearAllDataWarning": "删除所有连接资源(服务器与分组、订阅及 Age 密钥、路由配置、Raw JSON、自定义 Geodata)、本机运行记录与缓存,并重置连接、启动、更新及备份设置。备份位置会解绑,API 与 Android 自动化访问授权会撤销。\n\n主题、语言、App 图标、Dock 显示偏好、隐私确认、首次设置状态和系统权限会保留;外部备份文件不会删除。", "prototypeConfirmClearData": "确认清除数据", "prototypeSystemApprovalRequired": "需要系统批准", "prototypeOpenSystemSettings": "前往系统设置", @@ -861,6 +861,7 @@ "prototypeShareSubscriptionHint": "分享订阅链接", "prototypeExampleService": "示例服务", "prototypeLocalServer": "本地节点", + "importPreviewPending": "待确认内容", "prototypeImportedLinks": "导入的链接", "prototypeItemCount": "{count} 项", "prototypeLinkFormat": "链接格式", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 80f5a5281..15c86daf1 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -346,7 +346,7 @@ "prototypeAddLater": "稍後新增", "prototypeGoToHome": "進入首頁", "prototypeStartUsingOneXray": "開始使用 OneXray", - "prototypeFirstConnectionHint": "新增訂閱或匯入伺服器,即可開始第一次連線。", + "prototypeFirstConnectionHint": "新增訂閱或匯入伺服器,開始第一次連線。", "prototypeUseCompleteRawJson": "使用完整 Raw JSON 設定", "prototypeDisconnected": "未連線", "prototypeConnecting": "正在連線…", @@ -843,7 +843,7 @@ "prototypeHomeScreenPreview": "主畫面圖示預覽", "prototypeDockPreview": "Dock 圖示預覽", "prototypeClearAllDataQuestion": "清除全部 App 資料?", - "prototypeClearAllDataWarning": "這會移除已儲存的伺服器、訂閱、Age 金鑰、路由設定、Raw JSON、自訂 Geodata 和 App 偏好設定。", + "prototypeClearAllDataWarning": "刪除所有連線資源(伺服器與群組、訂閱及 Age 金鑰、路由設定、Raw JSON、自訂 Geodata)、本機執行記錄與快取,並重設連線、啟動、更新及備份設定。備份位置會解除綁定,API 與 Android 自動化存取授權會撤銷。\n\n主題、語言、App 圖示、Dock 顯示偏好、隱私確認、首次設定狀態和系統權限會保留;外部備份檔案不會刪除。", "prototypeConfirmClearData": "確認清除資料", "prototypeSystemApprovalRequired": "需要系統核准", "prototypeOpenSystemSettings": "前往系統設定", @@ -861,6 +861,7 @@ "prototypeShareSubscriptionHint": "分享訂閱連結", "prototypeExampleService": "範例服務", "prototypeLocalServer": "本機節點", + "importPreviewPending": "待確認內容", "prototypeImportedLinks": "匯入的連結", "prototypeItemCount": "{count} 項", "prototypeLinkFormat": "連結格式", diff --git a/lib/pages/advanced/local_api/page.dart b/lib/pages/advanced/local_api/page.dart index 07aded88d..af11cb05c 100644 --- a/lib/pages/advanced/local_api/page.dart +++ b/lib/pages/advanced/local_api/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; @@ -80,7 +81,7 @@ class LocalApiPage extends StatelessWidget { SettingRow( title: l.localApiEnable, titleMaxLines: 4, - trailing: ShadSwitch( + trailing: AppSwitch( key: const ValueKey('local-api-enabled'), value: state.enabled, enabled: !state.busy, diff --git a/lib/pages/advanced/tunnel/android/automation/page.dart b/lib/pages/advanced/tunnel/android/automation/page.dart index 3f25da3f4..84a1f50f9 100644 --- a/lib/pages/advanced/tunnel/android/automation/page.dart +++ b/lib/pages/advanced/tunnel/android/automation/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; @@ -11,7 +12,6 @@ import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/advanced/tunnel/android/automation/service.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class AndroidAutomationPage extends StatelessWidget { const AndroidAutomationPage({super.key, this.createController}); @@ -59,7 +59,7 @@ class AndroidAutomationPage extends StatelessWidget { titleMaxLines: 5, trailing: ButtonProgress( busy: state.writing == 'enable', - child: ShadSwitch( + child: AppSwitch( key: const ValueKey('automation-enable'), value: state.saved!.enabled, enabled: state.writing == null, diff --git a/lib/pages/advanced/tunnel/apple/widgets.dart b/lib/pages/advanced/tunnel/apple/widgets.dart index e87390d24..96b65efa4 100644 --- a/lib/pages/advanced/tunnel/apple/widgets.dart +++ b/lib/pages/advanced/tunnel/apple/widgets.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; @@ -65,7 +66,7 @@ class AppleSettingToggle extends StatelessWidget { ), ), SizedBox(width: mobile ? 12 : 16), - ShadSwitch( + AppSwitch( value: value, enabled: onChanged != null, onChanged: onChanged, diff --git a/lib/pages/advanced/tunnel/widgets.dart b/lib/pages/advanced/tunnel/widgets.dart index b961a7de2..7c5e6ca33 100644 --- a/lib/pages/advanced/tunnel/widgets.dart +++ b/lib/pages/advanced/tunnel/widgets.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; @@ -10,7 +11,6 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class PolicyDetailScaffold extends StatelessWidget { final String title; @@ -172,7 +172,7 @@ class PolicyToggle extends StatelessWidget { horizontal: mobile ? 13 : 14, vertical: 10, ), - trailing: ShadSwitch( + trailing: AppSwitch( value: (section == null ? controller.value diff --git a/lib/pages/advanced/tunnel/windows/page.dart b/lib/pages/advanced/tunnel/windows/page.dart index 511f53c9e..4734d15ed 100644 --- a/lib/pages/advanced/tunnel/windows/page.dart +++ b/lib/pages/advanced/tunnel/windows/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -8,7 +9,6 @@ import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/advanced/policy_editor.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class WindowsVpnPage extends StatelessWidget { final PolicyEditorDraft draft; @@ -180,7 +180,7 @@ class WindowsVpnView extends StatelessWidget { child: Text(title, style: AppTypography.platformChoiceTitle), ), const SizedBox(width: 16), - ShadSwitch( + AppSwitch( value: controller.group('windows')[field] as bool, onChanged: controller.blocked ? null diff --git a/lib/pages/advanced/xray/data_update/page.dart b/lib/pages/advanced/xray/data_update/page.dart index 8fcf5b552..ece91f6e4 100644 --- a/lib/pages/advanced/xray/data_update/page.dart +++ b/lib/pages/advanced/xray/data_update/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; @@ -173,7 +174,7 @@ class AutoUpdatePage extends StatelessWidget { ), enabled: onChanged != null, onTap: onChanged == null ? null : () => onChanged(!value), - trailing: ShadSwitch( + trailing: AppSwitch( value: value, enabled: onChanged != null, onChanged: onChanged, diff --git a/lib/pages/advanced/xray/dialing/page.dart b/lib/pages/advanced/xray/dialing/page.dart index 1eed5b8cd..0e69d6926 100644 --- a/lib/pages/advanced/xray/dialing/page.dart +++ b/lib/pages/advanced/xray/dialing/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -7,7 +8,6 @@ import 'package:onexray/pages/advanced/xray/dialing/controller.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/dialing.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class ServerDialingPage extends StatelessWidget { const ServerDialingPage({super.key, this.createController}); @@ -97,10 +97,7 @@ class ServerDialingPage extends StatelessWidget { controller.happyEyeballsAvailable, trailing: Semantics( label: 'Happy Eyeballs', - enabled: - !controller.blocked && - controller.happyEyeballsAvailable, - child: ShadSwitch( + child: AppSwitch( key: const ValueKey( 'server-dialing-happy-eyeballs', ), diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index c57ca4c7f..63d7f8d07 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:material_ui/material_ui.dart'; @@ -314,7 +315,7 @@ class XrayRuntimePage extends StatelessWidget { if (state.lanProxySaving) const ButtonProgressIndicator() else - ShadSwitch( + AppSwitch( key: const ValueKey('lan-proxy-switch'), value: controller.lanProxyEnabled, enabled: @@ -516,7 +517,7 @@ class XrayRuntimePage extends StatelessWidget { ? AppTypography.settingsFieldTitle : AppTypography.settingsRow, minHeight: mobile ? 52 : 56, - trailing: ShadSwitch( + trailing: AppSwitch( value: value, enabled: !controller.busy, onChanged: controller.busy diff --git a/lib/pages/connect/routing/options_form.dart b/lib/pages/connect/routing/options_form.dart index 3cd89d512..4391de218 100644 --- a/lib/pages/connect/routing/options_form.dart +++ b/lib/pages/connect/routing/options_form.dart @@ -7,7 +7,7 @@ import 'package:onexray/pages/shared/widgets/menu_picker.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/service/connect/routing/options.dart'; -import 'package:shadcn_ui/shadcn_ui.dart' show ShadSwitch; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; /// Ordinary routing forms edit business values, never generated Core JSON. class RoutingDnsSettingsForm extends StatelessWidget { @@ -380,7 +380,7 @@ class _OptionSwitch extends StatelessWidget { divider: divider, trailing: Semantics( label: title, - child: ShadSwitch(value: value, enabled: enabled, onChanged: onChanged), + child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), ); } diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index e19774ddb..3c59ae470 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -1,3 +1,5 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; + import 'dart:async'; import 'package:material_ui/material_ui.dart'; @@ -299,7 +301,7 @@ class SmartRoutingEditorContent extends StatelessWidget { below: below, trailing: Semantics( label: title, - child: ShadSwitch( + child: AppSwitch( value: value, enabled: state.original != null && !state.busy, onChanged: (value) => controller.update(key, value), diff --git a/lib/pages/connect/routing/widgets.dart b/lib/pages/connect/routing/widgets.dart index f9f6c4e21..ad453dd65 100644 --- a/lib/pages/connect/routing/widgets.dart +++ b/lib/pages/connect/routing/widgets.dart @@ -5,7 +5,7 @@ import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:shadcn_ui/shadcn_ui.dart' show ShadSwitch; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; class RoutingFakeDnsRow extends StatelessWidget { final bool value; @@ -32,7 +32,7 @@ class RoutingFakeDnsRow extends StatelessWidget { divider: divider, trailing: Semantics( label: l.routingFakeDns, - child: ShadSwitch(value: value, enabled: enabled, onChanged: onChanged), + child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), ); } @@ -63,7 +63,7 @@ class RoutingFragmentRow extends StatelessWidget { divider: divider, trailing: Semantics( label: l.routingFragment, - child: ShadSwitch(value: value, enabled: enabled, onChanged: onChanged), + child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), ); } diff --git a/lib/pages/servers/import/controller.dart b/lib/pages/servers/import/controller.dart index cf928f472..e3b7d6604 100644 --- a/lib/pages/servers/import/controller.dart +++ b/lib/pages/servers/import/controller.dart @@ -977,7 +977,22 @@ class ServerImportController extends PageCubit { } return; } - ContextAlert.showToast(context, l10n.prototypeUsableNodes(result.count)); + var message = l10n.prototypeSubscriptionSaved; + try { + // Automatic detection and conversion can change the requested mode. + // Describe the committed resource type rather than the selected panel. + final saved = await _loadSubscription(result.subId); + if (saved?.mode == SubscriptionMode.providerManaged.name) { + message = l10n.providerConfigurationCount(result.count); + } else if (saved?.mode == SubscriptionMode.appManaged.name) { + message = l10n.prototypeUsableNodes(result.count); + } + } catch (_) { + // The import already committed. Missing display metadata must not turn + // a successful write into an "add failed" result. + } + if (!isPageActive || !context.mounted) return; + ContextAlert.showToast(context, message); Navigator.of(context).pop( ServerImportResult(count: result.count, subscriptionId: result.subId), ); diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 96415b7dc..54c22cbf1 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -501,16 +501,25 @@ class ServerImportPreviewPage extends StatelessWidget { margin: const EdgeInsets.all(20), padding: const EdgeInsets.all(17), decoration: BoxDecoration( - color: palette.runningSurface, + color: committed == null + ? palette.card + : palette.runningSurface, + border: committed == null + ? Border.all(color: palette.border) + : null, borderRadius: BorderRadius.circular(AppRadii.card), ), child: Row( spacing: 13, children: [ Icon( - LucideIcons.circleCheck, + committed == null + ? LucideIcons.list + : LucideIcons.circleCheck, size: 26, - color: palette.running, + color: committed == null + ? palette.mutedStrong + : palette.running, ), Expanded( child: Column( @@ -518,7 +527,9 @@ class ServerImportPreviewPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - l10n.prototypeImportedLinks, + committed == null + ? l10n.importPreviewPending + : l10n.prototypeImportedLinks, style: AppTypography.importSummary, ), Text( @@ -608,6 +619,7 @@ class ServerImportPreviewPage extends StatelessWidget { child: _stat( context, l10n.prototypeUsableNodes(preview.count), + committed: committed != null, ), ), ], @@ -647,17 +659,19 @@ class ServerImportPreviewPage extends StatelessWidget { ), ); - Widget _stat(BuildContext context, String text) { + Widget _stat(BuildContext context, String text, {required bool committed}) { final palette = ColorManager.palette(context); return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: palette.runningSurface, + color: committed ? palette.runningSurface : palette.surfaceHover, borderRadius: BorderRadius.circular(AppRadii.control), ), child: Text( text, - style: AppTypography.importStat.copyWith(color: palette.running), + style: AppTypography.importStat.copyWith( + color: committed ? palette.running : palette.mutedStrong, + ), ), ); } diff --git a/lib/pages/servers/subscription/form_view.dart b/lib/pages/servers/subscription/form_view.dart index 43aaca560..000a1634e 100644 --- a/lib/pages/servers/subscription/form_view.dart +++ b/lib/pages/servers/subscription/form_view.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:onexray/core/pigeon/model.dart'; @@ -292,7 +293,7 @@ class SubscriptionFormView extends StatelessWidget { onTap: onHwidChanged == null ? null : () => onHwidChanged!(!hwidEnabled), - trailing: ShadSwitch( + trailing: AppSwitch( value: hwidEnabled, enabled: onHwidChanged != null, onChanged: onHwidChanged, diff --git a/lib/pages/settings/backup/page.dart b/lib/pages/settings/backup/page.dart index 720aadd06..2adb450c1 100644 --- a/lib/pages/settings/backup/page.dart +++ b/lib/pages/settings/backup/page.dart @@ -17,7 +17,7 @@ import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/settings/backup/service.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; -import 'package:shadcn_ui/shadcn_ui.dart' show ShadSwitch; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/service/shared/failure.dart'; class BackupPage extends StatelessWidget { @@ -273,11 +273,7 @@ class BackupPage extends StatelessWidget { ), Semantics( label: l.backupAutomatic, - enabled: - !backup.changingAutomatic && - state.action != - BackupPageAction.restoring, - child: ShadSwitch( + child: AppSwitch( value: backup.automatic, enabled: !backup.changingAutomatic && diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index 7dfb8db3a..4c7ecfd95 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -1,3 +1,4 @@ +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/core/tools/platform.dart'; @@ -509,7 +510,7 @@ class _StartupSettingRow extends StatelessWidget { : AppTypography.settingsRow, enabled: onChanged != null, onTap: onChanged == null ? null : () => onChanged!(!value), - trailing: ShadSwitch( + trailing: AppSwitch( value: value, width: mobile ? 40 : 46, height: mobile ? 23 : 27, diff --git a/lib/pages/shared/widgets/app_switch.dart b/lib/pages/shared/widgets/app_switch.dart new file mode 100644 index 000000000..e889c4c39 --- /dev/null +++ b/lib/pages/shared/widgets/app_switch.dart @@ -0,0 +1,37 @@ +import 'package:flutter/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Keeps the native accessibility state aligned with switch interaction. +class AppSwitch extends StatelessWidget { + const AppSwitch({ + super.key, + required this.value, + this.enabled = true, + this.onChanged, + this.width, + this.height, + }); + + final bool value; + final bool enabled; + final ValueChanged? onChanged; + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + final effectiveEnabled = enabled && onChanged != null; + // ShadSwitch supplies toggled/tap semantics but omits enabled. iOS requires + // an explicit enabled state when mapping a Flutter switch to UISwitch. + return Semantics( + enabled: effectiveEnabled, + child: ShadSwitch( + value: value, + enabled: effectiveEnabled, + onChanged: effectiveEnabled ? onChanged : null, + width: width, + height: height, + ), + ); + } +} diff --git a/lib/pages/shared/widgets/setting_row.dart b/lib/pages/shared/widgets/setting_row.dart index 2466eaf19..a9b0a215e 100644 --- a/lib/pages/shared/widgets/setting_row.dart +++ b/lib/pages/shared/widgets/setting_row.dart @@ -3,6 +3,7 @@ import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/menu_picker.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/pages/shared/widgets/section.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -289,7 +290,7 @@ class SwitchSettingRow extends StatelessWidget { leading: leading, enabled: onChanged != null, onTap: onChanged == null ? null : () => onChanged!(!value), - trailing: ShadSwitch( + trailing: AppSwitch( value: value, enabled: onChanged != null, onChanged: onChanged, diff --git a/test/pages/advanced/xray/dialing_test.dart b/test/pages/advanced/xray/dialing_test.dart index f41e152ac..9cb83998b 100644 --- a/test/pages/advanced/xray/dialing_test.dart +++ b/test/pages/advanced/xray/dialing_test.dart @@ -143,7 +143,11 @@ void main() { } await tester.tap(find.text('IPv4 first').last); await tester.pumpAndSettle(); - final inactive = tester.widget(toggle); + final control = find.descendant( + of: toggle, + matching: find.byType(ShadSwitch), + ); + final inactive = tester.widget(control); expect(inactive.enabled, isFalse); expect(inactive.value, isTrue); expect(controller.dialing.domainStrategy, ServerDomainStrategy.ipv4First); @@ -151,8 +155,8 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('System default').last); await tester.pumpAndSettle(); - expect(tester.widget(toggle).enabled, isTrue); - expect(tester.widget(toggle).value, isTrue); + expect(tester.widget(control).enabled, isTrue); + expect(tester.widget(control).value, isTrue); expect(service.saves, 0); expect(tester.takeException(), isNull); await tester.runAsync(controller.close); @@ -286,7 +290,14 @@ void main() { expect(opened, isTrue); expect(find.byType(ServerDialingPage), findsOneWidget); final toggle = find.byKey(const ValueKey('server-dialing-happy-eyeballs')); - expect(tester.widget(toggle).enabled, isTrue); + expect( + tester + .widget( + find.descendant(of: toggle, matching: find.byType(ShadSwitch)), + ) + .enabled, + isTrue, + ); await tester.tap(toggle); await tester.pumpAndSettle(); await tester.tap(find.text('Save')); diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index c9e079c3e..a54909545 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -853,6 +853,9 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ServerImportPreviewPage), findsOneWidget); expect(find.text('1 subscriptions imported.'), findsOneWidget); + expect(find.text('Pending confirmation'), findsOneWidget); + expect(find.text('Imported links'), findsNothing); + expect(find.byIcon(LucideIcons.circleCheck), findsNothing); expect(imported, 1); expect(writes, 0); controller.closePage( @@ -1258,8 +1261,14 @@ void main() { ); await tester.tap(find.text('Open')); await tester.pumpAndSettle(); + expect(find.text('Pending confirmation'), findsOneWidget); + expect(find.text('Imported links'), findsNothing); + expect(find.byIcon(LucideIcons.circleCheck), findsNothing); await _tapVisible(tester, find.text('Confirm add')); await tester.pumpAndSettle(); + expect(find.text('Pending confirmation'), findsNothing); + expect(find.text('Imported links'), findsOneWidget); + expect(find.byIcon(LucideIcons.circleCheck), findsOneWidget); expect(find.text('Servers added'), findsOneWidget); expect(find.text('Data source'), findsOneWidget); expect(find.text('Done'), findsOneWidget); @@ -1288,12 +1297,25 @@ void main() { ); } testWidgets( - 'new subscription submits automatic or explicitly selected mode', + 'new subscription submits mode and reports the saved resource type', (tester) async { - for (final mode in [null, SubscriptionMode.providerManaged]) { + for (final (mode, savedMode) in const [ + (null, SubscriptionMode.appManaged), + (null, SubscriptionMode.providerManaged), + (SubscriptionMode.providerManaged, SubscriptionMode.providerManaged), + (SubscriptionMode.appManaged, SubscriptionMode.appManaged), + ]) { SubscriptionInput? submitted; final controller = ServerImportController( - loadSubscription: (_) async => null, + loadSubscription: (_) async => SubscriptionData( + id: 8, + name: 'Provider', + url: 'https://example.test/subscription', + timestamp: DateTime(2026), + panel: SubscriptionPanel.remnawave.name, + mode: savedMode.name, + hwidEnabled: false, + ), insertSubscription: (input, {confirmOrdinary}) async { submitted = input; return const SubscriptionInsertResult( @@ -1335,8 +1357,26 @@ void main() { expect(submitted!.mode, mode); expect(submitted!.userAgent, 'Example/test'); expect(find.byType(ServerImportFormPage), findsNothing); + expect( + find.text( + savedMode == SubscriptionMode.providerManaged + ? l.providerConfigurationCount(3) + : l.prototypeUsableNodes(3), + ), + findsOneWidget, + ); + expect( + find.text( + savedMode == SubscriptionMode.providerManaged + ? l.prototypeUsableNodes(3) + : l.providerConfigurationCount(3), + ), + findsNothing, + ); expect(tester.takeException(), isNull); await controller.close(); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); } }, ); diff --git a/test/pages/shared/widgets/app_switch_test.dart b/test/pages/shared/widgets/app_switch_test.dart new file mode 100644 index 000000000..58d2f6749 --- /dev/null +++ b/test/pages/shared/widgets/app_switch_test.dart @@ -0,0 +1,183 @@ +import 'dart:ui' show SemanticsAction, Tristate; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('setting switch exposes its enabled state and activates once', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var value = false; + final changes = []; + await tester.pumpWidget( + MaterialApp( + home: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) => SwitchSettingRow( + title: 'Enable feature', + value: value, + onChanged: (next) => setState(() { + value = next; + changes.add(next); + }), + ), + ), + ), + ), + ), + ); + + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != Tristate.none, + ); + expect(toggles, findsOneWidget); + final node = toggles.evaluate().single; + expect( + node, + isSemantics( + hasEnabledState: true, + isEnabled: true, + hasToggledState: true, + isToggled: false, + hasTapAction: true, + ), + ); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(changes, [true]); + expect( + toggles.evaluate().single, + isSemantics(isEnabled: true, isToggled: true), + ); + + await tester.tap(find.byType(ShadSwitch)); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + await tester.tap(find.text('Enable feature')); + await tester.pumpAndSettle(); + expect(changes, [true, false, true]); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); + + for (final (enabled, hasCallback) in [(false, true), (true, false)]) { + testWidgets( + 'switch is disabled for enabled=$enabled callback=$hasCallback', + (tester) async { + final semantics = tester.ensureSemantics(); + var changes = 0; + await tester.pumpWidget( + MaterialApp( + home: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: Scaffold( + body: Center( + child: Semantics( + label: 'Disabled feature', + child: AppSwitch( + value: true, + enabled: enabled, + onChanged: hasCallback ? (_) => changes++ : null, + ), + ), + ), + ), + ), + ), + ); + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != + Tristate.none, + ); + expect(toggles, findsOneWidget); + final node = toggles.evaluate().single; + expect( + node, + isSemantics( + label: 'Disabled feature', + hasEnabledState: true, + isEnabled: false, + hasToggledState: true, + isToggled: true, + hasTapAction: false, + ), + ); + expect( + tester.widget(find.byType(ShadSwitch)).enabled, + false, + ); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.tap(find.byType(AppSwitch), warnIfMissed: false); + final focus = tester.widget( + find.descendant( + of: find.byType(AppSwitch), + matching: find.byWidgetPredicate( + (widget) => widget is Focus && widget.focusNode != null, + ), + ), + ); + focus.focusNode!.requestFocus(); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(changes, 0); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + } + + testWidgets('existing labelled semantics keep a single enabled switch', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + var changes = 0; + await tester.pumpWidget( + MaterialApp( + home: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: Scaffold( + body: Center( + child: Semantics( + label: 'Automatic backup', + child: AppSwitch(value: true, onChanged: (_) => changes++), + ), + ), + ), + ), + ), + ); + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != Tristate.none, + ); + expect(toggles, findsOneWidget); + expect( + toggles.evaluate().single, + isSemantics( + label: 'Automatic backup', + hasEnabledState: true, + isEnabled: true, + hasToggledState: true, + isToggled: true, + hasTapAction: true, + ), + ); + tester.semantics.tap(toggles); + await tester.pumpAndSettle(); + expect(changes, 1); + expect(tester.takeException(), isNull); + semantics.dispose(); + }); +} From f76dff0c2cb603629433a3f9818ae6566cb2c50d Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 22:03:44 +0800 Subject: [PATCH 15/60] test(ui): record native acceptance and final refactor checks Validate the production UI on isolated iPhone 16e and iPad Pro 13 simulators and the ordinary macOS audit instance. Verify single-column pages, JSON dirty/error handling, backup restore, plan switching, unknown-latency startup and real simulator Core traffic. Keep macOS VPN off and preserve the existing SE instance. Final validation: flutter test (2068 passed, 5 existing conditional skips); flutter analyze (no issues); layer dependency check; git diff --check; signed iOS Simulator build and production-entry macOS debug build. Native evidence and explicit device/system limitations are recorded in ../references/onexray-redesign-20261103/research/native-ui-acceptance.md. From 9762d7e83672829114852a076593be8108833bb1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 22:56:43 +0800 Subject: [PATCH 16/60] style(theme): align shared controls and palette with the prototype Match the approved prototype's type rhythm, semantic colors, CTA shapes, inputs, switches and tabs through the shared Material and Shad themes. Preserve the original AppFontFamily and shadcn bundled Geist/GeistMono without adding NotoSansSC or changing individual page structures. Validate with 2068 passing tests and 5 existing conditional skips, clean analysis and layer checks, signed iOS Simulator and macOS debug builds, and final native theme screenshots on phone, tablet and an isolated macOS instance. Keep macOS VPN off and restore the production macOS entry after the audit. --- docs/app.md | 4 +- lib/pages/theme/color.dart | 55 +++--- lib/pages/theme/font.dart | 38 ++-- lib/pages/theme/layout.dart | 19 +- lib/pages/theme/theme.dart | 167 +++++++++++++----- .../advanced/tunnel/apple/page_test.dart | 1 + .../advanced/xray/runtime_viewer_test.dart | 2 +- test/pages/connect/target_picker_test.dart | 6 +- .../shared/widgets/page_action_bar_test.dart | 12 +- test/pages/theme/theme_test.dart | 41 ++--- 10 files changed, 214 insertions(+), 131 deletions(-) diff --git a/docs/app.md b/docs/app.md index eea7eb70c..4e8e71cf6 100644 --- a/docs/app.md +++ b/docs/app.md @@ -27,8 +27,8 @@ 颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。 App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。 -字号为不小于 12 的整数、字重为整百值,不额外设置行高、字距或 `fontVariations`。 -代码字体使用已打包的 GeistMono;Windows 中文回退到 Microsoft YaHei UI / Microsoft YaHei。 +字号为不小于 12 的整数、字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 +界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 主题、语言默认跟随系统,已保存的选择优先;未支持的语言回退英语。 简体中文、繁体中文、英语、俄语、波斯语复用已批准文案。Linux ARM64 对 CJK 请求实际回退英语, diff --git a/lib/pages/theme/color.dart b/lib/pages/theme/color.dart index e0482a1d2..0e46af6dd 100644 --- a/lib/pages/theme/color.dart +++ b/lib/pages/theme/color.dart @@ -36,6 +36,7 @@ class AppPalette { required this.input, required this.ring, required this.selection, + required this.switchUnchecked, required this.scannerBackground, required this.overlay, required this.header, @@ -66,7 +67,7 @@ class AppPalette { required this.chart5, }); - // Semantic mapping of references/onexray-app-prototype/src/theme/tokens.json. + // Semantic mapping of prototype-shadcn/lib/demo_ui.dart. // Interactive colors and solid button fills differ in the dark theme. static const light = AppPalette( background: Color(0xFFF2F4F8), @@ -81,23 +82,24 @@ class AppPalette { primarySolid: Color(0xFF3268EF), primarySolidHover: Color(0xFF2455D2), primaryForeground: Color(0xFFFFFFFF), - surfaceHover: Color(0xFFF4F7FA), - secondary: Color(0xFFFFFFFF), - secondaryForeground: Color(0xFF202735), - muted: Color(0xFFF2F4F8), - mutedForeground: Color(0xFF606B7E), + surfaceHover: Color(0xFFF1F3F7), + secondary: Color(0xFFF1F3F7), + secondaryForeground: Color(0xFF4A5363), + muted: Color(0xFFF1F3F7), + mutedForeground: Color(0xFF697386), mutedStrong: Color(0xFF565D6D), - accent: Color(0xFFEEF5FF), - accentForeground: Color(0xFF3268EF), - destructive: Color(0xFFCF2828), - destructiveSolid: Color(0xFFCF2828), - destructiveSolidHover: Color(0xFFB82020), + accent: Color(0xFFF0F5FF), + accentForeground: Color(0xFF2455D2), + destructive: Color(0xFFB42318), + destructiveSolid: Color(0xFFB42318), + destructiveSolidHover: Color(0xFF981F19), destructiveForeground: Color(0xFFFFFFFF), border: Color(0xFFE7EAF0), borderStrong: Color(0xFFCFD5E0), - input: Color(0xFFE7EAF0), + input: Color(0xFFCFD5E0), ring: Color(0xFF3268EF), - selection: Color(0xFFEEF5FF), + selection: Color(0xFFDCE7FF), + switchUnchecked: Color(0xFFB1BDCD), scannerBackground: Color(0xFF101B35), overlay: Color.fromRGBO(9, 17, 29, 0.48), header: Color(0xFFF2F4F8), @@ -110,7 +112,7 @@ class AppPalette { sidebarAccentForeground: Color(0xFF133D70), sidebarBorder: Color(0xFFE7EAF0), sidebarRing: Color(0xFF3268EF), - selectedSurface: Color(0xFFEEF5FF), + selectedSurface: Color(0xFFF0F5FF), running: Color(0xFF1FA04E), runningText: Color(0xFF15803D), runningBadge: Color(0xFF047857), @@ -136,28 +138,29 @@ class AppPalette { popover: Color(0xFF292A30), popoverForeground: Color(0xFFF2F3F5), primary: Color(0xFF91B3FF), - primaryHover: Color(0xFF8AB8FF), - primaryText: Color(0xFF8AB8FF), + primaryHover: Color(0xFF91B3FF), + primaryText: Color(0xFF91B3FF), primarySolid: Color(0xFF3268EF), primarySolidHover: Color(0xFF2455D2), primaryForeground: Color(0xFFFFFFFF), surfaceHover: Color(0xFF2D2E35), - secondary: Color(0xFF17181C), - secondaryForeground: Color(0xFFF2F3F5), - muted: Color(0xFF222328), + secondary: Color(0xFF2D2E35), + secondaryForeground: Color(0xFFE3E5E9), + muted: Color(0xFF2B2C32), mutedForeground: Color(0xFFA9AFBB), mutedStrong: Color(0xFFC2CAD5), accent: Color(0xFF2A2E37), - accentForeground: Color(0xFF91B3FF), - destructive: Color(0xFFFF6262), - destructiveSolid: Color(0xFFCF2828), - destructiveSolidHover: Color(0xFFB82020), + accentForeground: Color(0xFFABC5FF), + destructive: Color(0xFFFF9A92), + destructiveSolid: Color(0xFFC63632), + destructiveSolidHover: Color(0xFF981F19), destructiveForeground: Color(0xFFFFFFFF), border: Color(0xFF35363E), borderStrong: Color(0xFF50525B), - input: Color(0xFF35363E), + input: Color(0xFF50525B), ring: Color(0xFF91B3FF), - selection: Color(0xFF2A2E37), + selection: Color(0xFF3A414E), + switchUnchecked: Color(0xFF62626E), scannerBackground: Color(0xFF101B35), overlay: Color.fromRGBO(9, 17, 29, 0.48), header: Color(0xFF17181C), @@ -219,6 +222,7 @@ class AppPalette { final Color input; final Color ring; final Color selection; + final Color switchUnchecked; final Color scannerBackground; final Color overlay; final Color header; @@ -290,6 +294,7 @@ class AppPalette { input: color(begin.input, end.input), ring: color(begin.ring, end.ring), selection: color(begin.selection, end.selection), + switchUnchecked: color(begin.switchUnchecked, end.switchUnchecked), scannerBackground: color(begin.scannerBackground, end.scannerBackground), overlay: color(begin.overlay, end.overlay), header: color(begin.header, end.header), diff --git a/lib/pages/theme/font.dart b/lib/pages/theme/font.dart index 1870bd2e7..bf1668917 100644 --- a/lib/pages/theme/font.dart +++ b/lib/pages/theme/font.dart @@ -15,29 +15,30 @@ abstract final class AppFontFamily { } abstract final class AppTypography { - // Semantic roles from the prototype's src/theme/tokens.json. Use logical - // pixels and leave system text scaling and locale-specific glyphs to Flutter. + // Match the prototype's type scale and rhythm. Reuse shadcn's bundled faces + // and leave script-specific glyph fallback to the platform. static TextStyle _style(int size, [int weight = 400]) => TextStyle( fontSize: size.toDouble(), fontWeight: FontWeight.values[weight ~/ 100 - 1], fontFamilyFallback: AppFontFamily.sansFallback, + height: 1.45, ); static final pageTitle = _style(26, 600); static final panelTitle = _style(16, 600); static final sectionTitle = _style(16, 600); - static final rowTitle = _style(14, 600); + static final rowTitle = _style(14, 500); static final rowValue = _style(14); static final supporting = _style(13); static final metadata = _style(13); static final control = _style(14, 600); static final navigationLabel = _style(14, 500); static final selectedNavigationLabel = _style(14, 600); - static final badge = _style(12, 600); + static final badge = _style(13, 500); static final code = _style(12); static final metric = _style(22, 500); - static final pageAction = _style(12, 600); + static final pageAction = _style(14, 600); static final settingsSectionTitle = _style(16, 600); static final settingsSectionDesktopTitle = _style(16, 600); static final settingsRow = _style(14, 600); @@ -53,14 +54,14 @@ abstract final class AppTypography { static final desktopSettingsHint = _style(12, 400); static final desktopSettingsNote = _style(12, 400); static final desktopSettingsDanger = _style(14, 600); - static final settingsHint = _style(12, 400); + static final settingsHint = _style(13, 400); static final settingsThemeOption = _style(14, 400); - static final settingsThemeOptionMobile = _style(13, 400); + static final settingsThemeOptionMobile = _style(14, 400); static final settingsVersion = _style(13, 400); static final settingsNote = _style(12, 400); static final settingsDetailNote = _style(12, 400); static final settingsSelect = _style(14, 400); - static final settingsInput = _style(13, 400); + static final settingsInput = _style(14, 400); static final routingCardTitle = _style(16, 600); static final routingCardDescription = _style(12, 400); static final routingRowTitle = _style(13, 700); @@ -263,15 +264,15 @@ abstract final class AppTypography { static final serverGroupCode = _style(13, 700); static final serverGroupTitle = _style(18, 700); static final serverGroupSummary = _style(12, 400); - static final serverNodeTitle = _style(13, 700); + static final serverNodeTitle = _style(14, 500); static final serverProtocol = _style(12, 600); static final serverGroupAction = _style(13, 600); static final serverGroupUseCount = _style(12, 700); static final serverConnectedBadge = _style(12, 700); - static final connectStatusTitle = _style(17, 700); + static final connectStatusTitle = _style(26, 600); static final connectStatusDetail = _style(13, 400); - static final connectButton = _style(16, 700); + static final connectButton = _style(14, 600); static final connectCaption = _style(12, 600); static final connectChoiceLabel = _style(12, 500); static final connectChoiceTitle = _style(14, 600); @@ -290,11 +291,11 @@ abstract final class AppTypography { static final connectRawRowTitle = _style(13, 600); static final connectRawRowDetail = _style(12, 400); - static final desktopBrand = _style(25, 700); + static final desktopBrand = _style(20, 600); static final desktopUpdateLabel = _style(13, 600); - static final connectDesktopStatusTitle = _style(21, 700); - static final connectDesktopStatusDetail = _style(15, 400); - static final connectDesktopAction = _style(20, 700); + static final connectDesktopStatusTitle = _style(26, 600); + static final connectDesktopStatusDetail = _style(13, 400); + static final connectDesktopAction = _style(14, 600); static final connectDesktopCaption = _style(14, 600); static final connectDesktopChoiceLabel = _style(16, 500); static final connectDesktopChoiceTitle = _style(16, 600); @@ -325,16 +326,17 @@ abstract final class AppTypography { static final serverMenuHint = _style(12, 400); static final shad = ShadTextTheme.custom( + canMerge: false, h1Large: pageTitle, h1: pageTitle, - h2: panelTitle, - h3: sectionTitle, + h2: _style(20, 600), + h3: _style(18, 600), h4: sectionTitle, p: rowValue, blockquote: rowValue, table: rowValue, list: rowValue, - lead: supporting, + lead: _style(15), large: panelTitle, small: control, muted: supporting, diff --git a/lib/pages/theme/layout.dart b/lib/pages/theme/layout.dart index fac65ee53..9b71cf195 100644 --- a/lib/pages/theme/layout.dart +++ b/lib/pages/theme/layout.dart @@ -26,18 +26,18 @@ abstract final class AppLayout { static const contentBreakpoint = 840.0; static const pageActionMinHeight = 72.0; static const mobilePageActionMinHeight = 64.0; - static const pageActionButtonMinHeight = 46.0; + static const pageActionButtonMinHeight = 48.0; static const pageActionButtonMinWidth = 150.0; - static const buttonMinHeight = 40.0; - static const mobileButtonMinHeight = 42.0; + static const buttonMinHeight = 42.0; + static const mobileButtonMinHeight = 48.0; static const setupContentMaxWidth = 760.0; static const setupProgressMaxWidth = 840.0; static const setupFooterMaxWidth = 1200.0; static const setupFooterMinHeight = 80.0; static const setupFooterButtonWidth = 210.0; - static const switchWidth = 42.0; - static const switchHeight = 24.0; - static const switchThumbMargin = 2.0; + static const switchWidth = 44.0; + static const switchHeight = 26.0; + static const switchThumbMargin = 3.0; static const emptyStateDesktopMinHeight = 280.0; static const emptyStateTextMaxWidth = 420.0; static const connectStatusMinHeight = 158.0; @@ -49,7 +49,7 @@ abstract final class AppLayout { static const connectDesktopTrafficGroupMinHeight = 165.0; static const connectDesktopRawEmptyMinHeight = 204.0; static const connectDesktopPanelViewportInset = 102.0; - static const connectButtonMinHeight = 45.0; + static const connectButtonMinHeight = 48.0; static const connectExpertRowMinHeight = 47.0; static const connectChoiceMinHeight = 49.0; static const connectTrafficGroupMinHeight = 80.0; @@ -84,8 +84,8 @@ abstract final class AppSpacing { static const connectPageHorizontal = 15.0; static const connectPageTop = 4.0; static const connectPageBottom = 22.0; - static const controlHorizontal = 12.0; - static const buttonHorizontal = 15.0; + static const controlHorizontal = 13.0; + static const buttonHorizontal = 16.0; static const controlVertical = 10.0; static const actionGap = 10.0; static const actionRunGap = 8.0; @@ -97,6 +97,7 @@ abstract final class AppRadii { static const small = 4.0; static const card = 18.0; static const control = 12.0; + static const cta = 24.0; static const compact = 6.0; static const chip = 5.0; static const indicator = 3.0; diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index 3851c7803..b1dfad52e 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -167,6 +167,9 @@ abstract final class AppTheme { return ButtonStyle( backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return disabled(fill); + if (states.contains(WidgetState.pressed)) { + return destructive ? hover : const Color(0xFF174FBF); + } return states.contains(WidgetState.hovered) ? hover : fill; }), foregroundColor: WidgetStateProperty.resolveWith( @@ -180,7 +183,7 @@ abstract final class AppTheme { padding: const WidgetStatePropertyAll(EdgeInsets.zero), shape: const WidgetStatePropertyAll( RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(AppRadii.compact)), + borderRadius: BorderRadius.all(Radius.circular(AppRadii.cta)), ), ), textStyle: WidgetStatePropertyAll(AppTypography.connectButton), @@ -200,29 +203,52 @@ abstract final class AppTheme { final palette = AppColorTokens.fallback(brightness).palette; // Shad buttons use a tight height. Grow it with the scaled label instead of // constraining larger system text to the prototype's minimum height. - final buttonHeight = math.max( - AppLayout.touchExtent( - mobile ? AppLayout.mobileButtonMinHeight : AppLayout.buttonMinHeight, + final labelSize = AppTypography.control.fontSize!; + final scaledExtra = + math.max(0, textScaler.scale(labelSize) - labelSize) * + AppTypography.control.height!; + double buttonHeight(double base) => + AppLayout.touchExtent(base + scaledExtra); + const ctaDecoration = ShadDecoration( + border: ShadBorder( + radius: BorderRadius.all(Radius.circular(AppRadii.cta)), + ), + focusedBorder: ShadBorder( + radius: BorderRadius.all(Radius.circular(AppRadii.cta)), + ), + secondaryFocusedBorder: ShadBorder( + radius: BorderRadius.all(Radius.circular(AppRadii.cta + 3)), ), - textScaler.scale(AppTypography.control.fontSize!) + - AppSpacing.controlVertical * 2, ); return ShadThemeData( brightness: brightness, colorScheme: shadColorScheme(brightness), radius: const BorderRadius.all(Radius.circular(AppRadii.control)), - textTheme: AppTypography.shad, + textTheme: AppTypography.shad.copyWith( + h1Large: AppTypography.pageTitle.copyWith(fontSize: mobile ? 24 : 26), + h1: AppTypography.pageTitle.copyWith(fontSize: mobile ? 24 : 26), + p: AppTypography.rowValue.copyWith(color: palette.foreground), + blockquote: AppTypography.rowValue.copyWith(color: palette.foreground), + table: AppTypography.rowValue.copyWith(color: palette.foreground), + list: AppTypography.rowValue.copyWith(color: palette.foreground), + muted: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), cardTheme: ShadCardTheme( backgroundColor: palette.card, radius: BorderRadius.circular(AppRadii.card), border: ShadBorder.all(color: palette.border, width: 1), shadows: const [], + padding: const EdgeInsets.all(18), ), primaryButtonTheme: ShadButtonTheme( backgroundColor: palette.primarySolid, hoverBackgroundColor: palette.primarySolidHover, foregroundColor: palette.primaryForeground, hoverForegroundColor: palette.primaryForeground, + pressedBackgroundColor: const Color(0xFF174FBF), + decoration: ctaDecoration, textStyle: AppTypography.control, ), destructiveButtonTheme: ShadButtonTheme( @@ -230,6 +256,7 @@ abstract final class AppTheme { hoverBackgroundColor: palette.destructiveSolidHover, foregroundColor: palette.destructiveForeground, hoverForegroundColor: palette.destructiveForeground, + decoration: ctaDecoration, textStyle: AppTypography.control, ), outlineButtonTheme: ShadButtonTheme( @@ -239,44 +266,64 @@ abstract final class AppTheme { hoverForegroundColor: palette.foreground, textStyle: AppTypography.control, ), - secondaryButtonTheme: ShadButtonTheme(textStyle: AppTypography.control), + secondaryButtonTheme: ShadButtonTheme( + backgroundColor: palette.accent, + foregroundColor: palette.primaryText, + hoverBackgroundColor: brightness == Brightness.dark + ? const Color(0xFF343944) + : const Color(0xFFE5EDFF), + hoverForegroundColor: palette.primaryText, + textStyle: AppTypography.control, + ), ghostButtonTheme: ShadButtonTheme( - hoverBackgroundColor: palette.surfaceHover, + foregroundColor: palette.foreground, + hoverForegroundColor: palette.primaryText, + hoverBackgroundColor: palette.accent, textStyle: AppTypography.control, ), linkButtonTheme: ShadButtonTheme( - foregroundColor: palette.primary, - hoverForegroundColor: palette.primaryHover, + foregroundColor: palette.primaryText, + hoverForegroundColor: palette.primaryText, textStyle: AppTypography.control, ), inputTheme: ShadInputTheme( decoration: ShadDecoration( color: palette.card, - // Prototype :focus-visible: 3px, 30% primary, with a 2px gap. + border: ShadBorder.all( + color: palette.input, + width: 1, + radius: BorderRadius.circular(AppRadii.control), + ), + focusedBorder: ShadBorder.all( + color: palette.primary, + width: 1, + radius: BorderRadius.circular(AppRadii.control), + ), secondaryFocusedBorder: ShadBorder.all( - color: palette.primary.withValues(alpha: .3), + color: palette.primary.withValues(alpha: .18), width: 3, - offset: 5, - radius: BorderRadius.circular(AppRadii.control + 5), + offset: 3, + radius: BorderRadius.circular(AppRadii.control + 3), ), ), style: AppTypography.rowValue.copyWith(color: palette.foreground), placeholderStyle: AppTypography.rowValue.copyWith( color: palette.mutedForeground, ), - padding: const EdgeInsets.symmetric( + padding: EdgeInsets.symmetric( horizontal: AppSpacing.controlHorizontal, - vertical: AppSpacing.controlVertical, + vertical: mobile ? 14 : 12, ), ), switchTheme: ShadSwitchTheme( width: AppLayout.switchWidth, height: AppLayout.switchHeight, margin: AppLayout.switchThumbMargin, - duration: const Duration(milliseconds: 140), + duration: const Duration(milliseconds: 100), thumbColor: palette.primaryForeground, - checkedTrackColor: palette.primary, - uncheckedTrackColor: palette.borderStrong, + checkedTrackColor: palette.primarySolid, + uncheckedTrackColor: palette.switchUnchecked, + padding: EdgeInsets.symmetric(horizontal: 2, vertical: mobile ? 11 : 7), ), radioTheme: ShadRadioTheme( // Shad adds the border outside its content size: 15 + 1 + 1 = 17. @@ -289,21 +336,45 @@ abstract final class AppTheme { ), buttonSizesTheme: ShadButtonSizesTheme( regular: ShadButtonSizeTheme( - height: buttonHeight, + height: buttonHeight( + mobile + ? AppLayout.mobileButtonMinHeight + : AppLayout.buttonMinHeight, + ), padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.controlHorizontal, + horizontal: AppSpacing.buttonHorizontal, vertical: AppSpacing.controlVertical, ), ), sm: ShadButtonSizeTheme( - height: math.max( - 36, - textScaler.scale(AppTypography.control.fontSize!), - ), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.controlHorizontal, - ), - ), + height: buttonHeight(mobile ? 48 : 36), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + lg: ShadButtonSizeTheme( + height: buttonHeight(mobile ? 52 : 48), + padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 12), + ), + icon: ShadButtonSizeTheme( + width: AppLayout.touchExtent(mobile ? 48 : 40), + height: AppLayout.touchExtent(mobile ? 48 : 40), + padding: EdgeInsets.zero, + ), + ), + tabsTheme: ShadTabsTheme( + padding: const EdgeInsets.all(4), + decoration: ShadDecoration(color: palette.secondary), + tabTextStyle: AppTypography.control, + tabForegroundColor: palette.mutedForeground, + tabSelectedForegroundColor: palette.primaryText, + tabSelectedBackgroundColor: palette.card, + tabSelectedHoverBackgroundColor: palette.card, + tabHoverBackgroundColor: palette.accent, + tabPadding: EdgeInsets.symmetric( + horizontal: 14, + vertical: mobile ? 12 : 9, + ), + tabShadows: const [], + tabSelectedShadows: const [], ), ); } @@ -329,7 +400,7 @@ abstract final class AppTheme { accentForeground: palette.accentForeground, destructive: palette.destructive, destructiveForeground: brightness == Brightness.dark - ? palette.background + ? const Color(0xFF18181B) : palette.destructiveForeground, border: palette.border, input: palette.input, @@ -384,7 +455,7 @@ abstract final class AppTheme { onTertiaryContainer: palette.foreground, error: palette.destructive, onError: brightness == Brightness.dark - ? palette.background + ? const Color(0xFF18181B) : palette.destructiveForeground, errorContainer: palette.destructiveSurface, onErrorContainer: palette.destructive, @@ -438,13 +509,18 @@ abstract final class AppTheme { horizontal: AppSpacing.buttonHorizontal, ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: roundedRectangle, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.cta), + ), textStyle: AppTypography.control, ).copyWith( backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) { return disabledButtonColor(palette.primarySolid); } + if (states.contains(WidgetState.pressed)) { + return const Color(0xFF174FBF); + } return states.contains(WidgetState.hovered) ? palette.primarySolidHover : palette.primarySolid; @@ -492,6 +568,7 @@ abstract final class AppTheme { surfaceTintColor: Colors.transparent, shadowColor: Colors.black.withValues(alpha: 0.07), elevation: 0, + margin: EdgeInsets.zero, shape: borderedRectangle, ), dialogTheme: DialogThemeData( @@ -529,7 +606,7 @@ abstract final class AppTheme { horizontal: AppSpacing.buttonHorizontal, ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: palette.input), + side: BorderSide(color: palette.border), shape: roundedRectangle, textStyle: AppTypography.control, ).copyWith( @@ -537,7 +614,7 @@ abstract final class AppTheme { (states) => BorderSide( color: states.contains(WidgetState.hovered) ? palette.borderStrong - : palette.input, + : palette.border, ), ), backgroundColor: WidgetStateProperty.resolveWith( @@ -550,7 +627,7 @@ abstract final class AppTheme { textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: palette.primary, + foregroundColor: palette.primaryText, disabledForegroundColor: disabledButtonColor(palette.primary), minimumSize: minimumButtonSize, shape: roundedRectangle, @@ -562,7 +639,7 @@ abstract final class AppTheme { } return states.contains(WidgetState.hovered) ? palette.primaryHover - : palette.primary; + : palette.primaryText; }), ), ), @@ -576,7 +653,7 @@ abstract final class AppTheme { ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: Colors.transparent, + fillColor: palette.card, prefixIconColor: palette.mutedForeground, suffixIconColor: palette.mutedForeground, labelStyle: AppTypography.rowValue.copyWith( @@ -594,9 +671,9 @@ abstract final class AppTheme { errorStyle: AppTypography.supporting.copyWith( color: palette.destructive, ), - contentPadding: const EdgeInsets.symmetric( + contentPadding: EdgeInsets.symmetric( horizontal: AppSpacing.controlHorizontal, - vertical: AppSpacing.controlVertical, + vertical: mobile ? 14 : 12, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadii.control), @@ -675,10 +752,10 @@ abstract final class AppTheme { ), ), tabBarTheme: TabBarThemeData( - labelColor: palette.primary, + labelColor: palette.primaryText, unselectedLabelColor: palette.mutedForeground, - labelStyle: const TextStyle(fontWeight: FontWeight.w600), - unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600), + labelStyle: AppTypography.control, + unselectedLabelStyle: AppTypography.control, indicatorColor: palette.primary, dividerColor: palette.border, overlayColor: WidgetStatePropertyAll( @@ -725,8 +802,8 @@ abstract final class AppTheme { thumbColor: WidgetStatePropertyAll(palette.primaryForeground), trackColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.selected) - ? palette.primary - : palette.borderStrong, + ? palette.primarySolid + : palette.switchUnchecked, ), trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent), ), diff --git a/test/pages/advanced/tunnel/apple/page_test.dart b/test/pages/advanced/tunnel/apple/page_test.dart index 8cc199d09..b92d9e094 100644 --- a/test/pages/advanced/tunnel/apple/page_test.dart +++ b/test/pages/advanced/tunnel/apple/page_test.dart @@ -79,6 +79,7 @@ Finder _toggle(String field) => find.descendant( Future _tap(WidgetTester tester, Finder finder) async { await tester.ensureVisible(finder); + await tester.pumpAndSettle(); await tester.tap(finder); await tester.pumpAndSettle(); } diff --git a/test/pages/advanced/xray/runtime_viewer_test.dart b/test/pages/advanced/xray/runtime_viewer_test.dart index 0de0cdca9..6fc540f65 100644 --- a/test/pages/advanced/xray/runtime_viewer_test.dart +++ b/test/pages/advanced/xray/runtime_viewer_test.dart @@ -148,7 +148,7 @@ void main() { final line = tester.widget(find.text('first line')); expect(line.textDirection, TextDirection.ltr); expect(line.style!.fontSize, 12); - expect(line.style!.height, isNull); + expect(line.style!.height, 1.45); expect( tester.widget(find.byType(AppBar)).actions ?? const [], isEmpty, diff --git a/test/pages/connect/target_picker_test.dart b/test/pages/connect/target_picker_test.dart index 89219675d..037e77261 100644 --- a/test/pages/connect/target_picker_test.dart +++ b/test/pages/connect/target_picker_test.dart @@ -162,7 +162,11 @@ void main() { await tester.pumpAndSettle(); await tester.scrollUntilVisible( row, - 80, + tester + .state(scrollable) + .position + .viewportDimension + .clamp(120.0, 320.0), scrollable: scrollable, maxScrolls: 180, ); diff --git a/test/pages/shared/widgets/page_action_bar_test.dart b/test/pages/shared/widgets/page_action_bar_test.dart index 00e23fe1a..1c2fda812 100644 --- a/test/pages/shared/widgets/page_action_bar_test.dart +++ b/test/pages/shared/widgets/page_action_bar_test.dart @@ -65,11 +65,11 @@ void main() { final variant = shad ? 'Shad' : 'Material'; for (final (platform, buttonHeight) in [ - (TargetPlatform.iOS, 46.0), + (TargetPlatform.iOS, 48.0), (TargetPlatform.android, 48.0), ]) { testWidgets( - '$variant ${platform.name} footer keeps equal buttons and 12px text', + '$variant ${platform.name} footer keeps equal buttons and prototype 14px text', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(427, 900); @@ -97,9 +97,9 @@ void main() { expect(cancel.height, buttonHeight); expect(save.height, buttonHeight); expect(save.left - cancel.right, AppSpacing.actionGap); - expect(cancel.top - bar.top, closeTo(9, 0.01)); - expect(bar.bottom - cancel.bottom, closeTo(24 + 9, 0.01)); - expect(bar.height, closeTo(buttonHeight + 18 + 24, 0.01)); + expect(cancel.top - bar.top, closeTo(8, 0.01)); + expect(bar.bottom - cancel.bottom, closeTo(24 + 8, 0.01)); + expect(bar.height, closeTo(buttonHeight + 16 + 24, 0.01)); for (final key in [_cancel, _save]) { final label = tester.widget( find.descendant( @@ -107,7 +107,7 @@ void main() { matching: find.byType(RichText), ), ); - expect(label.text.style!.fontSize, 12); + expect(label.text.style!.fontSize, 14); } await tester.tap(find.byKey(_cancel)); await tester.tap(find.byKey(_save)); diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index 6f561e232..1a837d0b2 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -1,6 +1,5 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -55,8 +54,8 @@ void main() { ); expect(material.navigationRailTheme.backgroundColor, palette.sidebar); expect(material.navigationBarTheme.height, isNull); - expect(material.tabBarTheme.labelStyle?.fontSize, isNull); - expect(material.tabBarTheme.unselectedLabelStyle?.fontSize, isNull); + expect(material.tabBarTheme.labelStyle?.fontSize, 14); + expect(material.tabBarTheme.unselectedLabelStyle?.fontSize, 14); expect(shad.background, palette.background); expect(shad.foreground, palette.foreground); @@ -100,7 +99,7 @@ void main() { expect(shad.input, palette.input); expect(material.colorScheme.outline, shad.border); expect(palette.border, const Color(0xFF35363E)); - expect(palette.input, const Color(0xFF35363E)); + expect(palette.input, const Color(0xFF50525B)); expect(palette.primary, const Color(0xFF91B3FF)); expect(palette.primarySolid, const Color(0xFF3268EF)); expect(palette.foreground, const Color(0xFFF2F3F5)); @@ -115,7 +114,7 @@ void main() { expect(material.textTheme.bodyMedium?.fontFamily, AppFontFamily.sans); expect(material.textTheme.bodyMedium?.fontSize, 14); expect(material.textTheme.bodySmall?.fontSize, 13); - expect(material.textTheme.labelSmall?.fontSize, 12); + expect(material.textTheme.labelSmall?.fontSize, 13); expect(shad.textTheme.family, AppFontFamily.sans); expect(shad.textTheme.p.fontSize, 14); expect(shad.textTheme.small.fontSize, 14); @@ -125,12 +124,12 @@ void main() { expect(AppTypography.panelTitle.fontSize, 16); expect(AppTypography.supporting.fontSize, 13); expect(AppTypography.navigationLabel.fontSize, 14); - expect(AppTypography.badge.fontSize, 12); + expect(AppTypography.badge.fontSize, 13); expect(AppTypography.code.fontSize, 12); expect(AppTypography.metric.fontSize, 22); for (final (style, size, weight) in [ - (AppTypography.connectStatusTitle, 17, FontWeight.w700), - (AppTypography.connectButton, 16, FontWeight.w700), + (AppTypography.connectStatusTitle, 26, FontWeight.w600), + (AppTypography.connectButton, 14, FontWeight.w600), (AppTypography.connectCaption, 12, FontWeight.w600), (AppTypography.connectChoiceLabel, 12, FontWeight.w500), (AppTypography.connectChoiceTitle, 14, FontWeight.w600), @@ -145,21 +144,15 @@ void main() { ]) { expect(style.fontSize, size); expect(style.fontWeight, weight); - expect(style.height, isNull); + expect(style.height, 1.45); expect(style.fontVariations, isNull); expect(style.letterSpacing, isNull); expect(style.fontFeatures, isNull); expect(style.fontFamily, isNull); expect(style.fontFamilyFallback, AppFontFamily.sansFallback); } - expect(AppFontFamily.windowsSansFallback, [ - 'Microsoft YaHei UI', - 'Microsoft YaHei', - ]); - expect( - AppFontFamily.sansFallback, - AppPlatform.isWindows ? AppFontFamily.windowsSansFallback : null, - ); + expect(AppFontFamily.sans, 'packages/shadcn_ui/Geist'); + expect(AppFontFamily.mono, 'packages/shadcn_ui/GeistMono'); expect( material.textTheme.bodyMedium?.fontFamilyFallback, AppFontFamily.sansFallback, @@ -184,7 +177,7 @@ void main() { expect(filled.foregroundColor!.resolve({}), Colors.white); expect( material.textButtonTheme.style!.foregroundColor!.resolve({}), - palette.primary, + palette.primaryText, ); expect(shad.primaryButtonTheme.backgroundColor, palette.primarySolid); expect( @@ -211,9 +204,9 @@ void main() { material.switchTheme.trackColor!.resolve({}), ); expect(shad.switchTheme.thumbColor, Colors.white); - expect(shad.switchTheme.width, 42); - expect(shad.switchTheme.height, 24); - expect(shad.switchTheme.margin, 2); + expect(shad.switchTheme.width, 44); + expect(shad.switchTheme.height, 26); + expect(shad.switchTheme.margin, 3); expect(material.dialogTheme.barrierColor, palette.overlay); expect( material.navigationRailTheme.indicatorColor, @@ -352,8 +345,8 @@ void main() { primary.overlayColor!.resolve({WidgetState.pressed}), Colors.transparent, ); - expect(primary.textStyle!.resolve({})!.fontSize, 16); - expect(tester.getSize(find.byType(FilledButton)).height, 45); + expect(primary.textStyle!.resolve({})!.fontSize, 14); + expect(tester.getSize(find.byType(FilledButton)).height, 48); expect(tester.takeException(), isNull); } }); @@ -410,7 +403,7 @@ void main() { tester.widget(outline).shape! as RoundedRectangleBorder; final filledShape = tester.widget(filled).shape! as RoundedRectangleBorder; - expect(outlineShape.borderRadius, filledShape.borderRadius); + expect(filledShape.borderRadius, BorderRadius.circular(AppRadii.cta)); expect( outlineShape.borderRadius, BorderRadius.circular(AppRadii.control), From 0dbdd377323e1a583e617a846db712df123cc558 Mon Sep 17 00:00:00 2001 From: yiguo Date: Tue, 3 Nov 2026 23:30:21 +0800 Subject: [PATCH 17/60] style(ui): align shared navigation and cards with prototype --- docs/app.md | 17 +- lib/l10n/app_en.arb | 3 + lib/l10n/app_fa.arb | 3 + lib/l10n/app_ru.arb | 3 + lib/l10n/app_zh.arb | 3 + lib/l10n/app_zh_Hant.arb | 3 + lib/pages/main/adaptive_shell.dart | 379 +++++++++------ lib/pages/main/desktop_window.dart | 2 +- lib/pages/shared/widgets/section.dart | 179 ++++---- lib/pages/shared/widgets/setting_row.dart | 43 +- lib/pages/theme/font.dart | 4 + lib/pages/theme/layout.dart | 17 +- test/pages/main/adaptive_shell_test.dart | 289 +++++++++++- .../appearance_startup_update_test.dart | 2 +- test/pages/shared/widgets/section_test.dart | 431 ++++++++++++++++++ 15 files changed, 1099 insertions(+), 279 deletions(-) diff --git a/docs/app.md b/docs/app.md index 4e8e71cf6..568ea1547 100644 --- a/docs/app.md +++ b/docs/app.md @@ -6,8 +6,10 @@ ## 导航与交互 - 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。 - 手机根页使用前五项底部导航,设置由顶部按钮进入;设置页和二级页面隐藏底部根导航。 - 平板和桌面宽布局保留带文字的左侧导航,设置位于侧栏底部;不再提供“高级”的二级 Tab 容器。 + 手机根页使用内缩的圆角卡片底栏,包含前五项入口,选中项为蓝底白字;设置由顶部按钮进入。 + 设置页和二级页面隐藏底部根导航。平板和桌面宽布局保留带文字的左侧导航, + 连接、服务器和路由归入“工作区”,VPN 隧道和 Xray 归入“系统与内核”,设置位于侧栏底部; + 不再提供“高级”的二级 Tab 容器。侧栏选中项使用无边框卡片底色与强调色文字。 完整业务页面均可在当前分支内作为二级页面打开,包括其他根页面。 页面入口使用 `pushScoped`,不因打开服务器或设置而切换根 Tab。 - 宽布局侧栏允许主动切换分支,并保留各栈。Setup 独立于业务导航。 @@ -27,7 +29,8 @@ 颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。 App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。 -字号为不小于 12 的整数、字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 +字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 +字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 主题、语言默认跟随系统,已保存的选择优先;未支持的语言回退英语。 @@ -36,6 +39,9 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 隐私正文使用 HTTPS 链接,App 不内置隐私正文,不收集匿名分析数据。 `ResponsiveContent` 只限制内容宽度,不叠加页面内边距;公共按钮保留系统文字缩放。 +`SectionView` 的标题、默认位置的说明与正文共用同一张圆角卡片,不在标题与正文间绘制通栏分隔线; +`descriptionBelow` 指定的说明仍位于卡片外。`SettingSection` 默认不绘制行间分隔线,需要时显式启用 `separated`。 +导航和卡片共用现有字体家族与文字角色;导航辅助标签使用专属角色,不改写正文等已有角色。 共享触控下限按平台应用:iOS 为 44×44 pt,Android 为 48×48 dp,不依赖窗口宽度。 弹窗关闭、返回及设置动作可保留较小的视觉图标,通过实际点击区域满足下限;横屏不切换为桌面触控尺寸。 入口数量选择器沿用相同触控下限,桌面保持紧凑。订阅可编辑字段关联本地化名称, @@ -52,8 +58,9 @@ UI-only 改动保持字段、语义、平台可见性、持久化和验证不变 ### 桌面窗口 -窗口框架位于 Router 外。macOS / macOS SE 保留原生红黄绿按钮,侧栏呈现原生材质,正文和表单保持不透明。 -材质跟随最终主题,初始化失败使用纯色窗口;不新增外观开关或强制活跃状态。 +窗口框架位于 Router 外。macOS / macOS SE 保留原生红黄绿按钮、标题栏几何与安全区; +Flutter 窗口背景和侧栏分别使用不透明的主题背景色与侧栏色,正文和表单保持不透明。 +原生窗口外观跟随最终主题,初始化失败使用纯色窗口;不新增外观开关或强制活跃状态。 标题栏高度在缩放和全屏事件后更新,不定时轮询;LTR 宽布局让侧栏避让按钮,RTL 正文仍避让物理左侧按钮。 窄窗口、Setup 和独立页面保留顶部安全区。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 785f18199..a7462c3aa 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,7 @@ { + "navigationTagline": "Your servers, your connection", + "navigationWorkspace": "Workspace", + "navigationSystemAndCore": "System & core", "serverDialingTitle": "Server dialing", "serverDialingDomainStrategy": "Domain resolution", "serverDialingSystem": "System default", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 26859a351..fe2d24dde 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,7 @@ { + "navigationTagline": "سرورهای شما، اتصال شما", + "navigationWorkspace": "فضای کاری", + "navigationSystemAndCore": "سیستم و هسته", "serverDialingTitle": "اتصال به سرور", "serverDialingDomainStrategy": "تبدیل نام دامنه", "serverDialingSystem": "پیش‌فرض سیستم", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3cf30dc89..3a717acc5 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,7 @@ { + "navigationTagline": "Ваши серверы, ваше подключение", + "navigationWorkspace": "Рабочая область", + "navigationSystemAndCore": "Система и ядро", "serverDialingTitle": "Подключение к серверу", "serverDialingDomainStrategy": "Разрешение доменов", "serverDialingSystem": "Системное", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 15ee206d8..e45b34e49 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,7 @@ { + "navigationTagline": "你的服务器,你的连接", + "navigationWorkspace": "工作区", + "navigationSystemAndCore": "系统与内核", "serverDialingTitle": "服务器拨号", "serverDialingDomainStrategy": "域名解析", "serverDialingSystem": "系统默认", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 15c86daf1..54c0b8516 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,7 @@ { + "navigationTagline": "你的伺服器,你的連線", + "navigationWorkspace": "工作區", + "navigationSystemAndCore": "系統與核心", "serverDialingTitle": "伺服器撥號", "serverDialingDomainStrategy": "網域解析", "serverDialingSystem": "系統預設", diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index b57a17647..ebd49b4a9 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -257,7 +257,7 @@ class _AdaptiveMainShellState extends State { appUpdateInfo, ); } - return _bottomNavigationScaffold(context, appUpdateInfo != null); + return _bottomNavigationScaffold(context); }, ), ); @@ -265,10 +265,8 @@ class _AdaptiveMainShellState extends State { ); } - Widget _bottomNavigationScaffold( - BuildContext context, - bool appUpdateAvailable, - ) { + Widget _bottomNavigationScaffold(BuildContext context) { + final palette = ColorManager.palette(context); return Scaffold( body: navigationShell, bottomNavigationBar: @@ -284,35 +282,94 @@ class _AdaptiveMainShellState extends State { ? null : navigationShell.currentIndex == AppPrimaryDestination.settings.index ? null - : DecoratedBox( - key: const ValueKey('primary-mobile-navigation'), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: ColorManager.palette(context).border), + : SafeArea( + top: false, + child: Container( + key: const ValueKey('primary-mobile-navigation'), + margin: const EdgeInsets.fromLTRB(12, 0, 12, 10), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: palette.card, + borderRadius: BorderRadius.circular(22), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final primary in AppPrimaryDestination.values.where( + (p) => p != AppPrimaryDestination.settings, + )) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _mobileDestination(context, primary), + ), + ), + ], + ), ), ), - child: NavigationBar( - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: (index) => context.goPrimary( - navigationShell, - AppPrimaryDestination.values[index], + ), + ); + } + + Widget _mobileDestination( + BuildContext context, + AppPrimaryDestination primary, + ) { + final palette = ColorManager.palette(context); + final selected = primary.index == navigationShell.currentIndex; + final color = selected + ? palette.primaryForeground + : palette.mutedForeground; + return Semantics( + key: ValueKey('primary-navigation-${primary.name}'), + label: _label(context, primary), + selected: selected, + button: true, + child: Material( + color: selected ? palette.primarySolid : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadii.control), + child: InkWell( + onTap: () => context.goPrimary(navigationShell, primary), + borderRadius: BorderRadius.circular(AppRadii.control), + hoverColor: selected ? palette.primarySolid : palette.secondary, + child: ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 56, + platform: Theme.of(context).platform, ), - destinations: [ - for (final primary in AppPrimaryDestination.values.where( - (p) => p != AppPrimaryDestination.settings, - )) - NavigationDestination( - key: ValueKey('primary-navigation-${primary.name}'), - icon: _navigationIcon( - context, - primary, - appUpdateAvailable, - ), - label: _label(context, primary), + minWidth: AppLayout.touchExtent( + 0, + platform: Theme.of(context).platform, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(_icon(primary), size: 19, color: color), + const SizedBox(height: 5), + Text( + _label(context, primary), + textAlign: TextAlign.center, + style: + (selected + ? AppTypography.selectedMobileNavigationLabel + : AppTypography.mobileNavigationLabel) + .copyWith(color: color), ), - ], + ], + ), ), ), + ), + ), + ), ); } @@ -328,7 +385,7 @@ class _AdaptiveMainShellState extends State { final palette = ColorManager.palette(context); final nativeSidebar = DesktopWindowFrame.hasNativeSidebar(context); return Scaffold( - backgroundColor: nativeSidebar ? Colors.transparent : null, + backgroundColor: palette.sidebar, // Keep landscape cutouts outside the sidebar's usable content width. body: SafeArea( top: false, @@ -338,26 +395,16 @@ class _AdaptiveMainShellState extends State { Container( key: const ValueKey('primary-desktop-navigation'), width: sidebarWidth, - decoration: BoxDecoration( - color: nativeSidebar ? Colors.transparent : palette.sidebar, - border: BorderDirectional( - end: BorderSide(color: palette.sidebarBorder), - ), - ), + color: palette.sidebar, child: SafeArea( child: Padding( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sidebarHorizontal, - vertical: nativeSidebar - ? AppSpacing.macOSSidebarVertical - : AppSpacing.sidebarVertical, - ), - child: _sidebarContent( - context, - compact: compact, - nativeSidebar: nativeSidebar, - appUpdateInfo: appUpdateInfo, + padding: const EdgeInsets.fromLTRB( + AppSpacing.sidebarHorizontal, + AppSpacing.sidebarVertical, + AppSpacing.sidebarHorizontal, + AppSpacing.sidebarBottom, ), + child: _sidebarContent(context, appUpdateInfo: appUpdateInfo), ), ), ), @@ -382,11 +429,10 @@ class _AdaptiveMainShellState extends State { Widget _sidebarContent( BuildContext context, { - required bool compact, - required bool nativeSidebar, required AppUpdateInfo? appUpdateInfo, }) { final palette = ColorManager.palette(context); + final localizations = AppLocalizations.of(context)!; return LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( primary: false, @@ -401,35 +447,61 @@ class _AdaptiveMainShellState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: EdgeInsetsDirectional.fromSTEB( - compact - ? AppSpacing.sidebarCompactBrandStart - : AppSpacing.sidebarBrandStart, - AppSpacing.sidebarBrandTop, - compact - ? AppSpacing.sidebarCompactBrandStart - : AppSpacing.sidebarBrandStart, - nativeSidebar - ? AppSpacing.macOSSidebarBrandBottom - : AppSpacing.sidebarBrandBottom, + padding: const EdgeInsetsDirectional.fromSTEB( + AppSpacing.sidebarBrandStart, + 0, + 0, + AppSpacing.sidebarBrandBottom, ), - child: Text( - 'OneXray', - maxLines: 1, - softWrap: false, - overflow: TextOverflow.ellipsis, - style: AppTypography.desktopBrand.copyWith( - color: palette.brand, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Image.asset( + 'assets/logo.png', + width: 32, + height: 32, + excludeFromSemantics: true, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + 'OneXray', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: AppTypography.desktopBrand.copyWith( + color: palette.foreground, + ), + ), + ), + ], + ), + const SizedBox(height: 9), + Text( + localizations.navigationTagline, + style: AppTypography.sidebarTagline.copyWith( + color: palette.mutedForeground, + ), + ), + ], ), ), - for (final primary in AppPrimaryDestination.values.where( - (p) => p != AppPrimaryDestination.settings, - )) ...[ - if (primary.index > 0) - const SizedBox(height: AppSpacing.sidebarRowGap), + _sidebarLabel(context, localizations.navigationWorkspace), + for (final primary in const [ + AppPrimaryDestination.connect, + AppPrimaryDestination.servers, + AppPrimaryDestination.routing, + ]) + _desktopDestination(context, primary), + const SizedBox(height: 22), + _sidebarLabel(context, localizations.navigationSystemAndCore), + for (final primary in const [ + AppPrimaryDestination.tunnel, + AppPrimaryDestination.xray, + ]) _desktopDestination(context, primary), - ], ], ), Column( @@ -453,6 +525,16 @@ class _AdaptiveMainShellState extends State { ); } + Widget _sidebarLabel(BuildContext context, String label) => Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 0, 0, 8), + child: Text( + label, + style: AppTypography.sidebarGroupLabel.copyWith( + color: ColorManager.palette(context).mutedForeground, + ), + ), + ); + Widget _desktopDestination( BuildContext context, AppPrimaryDestination primary, @@ -460,48 +542,50 @@ class _AdaptiveMainShellState extends State { final palette = ColorManager.palette(context); final selected = primary.index == navigationShell.currentIndex; final label = _label(context, primary); - final color = selected ? palette.primary : palette.mutedStrong; - return Semantics( - key: ValueKey('primary-navigation-${primary.name}'), - label: label, - selected: selected, - button: true, - child: Material( - color: selected ? palette.selectedSurface : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.card), - side: BorderSide( - color: selected - ? Color.lerp(palette.border, palette.primary, .2)! - : Colors.transparent, - ), - ), - child: InkWell( - onTap: () => context.goPrimary(navigationShell, primary), - borderRadius: BorderRadius.circular(AppRadii.card), - hoverColor: palette.surfaceHover, - child: ExcludeSemantics( - child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: AppSpacing.sidebarRowHeight, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon(_icon(primary), size: 22, color: color), - const SizedBox(width: 14), - Expanded( - child: Text( - label, - style: - (selected - ? AppTypography.selectedNavigationLabel - : AppTypography.navigationLabel) - .copyWith(color: color), + final color = selected ? palette.primary : palette.foreground; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sidebarRowGap), + child: Semantics( + key: ValueKey('primary-navigation-${primary.name}'), + label: label, + selected: selected, + button: true, + child: Material( + color: selected ? palette.card : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadii.control), + child: InkWell( + onTap: () => context.goPrimary(navigationShell, primary), + borderRadius: BorderRadius.circular(AppRadii.control), + hoverColor: palette.surfaceHover, + child: ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + AppSpacing.sidebarRowHeight, + platform: Theme.of(context).platform, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Row( + children: [ + Icon(_icon(primary), size: 18, color: color), + const SizedBox(width: 11), + Expanded( + child: Text( + label, + style: + (selected + ? AppTypography.selectedNavigationLabel + : AppTypography.navigationLabel) + .copyWith(color: color), + ), ), - ), - ], + ], + ), ), ), ), @@ -515,26 +599,14 @@ class _AdaptiveMainShellState extends State { context.pushAppUpdateDialog(updateInfo); } - Widget _navigationIcon( - BuildContext context, - AppPrimaryDestination primary, - bool appUpdateAvailable, - ) { - final icon = Icon(_icon(primary)); - if (primary != AppPrimaryDestination.settings || !appUpdateAvailable) { - return icon; - } - return _UpdateBadge(child: icon); - } - IconData _icon(AppPrimaryDestination primary) { return switch (primary) { - AppPrimaryDestination.connect => LucideIcons.link, - AppPrimaryDestination.servers => LucideIcons.layers3, + AppPrimaryDestination.connect => LucideIcons.power, + AppPrimaryDestination.servers => LucideIcons.server, AppPrimaryDestination.routing => LucideIcons.route, AppPrimaryDestination.tunnel => LucideIcons.shieldCheck, AppPrimaryDestination.xray => LucideIcons.activity, - AppPrimaryDestination.settings => LucideIcons.settings, + AppPrimaryDestination.settings => LucideIcons.settings2, }; } @@ -568,30 +640,37 @@ class _DesktopUpdateReminder extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadii.card), - child: SizedBox( - width: double.infinity, - height: 44, - child: Padding( - padding: const EdgeInsetsDirectional.symmetric( - horizontal: AppSpacing.controlHorizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 44, + platform: Theme.of(context).platform, ), - child: Row( - children: [ - const _UpdateBadge( - child: Icon(LucideIcons.download, size: 19), - ), - const SizedBox(width: AppSpacing.actionGap), - Expanded( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.desktopUpdateLabel.copyWith( - color: palette.sidebarAccentForeground, + ), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: AppSpacing.controlHorizontal, + ), + child: Row( + children: [ + const _UpdateBadge( + child: Icon(LucideIcons.download, size: 19), + ), + const SizedBox(width: AppSpacing.actionGap), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.desktopUpdateLabel.copyWith( + color: palette.sidebarAccentForeground, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pages/main/desktop_window.dart b/lib/pages/main/desktop_window.dart index eded325ea..575e27b22 100644 --- a/lib/pages/main/desktop_window.dart +++ b/lib/pages/main/desktop_window.dart @@ -88,7 +88,7 @@ class _DesktopWindowFrameState extends State padding: media.padding.copyWith(top: height), viewPadding: media.viewPadding.copyWith(top: height), ), - child: widget.child, + child: ColoredBox(color: palette.background, child: widget.child), ), ); } diff --git a/lib/pages/shared/widgets/section.dart b/lib/pages/shared/widgets/section.dart index 88d2ca645..e6296cf92 100644 --- a/lib/pages/shared/widgets/section.dart +++ b/lib/pages/shared/widgets/section.dart @@ -28,98 +28,52 @@ class SectionView extends StatelessWidget { @override Widget build(BuildContext context) { - final hasTitle = title.isNotEmpty; final hasDescription = description != null && description!.isNotEmpty; final hasHeader = - hasTitle || (hasDescription && !descriptionBelow) || action != null; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + title.isNotEmpty || + (hasDescription && !descriptionBelow) || + action != null; return Padding( padding: padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (hasHeader) - Padding( - padding: EdgeInsetsDirectional.only( - start: - headerInset ?? - (icon == null - ? 0 - : mobile - ? 6 - : 1), - bottom: icon == null - ? 8 - : mobile - ? 11 - : 13, - ), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: icon == null - ? 0 - : mobile - ? 20 - : 24, - ), - child: Row( - crossAxisAlignment: icon == null - ? CrossAxisAlignment.end - : CrossAxisAlignment.center, - children: [ - if (icon != null) ...[ - Icon( - icon, - size: mobile ? 18 : 19, - color: ColorManager.palette(context).primary, - ), - SizedBox(width: mobile ? 9 : 10), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasTitle) - Text( - title, - style: - (icon == null - ? AppTypography.sectionTitle - : mobile - ? AppTypography.settingsSectionTitle - : AppTypography - .settingsSectionDesktopTitle) - .copyWith( - color: ColorManager.primaryText( - context, - ), - ), - ), - if (hasDescription && !descriptionBelow) ...[ - if (hasTitle) const SizedBox(height: 3), - Text( - description!, - style: AppTypography.supporting.copyWith( - color: ColorManager.secondaryText(context), - ), - ), - ], - ], - ), - ), - if (action != null) ...[const SizedBox(width: 12), action!], - ], - ), - ), - ), ShadCard( width: double.infinity, padding: EdgeInsets.zero, radius: const BorderRadius.all(Radius.circular(AppRadii.card)), shadows: const [], clipBehavior: Clip.antiAlias, - child: Material(type: MaterialType.transparency, child: child), + child: Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (hasHeader) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, + ), + child: Padding( + padding: EdgeInsetsDirectional.only( + start: headerInset ?? 0, + ), + child: _header(context, hasDescription), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + 18, + hasHeader ? 0 : 18, + 18, + 18, + ), + child: child, + ), + ], + ), + ), ), if (hasDescription && descriptionBelow) Padding( @@ -135,4 +89,71 @@ class SectionView extends StatelessWidget { ), ); } + + Widget _header(BuildContext context, bool hasDescription) { + final identity = Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 3), + child: Icon( + icon, + size: 19, + color: ColorManager.palette(context).primaryText, + ), + ), + const SizedBox(width: 10), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Semantics( + header: true, + child: Text( + title, + style: AppTypography.sectionTitle.copyWith( + color: ColorManager.primaryText(context), + ), + ), + ), + if (hasDescription && !descriptionBelow) ...[ + if (title.isNotEmpty) const SizedBox(height: 4), + Text( + description!, + style: AppTypography.supporting.copyWith( + color: ColorManager.secondaryText(context), + ), + ), + ], + ], + ), + ), + ], + ); + if (action == null) return identity; + return LayoutBuilder( + builder: (context, constraints) { + final textScale = MediaQuery.textScalerOf(context).scale(16) / 16; + if (constraints.maxWidth < 280 * textScale) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [identity, const SizedBox(height: 12), action!], + ); + } + return Row( + children: [ + Expanded(child: identity), + const SizedBox(width: 16), + ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth / 2), + child: action!, + ), + ], + ); + }, + ); + } } diff --git a/lib/pages/shared/widgets/setting_row.dart b/lib/pages/shared/widgets/setting_row.dart index a9b0a215e..f410e21aa 100644 --- a/lib/pages/shared/widgets/setting_row.dart +++ b/lib/pages/shared/widgets/setting_row.dart @@ -26,7 +26,7 @@ class SettingSection extends StatelessWidget { this.icon, this.action, required this.children, - this.separated = true, + this.separated = false, this.padding = const EdgeInsetsDirectional.fromSTEB(16, 11, 16, 11), this.descriptionBelow = false, this.dividerIndent = 16, @@ -44,6 +44,7 @@ class SettingSection extends StatelessWidget { descriptionBelow: descriptionBelow, headerInset: headerInset, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: _buildSettingChildren( context, children, @@ -93,7 +94,7 @@ class SettingRow extends StatelessWidget { final bool showChevron; final int titleMaxLines; final int valueMaxLines; - final double minHeight; + final double? minHeight; final EdgeInsetsGeometry contentPadding; final TextStyle? titleStyle; final TextStyle? valueStyle; @@ -116,28 +117,30 @@ class SettingRow extends StatelessWidget { this.showChevron = false, this.titleMaxLines = 2, this.valueMaxLines = 1, - this.minHeight = 62, - this.contentPadding = const EdgeInsetsDirectional.symmetric( - horizontal: 13, - vertical: 10, - ), + this.minHeight, + this.contentPadding = const EdgeInsets.all(12), this.titleStyle, this.valueStyle, this.subtitleStyle, this.titleTrailing, - this.decorateLeading = true, + this.decorateLeading = false, this.valueTextDirection, this.titleTextDirection, }); @override Widget build(BuildContext context) { + final rowHeight = + minHeight ?? + (MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 56 + : 48); final content = ConstrainedBox( constraints: BoxConstraints( minHeight: onTap == null - ? minHeight + ? rowHeight : AppLayout.touchExtent( - minHeight, + rowHeight, platform: Theme.of(context).platform, ), ), @@ -162,21 +165,21 @@ class SettingRow extends StatelessWidget { ) else IconTheme.merge(data: _iconTheme(context), child: leading!), - const SizedBox(width: 11), + const SizedBox(width: 12), ], Expanded(flex: value == null ? 1 : 5, child: _title(context)), if (value != null) ...[ - const SizedBox(width: 11), + const SizedBox(width: 12), Expanded(flex: 4, child: _value(context)), ], if (trailing != null) ...[ - const SizedBox(width: 7), + const SizedBox(width: 12), IconTheme.merge(data: _iconTheme(context), child: trailing!), ] else if (showChevron) ...[ - const SizedBox(width: 7), + const SizedBox(width: 12), IconTheme.merge( data: _iconTheme(context), - child: const Icon(LucideIcons.chevronRightDir), + child: const Icon(LucideIcons.chevronRightDir, size: 18), ), ], ], @@ -188,7 +191,11 @@ class SettingRow extends StatelessWidget { } return Material( type: MaterialType.transparency, - child: InkWell(onTap: onTap, child: content), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadii.control), + child: content, + ), ); } @@ -231,7 +238,7 @@ class SettingRow extends StatelessWidget { ], ), if (subtitle != null) ...[ - const SizedBox(height: 3), + const SizedBox(height: 4), Text( subtitle!, maxLines: 2, @@ -255,7 +262,7 @@ class SettingRow extends StatelessWidget { ? TextAlign.right : TextAlign.left, textDirection: valueTextDirection, - style: AppTypography.code + style: AppTypography.rowValue .copyWith(color: ColorManager.secondaryText(context)) .merge(valueStyle), ); diff --git a/lib/pages/theme/font.dart b/lib/pages/theme/font.dart index bf1668917..9e094273e 100644 --- a/lib/pages/theme/font.dart +++ b/lib/pages/theme/font.dart @@ -34,6 +34,10 @@ abstract final class AppTypography { static final control = _style(14, 600); static final navigationLabel = _style(14, 500); static final selectedNavigationLabel = _style(14, 600); + static final sidebarTagline = _style(12); + static final sidebarGroupLabel = _style(11, 500); + static final mobileNavigationLabel = _style(11); + static final selectedMobileNavigationLabel = _style(11, 600); static final badge = _style(13, 500); static final code = _style(12); static final metric = _style(22, 500); diff --git a/lib/pages/theme/layout.dart b/lib/pages/theme/layout.dart index 9b71cf195..dbafe128d 100644 --- a/lib/pages/theme/layout.dart +++ b/lib/pages/theme/layout.dart @@ -69,16 +69,13 @@ abstract final class AppSpacing { static const page = 28.0; static const desktopPageTop = 24.0; static const desktopPageBottom = 42.0; - static const sidebarHorizontal = 17.0; - static const sidebarVertical = 36.0; - static const macOSSidebarVertical = 16.0; - static const macOSSidebarBrandBottom = 24.0; - static const sidebarBrandStart = 24.0; - static const sidebarCompactBrandStart = 14.0; - static const sidebarBrandTop = 5.0; - static const sidebarBrandBottom = 40.0; - static const sidebarRowHeight = 50.0; - static const sidebarRowGap = 7.0; + static const sidebarHorizontal = 16.0; + static const sidebarVertical = 28.0; + static const sidebarBottom = 16.0; + static const sidebarBrandStart = 6.0; + static const sidebarBrandBottom = 25.0; + static const sidebarRowHeight = 46.0; + static const sidebarRowGap = 5.0; static const mobilePage = 16.0; static const mobileHeaderHorizontal = 16.0; static const connectPageHorizontal = 15.0; diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index d566e6b8b..bbc2c1a98 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui' show SemanticsAction; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -11,6 +12,8 @@ import 'package:onexray/pages/main/dialog_page.dart'; import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/main/url.dart'; import 'package:onexray/core/tools/platform.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; @@ -200,6 +203,17 @@ void main() { ); expect(find.text('connect-content'), findsOneWidget); expect(find.text('OneXray'), findsOneWidget); + final sidebarLocalizations = AppLocalizations.of( + tester.element(desktopNavigation), + )!; + expect(find.text(sidebarLocalizations.navigationTagline), findsOneWidget); + expect(find.text(sidebarLocalizations.navigationWorkspace), findsOneWidget); + expect( + find.text(sidebarLocalizations.navigationSystemAndCore), + findsOneWidget, + ); + final palette = ColorManager.palette(tester.element(desktopNavigation)); + expect(tester.widget(desktopNavigation).color, palette.sidebar); expect( tester.getSize(desktopNavigation).width, AppLayout.desktopSidebarWidth, @@ -211,10 +225,35 @@ void main() { tester.widget(desktopConnect).properties.selected, isTrue, ); - expect(tester.getSize(desktopConnect).height, AppSpacing.sidebarRowHeight); + expect( + tester.getSize(desktopConnect).height, + AppLayout.touchExtent( + AppSpacing.sidebarRowHeight, + platform: Theme.of(tester.element(desktopConnect)).platform, + ), + ); expect( tester.getSize(desktopConnect).width, - AppLayout.desktopSidebarWidth - AppSpacing.sidebarHorizontal * 2 - 1, + AppLayout.desktopSidebarWidth - AppSpacing.sidebarHorizontal * 2, + ); + final selectedDesktopSurface = tester.widget( + find + .descendant(of: desktopConnect, matching: find.byType(Material)) + .first, + ); + expect(selectedDesktopSurface.color, palette.card); + expect( + selectedDesktopSurface.borderRadius, + BorderRadius.circular(AppRadii.control), + ); + expect(selectedDesktopSurface.shape, isNull); + expect( + tester + .widget( + find.descendant(of: desktopConnect, matching: find.byType(Icon)), + ) + .size, + 18, ); router.go('/servers/details'); await tester.pumpAndSettle(); @@ -257,21 +296,49 @@ void main() { const ValueKey('primary-navigation-connect'), ); expect(navigation, findsOneWidget); - final navigationBar = tester.widget( - find.byType(NavigationBar), + final navigationCard = tester.widget(navigation); + expect(navigationCard.margin, const EdgeInsets.fromLTRB(12, 0, 12, 10)); + expect(navigationCard.padding, const EdgeInsets.all(6)); + final decoration = navigationCard.decoration! as BoxDecoration; + expect(decoration.color, palette.card); + expect(decoration.borderRadius, BorderRadius.circular(22)); + expect( + find.descendant(of: navigation, matching: find.byType(InkWell)), + findsNWidgets(5), + ); + expect( + find.byKey(const ValueKey('primary-navigation-settings')), + findsNothing, ); - expect(navigationBar.height, isNull); - expect(navigationBar.selectedIndex, AppPrimaryDestination.connect.index); - expect(navigationBar.destinations, hasLength(5)); expect( - tester.widget(connectDestination).label, + tester.widget(connectDestination).properties.label, 'Connect', ); + expect( + tester.widget(connectDestination).properties.selected, + isTrue, + ); + expect(tester.getSize(connectDestination).height, 56); + final selectedMobileSurface = tester.widget( + find + .descendant(of: connectDestination, matching: find.byType(Material)) + .first, + ); + expect(selectedMobileSurface.color, palette.primarySolid); + final mobileLabel = tester.widget( + find.descendant(of: connectDestination, matching: find.byType(Text)), + ); + expect(mobileLabel.style!.fontSize, 11); + expect(mobileLabel.style!.color, palette.primaryForeground); + expect( + mobileLabel.style!.fontWeight, + AppTypography.selectedMobileNavigationLabel.fontWeight, + ); expect(find.byType(Badge), findsNothing); router.go('/settings'); await tester.pumpAndSettle(); expect(router.routeInformationProvider.value.uri.path, '/settings'); - expect(find.byType(NavigationBar), findsNothing); + expect(navigation, findsNothing); for (final path in ['/settings', '/settings/details']) { router.go(path); await tester.pumpAndSettle(); @@ -355,6 +422,161 @@ void main() { expect(router.routeInformationProvider.value.uri.path, '/connect'); }); + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + for (final (scale, locale, brightness) in const [ + (1.0, Locale('en'), Brightness.light), + (2.0, Locale('ru'), Brightness.light), + (2.0, Locale('fa'), Brightness.dark), + ]) { + testWidgets( + 'inset mobile navigation stays reachable on $platform in $locale at $scale', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 360)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final eventBus = AppEventBus(); + addTearDown(eventBus.close); + final router = _navigationFixture(); + addTearDown(router.dispose); + const padding = EdgeInsets.only(top: 24, bottom: 21); + await tester.pumpWidget( + BlocProvider.value( + value: eventBus, + child: MaterialApp.router( + theme: AppTheme.material(brightness, mobile: true), + locale: locale, + routerConfig: router, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + padding: padding, + viewPadding: padding, + ), + child: child!, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final navigation = find.byKey( + const ValueKey('primary-mobile-navigation'), + ); + final palette = ColorManager.palette(tester.element(navigation)); + final rect = tester.getRect(navigation); + expect(rect.bottom, lessThanOrEqualTo(360 - padding.bottom)); + expect(rect.top, greaterThanOrEqualTo(padding.top)); + expect( + Directionality.of(tester.element(navigation)), + locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, + ); + final destinations = AppPrimaryDestination.values.where( + (primary) => primary != AppPrimaryDestination.settings, + ); + for (final primary in destinations) { + final destination = find.byKey( + ValueKey('primary-navigation-${primary.name}'), + ); + final size = tester.getSize(destination); + expect( + size.width, + greaterThanOrEqualTo( + AppLayout.touchExtent(0, platform: platform), + ), + ); + expect(size.height, greaterThanOrEqualTo(56)); + expect( + size.height, + closeTo( + tester + .getSize( + find.byKey(const ValueKey('primary-navigation-connect')), + ) + .height, + .01, + ), + ); + final labelRect = tester.getRect( + find.descendant(of: destination, matching: find.byType(Text)), + ); + final iconRect = tester.getRect( + find.descendant(of: destination, matching: find.byType(Icon)), + ); + final destinationRect = tester.getRect(destination); + expect( + size.height, + greaterThanOrEqualTo( + iconRect.height + 5 + labelRect.height + 12 - .01, + ), + ); + expect(labelRect.left, greaterThanOrEqualTo(destinationRect.left)); + expect(labelRect.right, lessThanOrEqualTo(destinationRect.right)); + expect( + labelRect.bottom, + lessThanOrEqualTo(destinationRect.bottom - 6 + .01), + ); + final semantics = tester.getSemantics(destination); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + semantics.owner!.performAction(semantics.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect( + router.routeInformationProvider.value.uri.path, + primary.rootPath, + ); + expect(find.text('${primary.name}-content'), findsOneWidget); + expect( + tester.widget(destination).properties.selected, + isTrue, + ); + final surface = tester.widget( + find + .descendant(of: destination, matching: find.byType(Material)) + .first, + ); + expect(surface.color, palette.primarySolid); + await tester.tap(destination); + await tester.pumpAndSettle(); + expect( + router.routeInformationProvider.value.uri.path, + primary.rootPath, + ); + expect(tester.takeException(), isNull); + } + final destinationHeight = tester + .getSize(find.byKey(const ValueKey('primary-navigation-connect'))) + .height; + expect( + destinationHeight, + scale > 1 ? greaterThan(56) : greaterThanOrEqualTo(56), + ); + if (locale.languageCode == 'fa') { + expect( + tester + .getCenter( + find.byKey(const ValueKey('primary-navigation-connect')), + ) + .dx, + greaterThan( + tester + .getCenter( + find.byKey(const ValueKey('primary-navigation-xray')), + ) + .dx, + ), + ); + } + expect(tester.takeException(), isNull); + }, + semanticsEnabled: true, + variant: TargetPlatformVariant.only(platform), + ); + } + } + for (final (scale, locale, landscapePadding) in const [ (1.0, Locale('en'), EdgeInsets.only(left: 59, bottom: 21)), (2.0, Locale('ru'), EdgeInsets.only(left: 59, bottom: 21)), @@ -508,7 +730,10 @@ void main() { await tester.binding.setSurfaceSize(const Size(426.7, 952)); await tester.pumpAndSettle(); expect(sidebar, findsNothing); - expect(find.byType(NavigationBar), findsNothing); + expect( + find.byKey(const ValueKey('primary-mobile-navigation')), + findsNothing, + ); expect(find.text('settings-details'), findsOneWidget); expect( GoRouterState.of(tester.element(find.text('settings-details'))) @@ -630,7 +855,10 @@ void main() { expect(rootKey.currentState!.canPop(), isFalse); expect(find.byType(BackButton), findsOneWidget); if (width < AppLayout.mobileBreakpoint) { - expect(find.byType(NavigationBar), findsNothing); + expect( + find.byKey(const ValueKey('primary-mobile-navigation')), + findsNothing, + ); } } while (stack.length > 1) { @@ -823,13 +1051,19 @@ void main() { primary.rootPath, ); if (primary == AppPrimaryDestination.settings) { - expect(find.byType(NavigationBar), findsNothing); + expect( + find.byKey(const ValueKey('primary-mobile-navigation')), + findsNothing, + ); } else { expect( tester - .widget(find.byType(NavigationBar)) - .selectedIndex, - primary.index, + .widget( + find.byKey(ValueKey('primary-navigation-${primary.name}')), + ) + .properties + .selected, + isTrue, ); } expect(tester.takeException(), isNull); @@ -838,3 +1072,28 @@ void main() { variant: TargetPlatformVariant.only(TargetPlatform.iOS), ); } + +GoRouter _navigationFixture() => GoRouter( + initialLocation: AppPrimaryDestination.connect.rootPath, + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => AdaptiveMainShell( + navigationShell: shell, + hasActiveModal: (_) => false, + initializeServices: (_) async {}, + ), + branches: [ + for (final primary in AppPrimaryDestination.values) + StatefulShellBranch( + routes: [ + GoRoute( + path: primary.rootPath, + builder: (_, _) => + Center(child: Text('${primary.name}-content')), + ), + ], + ), + ], + ), + ], +); diff --git a/test/pages/settings/appearance_startup_update_test.dart b/test/pages/settings/appearance_startup_update_test.dart index e71eb7e80..3e4c8932a 100644 --- a/test/pages/settings/appearance_startup_update_test.dart +++ b/test/pages/settings/appearance_startup_update_test.dart @@ -121,7 +121,7 @@ void main() { }); for (final (locale, width, scale, vertical) in const [ - (Locale('en'), 390.0, 1.0, false), + (Locale('en'), 390.0, 1.0, true), (Locale('en'), 390.0, 2.0, true), (Locale('ru'), 390.0, 2.0, true), (Locale('en'), 834.0, 2.0, false), diff --git a/test/pages/shared/widgets/section_test.dart b/test/pages/shared/widgets/section_test.dart index 422e9d029..7753b4643 100644 --- a/test/pages/shared/widgets/section_test.dart +++ b/test/pages/shared/widgets/section_test.dart @@ -1,9 +1,352 @@ +import 'package:flutter/rendering.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/pages/shared/widgets/section.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/theme.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { + testWidgets('header, description, action and body share one card', ( + tester, + ) async { + var actions = 0; + const body = ValueKey('section-body'); + await _pumpSection( + tester, + SectionView( + title: 'Connection resources', + description: 'Resources saved on this device', + padding: EdgeInsets.zero, + action: OutlinedButton( + onPressed: () => actions++, + child: const Text('Manage resources'), + ), + child: const SizedBox(key: body, height: 32, width: double.infinity), + ), + ); + + final card = find.byType(ShadCard); + expect(card, findsOneWidget); + for (final content in [ + find.text('Connection resources'), + find.text('Resources saved on this device'), + find.text('Manage resources'), + find.byKey(body), + ]) { + expect(find.descendant(of: card, matching: content), findsOneWidget); + } + expect( + find.ancestor( + of: find.text('Connection resources'), + matching: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.header == true, + ), + ), + findsOneWidget, + ); + expect(find.byType(Divider), findsNothing); + expect( + _cardInnerRect(tester).right - + tester + .getRect(find.widgetWithText(OutlinedButton, 'Manage resources')) + .right, + closeTo(18, .01), + ); + await tester.tap(find.text('Manage resources')); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('header and body use their own prototype insets', (tester) async { + const body = ValueKey('inset-body'); + await _pumpSection( + tester, + const SectionView( + title: 'Title', + description: 'Description', + padding: EdgeInsets.zero, + child: SizedBox(key: body, height: 32, width: double.infinity), + ), + ); + + final inner = _cardInnerRect(tester); + final title = tester.getRect(find.text('Title')); + final description = tester.getRect(find.text('Description')); + final content = tester.getRect(find.byKey(body)); + expect(title.left - inner.left, closeTo(18, .01)); + expect(title.top - inner.top, closeTo(16, .01)); + expect(description.top - title.bottom, closeTo(4, .01)); + expect(content.top - description.bottom, closeTo(16, .01)); + expect(content.left - inner.left, closeTo(18, .01)); + expect(inner.right - content.right, closeTo(18, .01)); + expect(inner.bottom - content.bottom, closeTo(18, .01)); + expect(tester.takeException(), isNull); + }); + + testWidgets('footer remains outside the card and empty headers add no gap', ( + tester, + ) async { + const body = ValueKey('headerless-body'); + await _pumpSection( + tester, + const SectionView( + title: '', + description: 'Applies to the next connection', + descriptionBelow: true, + padding: EdgeInsets.zero, + child: SizedBox(key: body, height: 32, width: double.infinity), + ), + ); + final card = find.byType(ShadCard); + final footer = find.text('Applies to the next connection'); + expect(find.descendant(of: card, matching: footer), findsNothing); + expect( + tester.getRect(footer).top, + greaterThan(tester.getRect(card).bottom), + ); + final inner = _cardInnerRect(tester); + final content = tester.getRect(find.byKey(body)); + expect(content.top - inner.top, closeTo(18, .01)); + expect(inner.bottom - content.bottom, closeTo(18, .01)); + expect( + find.descendant( + of: card, + matching: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.header == true, + ), + ), + findsNothing, + ); + + await _pumpSection( + tester, + const SectionView( + title: '', + padding: EdgeInsets.zero, + child: SizedBox(key: body, height: 32, width: double.infinity), + ), + ); + expect(find.byType(Text), findsNothing); + expect( + tester.getRect(find.byKey(body)).top - _cardInnerRect(tester).top, + closeTo(18, .01), + ); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'action wraps below the identity at the internal width boundary', + (tester) async { + for (final (width, stacked) in [(317.0, true), (318.0, false)]) { + await _pumpSection( + tester, + SectionView( + title: 'Title', + padding: EdgeInsets.zero, + action: OutlinedButton(onPressed: () {}, child: const Text('Edit')), + child: const SizedBox(height: 20, width: double.infinity), + ), + width: width, + ); + // ShadCard's 1px border and the two 18px header insets leave 279/280px. + expect( + _cardInnerRect(tester).width - 36, + closeTo(stacked ? 279 : 280, .01), + ); + final title = tester.getRect(find.text('Title')); + final action = tester.getRect( + find.widgetWithText(OutlinedButton, 'Edit'), + ); + if (stacked) { + expect(action.top - title.bottom, closeTo(12, .01)); + } else { + expect(action.top, lessThan(title.bottom)); + expect(action.left, greaterThan(title.right)); + expect(_cardInnerRect(tester).right - action.right, closeTo(18, .01)); + } + expect(tester.takeException(), isNull); + } + }, + ); + + for (final direction in TextDirection.values) { + testWidgets( + 'large text keeps long header and action reachable in $direction', + (tester) async { + var actions = 0; + const title = 'Настройки соединения и сохранённые ресурсы'; + const description = 'إعدادات الاتصال والموارد المحفوظة على هذا الجهاز'; + const actionLabel = 'Управление'; + await _pumpSection( + tester, + SectionView( + title: title, + description: description, + padding: EdgeInsets.zero, + action: OutlinedButton( + onPressed: () => actions++, + child: const Text(actionLabel), + ), + child: const Text('Body'), + ), + width: 390, + scale: 2, + direction: direction, + ); + final inner = _cardInnerRect(tester); + final action = find.widgetWithText(OutlinedButton, actionLabel); + expect( + tester.getRect(action).top, + greaterThanOrEqualTo( + tester.getRect(find.text(description)).bottom + 12, + ), + ); + for (final finder in [ + find.text(title), + find.text(description), + action, + ]) { + final bounds = tester.getRect(finder); + expect(bounds.left, greaterThanOrEqualTo(inner.left)); + expect(bounds.right, lessThanOrEqualTo(inner.right)); + } + _expectCompleteText(tester, find.text(title)); + _expectCompleteText(tester, find.text(description)); + _expectCompleteText(tester, find.text(actionLabel)); + await tester.ensureVisible(action); + await tester.pumpAndSettle(); + expect(action.hitTestable(), findsOneWidget); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('sections separate rows only when explicitly requested', ( + tester, + ) async { + for (final separated in [false, true]) { + await _pumpSection( + tester, + separated + ? const SettingSection( + title: 'Explicit separators', + separated: true, + children: [ + SettingRow(title: 'One'), + SettingRow(title: 'Two'), + ], + ) + : const SettingSection( + title: 'Default rows', + children: [ + SettingRow(title: 'One'), + SettingRow(title: 'Two'), + ], + ), + ); + expect(find.byType(Divider), separated ? findsOneWidget : findsNothing); + expect(tester.takeException(), isNull); + } + }); + + testWidgets('rows use plain leading icons and 12px padding unless opted in', ( + tester, + ) async { + const leading = ValueKey('leading-icon'); + const trailing = ValueKey('trailing-icon'); + Rect? plainIcon; + for (final decorate in [false, true]) { + await _pumpSection( + tester, + decorate + ? const SettingRow( + title: 'Decorated', + decorateLeading: true, + leading: Icon(LucideIcons.settings, key: leading, size: 20), + trailing: Icon( + LucideIcons.chevronRight, + key: trailing, + size: 20, + ), + ) + : const SettingRow( + title: 'Plain', + leading: Icon(LucideIcons.settings, key: leading, size: 20), + trailing: Icon( + LucideIcons.chevronRight, + key: trailing, + size: 20, + ), + ), + ); + final row = find.byType(SettingRow); + expect( + tester + .widget(row) + .contentPadding + .resolve(TextDirection.ltr), + const EdgeInsets.all(12), + ); + final rowBounds = tester.getRect(row); + final icon = tester.getRect(find.byKey(leading)); + expect( + rowBounds.right - tester.getRect(find.byKey(trailing)).right, + closeTo(12, .01), + ); + if (decorate) { + expect(icon.left, greaterThan(plainIcon!.left)); + } else { + plainIcon = icon; + expect(icon.left - rowBounds.left, closeTo(12, .01)); + expect( + tester.getRect(find.text('Plain')).left - icon.right, + closeTo(12, .01), + ); + } + expect(tester.takeException(), isNull); + } + }); + + testWidgets( + 'a switch inside the card activates once from either hit target', + (tester) async { + var value = false; + final changes = []; + await _pumpSection( + tester, + StatefulBuilder( + builder: (context, setState) => SettingSection( + title: 'Controls', + children: [ + SwitchSettingRow( + title: 'Enable feature', + value: value, + onChanged: (next) => setState(() { + value = next; + changes.add(next); + }), + ), + ], + ), + ), + ); + await tester.tap(find.text('Enable feature')); + await tester.pumpAndSettle(); + expect(changes, [true]); + expect(tester.widget(find.byType(ShadSwitch)).value, isTrue); + await tester.tap(find.byType(ShadSwitch)); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + expect(tester.widget(find.byType(ShadSwitch)).value, isFalse); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('setting section exposes Material ink for native switch rows', ( tester, ) async { @@ -36,3 +379,91 @@ void main() { expect(tester.takeException(), isNull); }); } + +Future _pumpSection( + WidgetTester tester, + Widget section, { + double width = 600, + double scale = 1, + TextDirection direction = TextDirection.ltr, +}) async { + await tester.binding.setSurfaceSize(const Size(1000, 1200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final scaler = TextScaler.linear(scale); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: width < 720), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: scaler), + child: ShadTheme( + data: AppTheme.shad( + Brightness.light, + mobile: width < 720, + textScaler: scaler, + ), + child: Directionality(textDirection: direction, child: child!), + ), + ), + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: SingleChildScrollView(child: section), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Rect _cardInnerRect(WidgetTester tester) { + final card = find.byType(ShadCard); + final container = find + .descendant( + of: card, + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.decoration is BoxDecoration && + (widget.decoration! as BoxDecoration).border != null, + ), + ) + .first; + final decoration = + tester.widget(container).decoration! as BoxDecoration; + final border = decoration.border!.dimensions.resolve(TextDirection.ltr); + return border.deflateRect(tester.getRect(card)); +} + +void _expectCompleteText(WidgetTester tester, Finder finder) { + final richText = find.descendant(of: finder, matching: find.byType(RichText)); + final paragraph = tester.renderObject(richText); + expect(paragraph.didExceedMaxLines, isFalse); + final text = paragraph.text.toPlainText(); + // Selection boxes include invisible trailing spaces beyond the paint bounds. + // Check every non-whitespace run so real glyph clipping still fails. + for (final word in RegExp(r'\S+', unicode: true).allMatches(text)) { + final diagnostic = + 'word=${word.group(0)} text=$text ' + 'size=${paragraph.size} constraints=${paragraph.constraints} ' + 'scaler=${tester.widget(richText).textScaler}'; + for (final box in paragraph.getBoxesForSelection( + TextSelection(baseOffset: word.start, extentOffset: word.end), + )) { + expect(box.left, greaterThanOrEqualTo(-.5), reason: diagnostic); + expect( + box.right, + lessThanOrEqualTo(paragraph.size.width + .5), + reason: diagnostic, + ); + expect(box.top, greaterThanOrEqualTo(-.5), reason: diagnostic); + expect( + box.bottom, + lessThanOrEqualTo(paragraph.size.height + .5), + reason: diagnostic, + ); + } + } +} From 09b06597168b020d3aa98c61249d895a6764910b Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 01:05:38 +0800 Subject: [PATCH 18/60] style(ui): align pages with approved prototype across layouts --- docs/app.md | 11 +- lib/l10n/app_en.arb | 26 +- lib/l10n/app_fa.arb | 26 +- lib/l10n/app_ru.arb | 26 +- lib/l10n/app_zh.arb | 26 +- lib/l10n/app_zh_Hant.arb | 26 +- lib/pages/advanced/local_api/page.dart | 84 ++- lib/pages/advanced/tunnel/android/apps.dart | 49 +- .../tunnel/android/automation/page.dart | 31 +- lib/pages/advanced/tunnel/android/page.dart | 265 +++++---- lib/pages/advanced/tunnel/apple/page.dart | 91 +-- lib/pages/advanced/tunnel/apple/widgets.dart | 129 ++-- lib/pages/advanced/tunnel/apple/wifi.dart | 11 +- lib/pages/advanced/tunnel/controller.dart | 2 +- lib/pages/advanced/tunnel/details.dart | 160 +++++ .../advanced/tunnel/excluded_networks.dart | 2 +- lib/pages/advanced/tunnel/interface/page.dart | 37 +- lib/pages/advanced/tunnel/page.dart | 399 +++++-------- lib/pages/advanced/tunnel/widgets.dart | 49 +- lib/pages/advanced/tunnel/windows/page.dart | 119 ++-- lib/pages/advanced/xray/config/page.dart | 3 +- lib/pages/advanced/xray/controller.dart | 26 +- lib/pages/advanced/xray/data_update/page.dart | 35 +- lib/pages/advanced/xray/diagnostics.dart | 15 +- lib/pages/advanced/xray/dialing/page.dart | 16 +- lib/pages/advanced/xray/geodata/add.dart | 180 ++++++ lib/pages/advanced/xray/geodata/detail.dart | 34 +- lib/pages/advanced/xray/geodata/page.dart | 377 ++++-------- lib/pages/advanced/xray/geodata/view.dart | 371 +++--------- lib/pages/advanced/xray/lan_proxy/page.dart | 30 +- lib/pages/advanced/xray/log/page.dart | 5 +- lib/pages/advanced/xray/log/settings.dart | 234 ++++++++ lib/pages/advanced/xray/page.dart | 285 ++------- lib/pages/advanced/xray/ping/page.dart | 47 +- .../advanced/xray/runtime_code_view.dart | 13 +- lib/pages/connect/json_editor/page.dart | 32 +- lib/pages/connect/page.dart | 3 +- lib/pages/connect/routing/custom/page.dart | 19 +- .../connect/routing/custom/rule_page.dart | 20 +- lib/pages/connect/routing/options_form.dart | 9 +- lib/pages/connect/routing/page.dart | 249 ++++---- .../connect/routing/smart/exit_picker.dart | 59 +- lib/pages/connect/routing/smart/page.dart | 5 +- lib/pages/connect/routing/smart/regions.dart | 20 +- lib/pages/connect/routing/widgets.dart | 100 ++-- lib/pages/connect/target_picker.dart | 158 +++-- lib/pages/connect/view.dart | 386 ++++++------ lib/pages/launch/setup/view.dart | 389 ++---------- lib/pages/launch/setup/widgets.dart | 6 +- lib/pages/main/adaptive_shell.dart | 30 +- lib/pages/main/advanced.dart | 10 +- lib/pages/main/navigation.dart | 4 + lib/pages/main/url.dart | 32 +- lib/pages/servers/details.dart | 42 +- lib/pages/servers/editor/page.dart | 10 +- lib/pages/servers/form_page.dart | 26 +- lib/pages/servers/import/page.dart | 105 ++-- lib/pages/servers/order/page.dart | 181 +++--- lib/pages/servers/page.dart | 36 +- lib/pages/servers/sources.dart | 152 +++-- lib/pages/servers/subscription/form_view.dart | 48 +- lib/pages/servers/view.dart | 555 +++++++++++------- lib/pages/settings/about/page.dart | 116 +--- lib/pages/settings/app_icon/page.dart | 171 +----- lib/pages/settings/app_update/dialog.dart | 43 +- lib/pages/settings/backup/page.dart | 169 +++--- lib/pages/settings/controller.dart | 4 +- lib/pages/settings/desktop/page.dart | 235 +++++--- lib/pages/settings/language/page.dart | 137 ++--- lib/pages/settings/page.dart | 461 ++------------- lib/pages/settings/theme/page.dart | 59 +- lib/pages/settings/widgets.dart | 156 ++++- lib/pages/shared/alert.dart | 226 +++++-- lib/pages/shared/share/page.dart | 115 ++-- lib/pages/shared/widgets/adaptive_dialog.dart | 344 ++++++----- .../widgets/configuration_transfer.dart | 18 +- lib/pages/shared/widgets/dns_text_field.dart | 48 +- lib/pages/shared/widgets/json_editor.dart | 17 +- lib/pages/shared/widgets/page_app_bar.dart | 406 ++++++++++++- lib/pages/shared/widgets/settings_page.dart | 355 +++++------ lib/pages/theme/font.dart | 281 ++++----- .../advanced/tunnel/apple/page_test.dart | 8 +- .../tunnel/connection_status_test.dart | 10 + .../advanced/tunnel/dns_fields_test.dart | 84 ++- .../pages/advanced/tunnel/root_page_test.dart | 365 ++++++++++++ .../tunnel/windows_interface_test.dart | 33 +- .../advanced/xray/geodata/add_page_test.dart | 178 ++++++ .../advanced/xray/geodata/rows_test.dart | 21 +- test/pages/advanced/xray/log_test.dart | 134 ++++- .../advanced/xray/runtime_viewer_test.dart | 14 +- test/pages/connect/controller_test.dart | 18 +- test/pages/connect/dialogs_test.dart | 21 +- .../pages/connect/routing/custom_ui_test.dart | 121 ++-- .../pages/connect/routing/root_page_test.dart | 28 +- .../routing/smart/exit_picker_test.dart | 9 +- test/pages/connect/target_picker_test.dart | 69 +++ test/pages/connect/view_test.dart | 13 +- test/pages/launch/setup_view_test.dart | 34 +- test/pages/main/page_app_bar_test.dart | 275 ++++++++- test/pages/servers/manual_groups_test.dart | 6 +- test/pages/servers/menus_test.dart | 25 +- .../appearance_startup_update_test.dart | 213 ++++++- test/pages/settings/backup_test.dart | 29 +- test/pages/settings/cleanup_test.dart | 5 +- test/pages/settings/details_test.dart | 126 ++-- test/pages/shared/alert_test.dart | 63 ++ .../shared/widgets/adaptive_dialog_test.dart | 135 ++++- .../widgets/app_confirmation_dialog_test.dart | 101 +++- test/pages/theme/theme_test.dart | 3 +- 109 files changed, 6570 insertions(+), 4858 deletions(-) create mode 100644 lib/pages/advanced/tunnel/details.dart create mode 100644 lib/pages/advanced/xray/geodata/add.dart create mode 100644 lib/pages/advanced/xray/log/settings.dart create mode 100644 test/pages/advanced/tunnel/root_page_test.dart create mode 100644 test/pages/advanced/xray/geodata/add_page_test.dart diff --git a/docs/app.md b/docs/app.md index 568ea1547..9c43f5fc4 100644 --- a/docs/app.md +++ b/docs/app.md @@ -29,6 +29,9 @@ 颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。 App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。 +业务页使用公共顶部工具栏及页标题区:手机显示品牌,宽屏显示当前工作区路径; +页标题为手机24、宽屏26,副文14。主要页面动作在标题旁,窄屏自然下排。 +长资源名和大字号下标题区可独立滚动,保留正文和固定操作区的可用空间,不缩小字号或截断资源身份。 字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 @@ -193,6 +196,8 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 VPN 隧道有独立主入口,公共参数与平台策略沿一条内容流呈现,平台配置打开全宽详情。 公共页和子页继续使用同一策略草稿、字段诊断和固定操作栏;恢复默认不保存,取消不写入, 只有当前平台的有效变化经原服务确认后才重连。 +首页以系统接管状态与连接参数摘要组织导航,DNS 和 IPv6 在各自详情编辑并保存。 +恢复全部隧道默认仍作用于完整策略草稿,尚未提交时才显示根页保存操作;它不等同于只重置 DNS。 - 隧道页展示只读地址,可编辑 IPv4/IPv6 DNS 和 Apple DoT 服务器域名,IPv6 使用独立开关。 Windows/Linux 才显示出口网卡,必须明确选择。 @@ -223,7 +228,7 @@ Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用 后台 saved-start 和平台限制见 [LAN 共享](xray-configuration.md#局域网代理共享)。 桌面端另有本地 HTTP API,继续沿用即时开关、端口提交与 Token 重置语义;移动端不注册该入口或路由, 服务合同见 [外部接口](external-interfaces.md)。 -System Extension 隐藏整个日志区和对应保存/恢复按钮,诊断和深链同样限制日志访问;其他入口不受影响。 +日志偏好与恢复默认位于独立日志设置页,草稿保存后才生效;Xray 根页不显示日志表单或固定保存按钮。System Extension 隐藏日志入口,独立页面、诊断和深链同样限制日志访问;其他入口不受影响。 日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存; 保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。 @@ -238,8 +243,10 @@ App 没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运 ### 设置 设置以单列分组管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。 -首页主题选项按当前字号、标签实际宽度和可用空间选择横排或纵排,不按语言硬编码,也不缩小系统字号。 +首页按外观与语言、启动与系统、数据、关于分组;启动偏好进入独立页面,沿用即时保存并在返回后刷新摘要。 +首页主题选项按当前字号、标签实际宽度和可用空间自然换行,不按语言硬编码,也不缩小系统字号。 选中状态具有语义标记,点击后立即保存;Apple 图标行空间不足时将当前值移至标题下方自然换行。 +语言及图标详情使用单列选择控件,图标保留预览;独立表单仍在保存前保留草稿,取消不写入。 Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢复见 [备份](backup.md)。 自动备份开关位于分区标题旁,只显示一次标题,并保留开关名称、状态及操作中的禁用和进度反馈。 周期仍在同一分区编辑,Apple 位置选择保留 iCloud 主动作,敏感数据提醒及恢复、覆盖确认保持原规则。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a7462c3aa..f09f09c4f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,26 @@ { + "tunnelIpv6Enabled": "IPv6 enabled", + "tunnelIpv6Disabled": "IPv6 disabled", + "tunnelSystemCapture": "System capture", + "tunnelConnectionParameters": "Connection parameters", + "tunnelCurrentPlatform": "Current platform", + "serversPageDescription": "Manage nodes, full configurations and subscription sources.", + "subscriptionInformationTitle": "Subscription information", + "shareContentTitle": "Share content", + "settingsAppLanguage": "App language", + "settingsDockIcon": "Dock icon", + "settingsHomeScreenIcon": "Home Screen icon", + "settingsManualConnection": "Connect manually", + "settingsAppearanceAndLanguage": "Appearance & language", + "settingsStartupAndSystem": "Startup & system", + "settingsOverviewDescription": "Appearance, startup and data on this device.", + "settingsDevicePreferenceDescription": "Changes apply only to this device.", + "backupPageDescription": "Move connection assets and VPN settings for this platform.", + "pageTunnelDescription": "System capture, tunnel networking and platform policies.", + "pageXrayDescription": "Core runtime, diagnostics and connection resources.", + "pageRoutingDescription": "Choose traffic handling or manage routing profiles.", + "headerSwitchToLight": "Switch to light theme", + "headerSwitchToDark": "Switch to dark theme", "navigationTagline": "Your servers, your connection", "navigationWorkspace": "Workspace", "navigationSystemAndCore": "System & core", @@ -3204,5 +3226,7 @@ "connectionRuntimeTitle": "Active connection", "serverLatencyFailed": "Test failed", "completeConfigurationRoutingTitle": "Configuration routing", - "completeConfigurationRoutingNotice": "Uses routing and DNS from the complete configuration. App routing preferences are kept." + "completeConfigurationRoutingNotice": "Uses routing and DNS from the complete configuration. App routing preferences are kept.", + "xrayLogPreferences": "Recording preferences", + "xrayLogFiles": "Log files" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index fe2d24dde..4dd2add03 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,26 @@ { + "tunnelIpv6Enabled": "IPv6 روشن است", + "tunnelIpv6Disabled": "IPv6 خاموش است", + "tunnelSystemCapture": "کنترل سیستم", + "tunnelConnectionParameters": "پارامترهای اتصال", + "tunnelCurrentPlatform": "پلتفرم فعلی", + "serversPageDescription": "گره‌ها، پیکربندی‌های کامل و منابع اشتراک را مدیریت کنید.", + "subscriptionInformationTitle": "اطلاعات اشتراک", + "shareContentTitle": "محتوای اشتراک‌گذاری", + "settingsAppLanguage": "زبان برنامه", + "settingsDockIcon": "نماد Dock", + "settingsHomeScreenIcon": "نماد صفحه اصلی", + "settingsManualConnection": "اتصال دستی", + "settingsAppearanceAndLanguage": "ظاهر و زبان", + "settingsStartupAndSystem": "راه‌اندازی و سیستم", + "settingsOverviewDescription": "ظاهر، راه‌اندازی و داده‌های این دستگاه.", + "settingsDevicePreferenceDescription": "تغییرات فقط در این دستگاه اعمال می‌شوند.", + "backupPageDescription": "انتقال منابع اتصال و تنظیمات VPN این پلتفرم.", + "pageTunnelDescription": "کنترل سیستم، شبکه تونل و سیاست‌های اتصال پلتفرم.", + "pageXrayDescription": "اجرای هسته، عیب‌یابی و منابع اتصال.", + "pageRoutingDescription": "روش ترافیک را انتخاب کنید یا پیکربندی‌های مسیریابی را مدیریت کنید.", + "headerSwitchToLight": "تغییر به پوسته روشن", + "headerSwitchToDark": "تغییر به پوسته تیره", "navigationTagline": "سرورهای شما، اتصال شما", "navigationWorkspace": "فضای کاری", "navigationSystemAndCore": "سیستم و هسته", @@ -1023,5 +1045,7 @@ "connectionRuntimeTitle": "اتصال فعال", "serverLatencyFailed": "آزمایش ناموفق", "completeConfigurationRoutingTitle": "مسیریابی پیکربندی", - "completeConfigurationRoutingNotice": "از مسیریابی و DNS پیکربندی کامل استفاده می‌شود. تنظیمات مسیریابی عادی حفظ می‌شوند." + "completeConfigurationRoutingNotice": "از مسیریابی و DNS پیکربندی کامل استفاده می‌شود. تنظیمات مسیریابی عادی حفظ می‌شوند.", + "xrayLogPreferences": "تنظیمات ثبت رویداد", + "xrayLogFiles": "فایل‌های گزارش" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3a717acc5..a9ddce5a9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,26 @@ { + "tunnelIpv6Enabled": "IPv6 включён", + "tunnelIpv6Disabled": "IPv6 выключен", + "tunnelSystemCapture": "Системный VPN", + "tunnelConnectionParameters": "Параметры подключения", + "tunnelCurrentPlatform": "Текущая платформа", + "serversPageDescription": "Управляйте узлами, полными конфигурациями и источниками подписок.", + "subscriptionInformationTitle": "Информация о подписке", + "shareContentTitle": "Содержимое для отправки", + "settingsAppLanguage": "Язык приложения", + "settingsDockIcon": "Значок в Dock", + "settingsHomeScreenIcon": "Значок на экране «Домой»", + "settingsManualConnection": "Подключение вручную", + "settingsAppearanceAndLanguage": "Внешний вид и язык", + "settingsStartupAndSystem": "Запуск и система", + "settingsOverviewDescription": "Внешний вид, запуск и данные этого устройства.", + "settingsDevicePreferenceDescription": "Изменения применяются только к этому устройству.", + "backupPageDescription": "Перенос ресурсов подключения и настроек VPN этой платформы.", + "pageTunnelDescription": "Системный VPN, сеть туннеля и правила платформы.", + "pageXrayDescription": "Работа ядра, диагностика и ресурсы подключения.", + "pageRoutingDescription": "Выберите обработку трафика или управляйте профилями маршрутизации.", + "headerSwitchToLight": "Переключить на светлую тему", + "headerSwitchToDark": "Переключить на тёмную тему", "navigationTagline": "Ваши серверы, ваше подключение", "navigationWorkspace": "Рабочая область", "navigationSystemAndCore": "Система и ядро", @@ -1023,5 +1045,7 @@ "connectionRuntimeTitle": "Активное подключение", "serverLatencyFailed": "Ошибка теста", "completeConfigurationRoutingTitle": "Маршруты конфигурации", - "completeConfigurationRoutingNotice": "Используются маршруты и DNS полной конфигурации. Настройки обычной маршрутизации сохраняются." + "completeConfigurationRoutingNotice": "Используются маршруты и DNS полной конфигурации. Настройки обычной маршрутизации сохраняются.", + "xrayLogPreferences": "Параметры записи", + "xrayLogFiles": "Файлы журналов" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index e45b34e49..fce0d0633 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,26 @@ { + "tunnelIpv6Enabled": "IPv6 已开启", + "tunnelIpv6Disabled": "IPv6 已关闭", + "tunnelSystemCapture": "系统接管", + "tunnelConnectionParameters": "连接参数", + "tunnelCurrentPlatform": "当前平台", + "serversPageDescription": "管理节点、完整配置与订阅来源。", + "subscriptionInformationTitle": "订阅信息", + "shareContentTitle": "分享内容", + "settingsAppLanguage": "App 语言", + "settingsDockIcon": "Dock 图标", + "settingsHomeScreenIcon": "主屏幕图标", + "settingsManualConnection": "手动连接", + "settingsAppearanceAndLanguage": "外观与语言", + "settingsStartupAndSystem": "启动与系统", + "settingsOverviewDescription": "外观、启动方式与此设备的数据。", + "settingsDevicePreferenceDescription": "修改仅应用于此设备。", + "backupPageDescription": "迁移连接资产与当前平台的 VPN 设置。", + "pageTunnelDescription": "系统接管、隧道网络与平台连接策略。", + "pageXrayDescription": "内核运行、诊断与连接资源。", + "pageRoutingDescription": "选择流量方式,或独立管理路由配置。", + "headerSwitchToLight": "切换浅色主题", + "headerSwitchToDark": "切换深色主题", "navigationTagline": "你的服务器,你的连接", "navigationWorkspace": "工作区", "navigationSystemAndCore": "系统与内核", @@ -1023,5 +1045,7 @@ "connectionRuntimeTitle": "正在运行", "serverLatencyFailed": "测速失败", "completeConfigurationRoutingTitle": "配置自带路由", - "completeConfigurationRoutingNotice": "使用完整配置内的路由与 DNS,普通路由偏好保持不变。" + "completeConfigurationRoutingNotice": "使用完整配置内的路由与 DNS,普通路由偏好保持不变。", + "xrayLogPreferences": "记录偏好", + "xrayLogFiles": "日志文件" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 54c0b8516..3e33a325c 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,26 @@ { + "tunnelIpv6Enabled": "IPv6 已開啟", + "tunnelIpv6Disabled": "IPv6 已關閉", + "tunnelSystemCapture": "系統接管", + "tunnelConnectionParameters": "連線參數", + "tunnelCurrentPlatform": "目前平台", + "serversPageDescription": "管理節點、完整設定與訂閱來源。", + "subscriptionInformationTitle": "訂閱資訊", + "shareContentTitle": "分享內容", + "settingsAppLanguage": "App 語言", + "settingsDockIcon": "Dock 圖示", + "settingsHomeScreenIcon": "主畫面圖示", + "settingsManualConnection": "手動連線", + "settingsAppearanceAndLanguage": "外觀與語言", + "settingsStartupAndSystem": "啟動與系統", + "settingsOverviewDescription": "外觀、啟動方式與此裝置的資料。", + "settingsDevicePreferenceDescription": "修改僅套用於此裝置。", + "backupPageDescription": "遷移連線資產與目前平台的 VPN 設定。", + "pageTunnelDescription": "系統接管、通道網路與平台連線策略。", + "pageXrayDescription": "核心執行、診斷與連線資源。", + "pageRoutingDescription": "選擇流量方式,或獨立管理路由設定。", + "headerSwitchToLight": "切換淺色主題", + "headerSwitchToDark": "切換深色主題", "navigationTagline": "你的伺服器,你的連線", "navigationWorkspace": "工作區", "navigationSystemAndCore": "系統與核心", @@ -1023,5 +1045,7 @@ "connectionRuntimeTitle": "正在執行", "serverLatencyFailed": "測速失敗", "completeConfigurationRoutingTitle": "設定自帶路由", - "completeConfigurationRoutingNotice": "使用完整設定內的路由與 DNS,一般路由偏好保持不變。" + "completeConfigurationRoutingNotice": "使用完整設定內的路由與 DNS,一般路由偏好保持不變。", + "xrayLogPreferences": "記錄偏好", + "xrayLogFiles": "日誌檔案" } diff --git a/lib/pages/advanced/local_api/page.dart b/lib/pages/advanced/local_api/page.dart index af11cb05c..5be67bf86 100644 --- a/lib/pages/advanced/local_api/page.dart +++ b/lib/pages/advanced/local_api/page.dart @@ -30,12 +30,18 @@ class LocalApiPage extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final compact = width <= AppLayout.mobileBreakpoint; - final gutter = compact ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = compact + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar(title: Text(l.localApiTitle)), + appBar: PageAppBar.forPage( + context, + title: Text(l.localApiTitle), + description: l.localApiSummary, + ), bottomNavigationBar: PageActionBar( maxWidth: AppLayout.advancedMaxWidth, - expandDesktop: true, + expandDesktop: false, horizontalPadding: compact ? null : gutter, children: [ OutlinedButton( @@ -61,12 +67,7 @@ class LocalApiPage extends StatelessWidget { desktopMaxWidth: AppLayout.advancedMaxWidth, padding: EdgeInsets.zero, child: Padding( - padding: EdgeInsets.fromLTRB( - gutter, - compact ? 16 : 48, - gutter, - 24, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -74,8 +75,6 @@ class LocalApiPage extends StatelessWidget { SettingSection( title: l.localApiTitle, icon: LucideIcons.terminal, - description: l.localApiSummary, - descriptionBelow: true, padding: EdgeInsets.zero, children: [ SettingRow( @@ -91,20 +90,40 @@ class LocalApiPage extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(14), - child: TextField( - key: const ValueKey('local-api-port'), - controller: controller.portController, - enabled: !state.busy, - keyboardType: TextInputType.number, - textDirection: TextDirection.ltr, - textAlign: TextAlign.left, - autocorrect: false, - decoration: InputDecoration( - labelText: l.localApiPort, - hintText: '18587', - hintTextDirection: TextDirection.ltr, - ), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Text( + l.localApiPort, + style: AppTypography.fieldLabel + .copyWith( + color: palette.foreground, + ), + ), + const SizedBox(height: 8), + Semantics( + label: l.localApiPort, + child: TextField( + key: const ValueKey('local-api-port'), + controller: controller.portController, + enabled: !state.busy, + keyboardType: TextInputType.number, + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + autocorrect: false, + style: AppTypography.rowValue, + decoration: const InputDecoration( + hintText: '18587', + hintTextDirection: + TextDirection.ltr, + ), + ), + ), + ], ), ), SettingRow( @@ -131,14 +150,14 @@ class LocalApiPage extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l.localApiEndpoint, - style: AppTypography.settingsRow, + style: AppTypography.rowTitle, ), const SizedBox(height: 8), SelectableText( @@ -155,7 +174,7 @@ class LocalApiPage extends StatelessWidget { ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 20), SettingSection( title: l.localApiToken, icon: LucideIcons.keyRound, @@ -164,7 +183,7 @@ class LocalApiPage extends StatelessWidget { messages: [l.localApiTokenHint], ), descriptionBelow: true, - padding: const EdgeInsets.all(14), + padding: EdgeInsets.zero, children: [ Align( alignment: AlignmentDirectional.centerStart, @@ -209,7 +228,7 @@ class LocalApiPage extends StatelessWidget { const SizedBox(height: 24), Text( l.localApiAppRequired, - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: palette.mutedForeground, ), ), @@ -220,8 +239,9 @@ class LocalApiPage extends StatelessWidget { child: Text( state.error!, key: const ValueKey('local-api-error'), - style: AppTypography.settingsDetailNote - .copyWith(color: palette.destructive), + style: AppTypography.supporting.copyWith( + color: palette.destructive, + ), ), ), if (!state.loaded) diff --git a/lib/pages/advanced/tunnel/android/apps.dart b/lib/pages/advanced/tunnel/android/apps.dart index 85d7c2339..90ca0b0b5 100644 --- a/lib/pages/advanced/tunnel/android/apps.dart +++ b/lib/pages/advanced/tunnel/android/apps.dart @@ -138,13 +138,27 @@ class AndroidAppsPage extends StatelessWidget { final rows = controller.visible; final missing = controller.missing; return Scaffold( - appBar: PageAppBar(title: Text(l.prototypeSelectApps)), + appBar: PageAppBar.forPage( + context, + title: Text(l.prototypeSelectApps), + ), body: SafeArea( child: ResponsiveContent( child: CustomScrollView( slivers: [ SliverPadding( - padding: const EdgeInsets.fromLTRB(14, 16, 14, 14), + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 20, + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 16, + ), sliver: SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -166,11 +180,6 @@ class AndroidAppsPage extends StatelessWidget { Semantics( label: l.prototypeSearchInstalledApps, child: ShadInput( - constraints: const BoxConstraints(minHeight: 43), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), gap: 9, style: AppTypography.androidBody, placeholderStyle: AppTypography.androidBody @@ -201,7 +210,18 @@ class AndroidAppsPage extends StatelessWidget { ), ), SliverPadding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 18), + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 0, + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 24, + ), sliver: controller.loading || controller.failed ? SliverToBoxAdapter( child: Padding( @@ -344,19 +364,14 @@ class _AndroidAppRow extends StatelessWidget { final palette = ColorManager.palette(context); return MergeSemantics( child: Material( - type: MaterialType.transparency, + color: selected ? palette.selectedSurface : palette.card, child: InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: 62), - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9), - decoration: BoxDecoration( - border: last - ? null - : Border(bottom: BorderSide(color: palette.border)), - ), + padding: const EdgeInsets.all(12), child: Row( - spacing: 10, + spacing: 12, children: [ SizedBox.square(dimension: 34, child: icon), Expanded( @@ -364,7 +379,7 @@ class _AndroidAppRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(name, style: AppTypography.androidRowTitle), - const SizedBox(height: 3), + const SizedBox(height: 4), Text( description, maxLines: 1, diff --git a/lib/pages/advanced/tunnel/android/automation/page.dart b/lib/pages/advanced/tunnel/android/automation/page.dart index 84a1f50f9..0af72977f 100644 --- a/lib/pages/advanced/tunnel/android/automation/page.dart +++ b/lib/pages/advanced/tunnel/android/automation/page.dart @@ -25,7 +25,8 @@ class AndroidAutomationPage extends StatelessWidget { final controller = context.read(); final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.automationTitle), actions: [ PageHelpButton( @@ -38,10 +39,16 @@ class AndroidAutomationPage extends StatelessWidget { child: state.loading ? const Center(child: CircularProgressIndicator()) : SettingsPageScroll( - padding: const EdgeInsets.fromLTRB( - AppSpacing.mobilePage, - 16, - AppSpacing.mobilePage, + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 20, + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, 24, ), child: Column( @@ -74,7 +81,7 @@ class AndroidAutomationPage extends StatelessWidget { ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 20), if (state.saved!.enabled) ...[ SettingSection( title: l.localApiToken, @@ -85,7 +92,7 @@ class AndroidAutomationPage extends StatelessWidget { separated: false, children: [ Padding( - padding: const EdgeInsets.all(14), + padding: EdgeInsets.zero, child: Align( alignment: AlignmentDirectional.centerStart, child: Wrap( @@ -132,7 +139,7 @@ class AndroidAutomationPage extends StatelessWidget { ), ], ), - const SizedBox(height: 24), + const SizedBox(height: 20), ], ], SettingSection( @@ -142,7 +149,7 @@ class AndroidAutomationPage extends StatelessWidget { separated: false, children: [ Padding( - padding: const EdgeInsets.all(14), + padding: EdgeInsets.zero, child: SizedBox( width: double.infinity, child: SelectableText( @@ -154,7 +161,7 @@ class AndroidAutomationPage extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.fromLTRB(14, 0, 14, 14), + padding: const EdgeInsets.only(top: 12), child: Align( alignment: AlignmentDirectional.centerStart, child: OutlinedButton( @@ -181,7 +188,7 @@ class AndroidAutomationPage extends StatelessWidget { const SizedBox(height: 24), Text( l.automationConfigurationHint, - style: AppTypography.settingsDetailNote, + style: AppTypography.supporting, ), Align( alignment: AlignmentDirectional.centerStart, @@ -199,7 +206,7 @@ class AndroidAutomationPage extends StatelessWidget { Text( state.error!, key: const ValueKey('automation-error'), - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: ColorManager.palette(context).destructive, ), ), diff --git a/lib/pages/advanced/tunnel/android/page.dart b/lib/pages/advanced/tunnel/android/page.dart index 4c9e578f7..7f575280e 100644 --- a/lib/pages/advanced/tunnel/android/page.dart +++ b/lib/pages/advanced/tunnel/android/page.dart @@ -53,145 +53,144 @@ class _AndroidVpnPageState extends State { return PolicyDetailScaffold( title: l.prototypeAndroidSystemVpn, controller: controller, - contentPadding: const EdgeInsets.fromLTRB(14, 12, 14, 18), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Semantics( - header: true, - child: Text( - l.prototypeVpnAppScope, - style: AppTypography.androidTitle, - ), - ), - Text( - l.prototypeChooseAndroidApps, - style: AppTypography.androidBody, - ), - const SizedBox(height: 12), - ShadRadioGroup( - axis: Axis.horizontal, - initialValue: mode, - enabled: !controller.blocked, - onChanged: (value) { - if (value != null) { - controller.update('appScope', value, section: 'android'); - } - }, - items: [ - Container( - width: double.infinity, - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: Column( - children: [ - for (final choice in ['all', 'included', 'excluded']) - _AndroidModeRow( - value: choice, - title: switch (choice) { - 'all' => l.prototypeAllApps, - 'included' => l.prototypeOnlySelectedApps, - _ => l.prototypeAllExceptSelectedApps, - }, - selected: mode == choice, - enabled: !controller.blocked, - onTap: () => controller.update( - 'appScope', - choice, - section: 'android', + contentPadding: EdgeInsets.zero, + body: Padding( + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 20, + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.page, + 24, + ), + child: SettingSection( + title: l.prototypeVpnAppScope, + description: l.prototypeChooseAndroidApps, + padding: EdgeInsets.zero, + children: [ + ShadRadioGroup( + axis: Axis.horizontal, + initialValue: mode, + enabled: !controller.blocked, + onChanged: (value) { + if (value != null) { + controller.update('appScope', value, section: 'android'); + } + }, + items: [ + SizedBox( + width: double.infinity, + child: Column( + children: [ + for (final choice in ['all', 'included', 'excluded']) + _AndroidModeRow( + value: choice, + title: switch (choice) { + 'all' => l.prototypeAllApps, + 'included' => l.prototypeOnlySelectedApps, + _ => l.prototypeAllExceptSelectedApps, + }, + selected: mode == choice, + enabled: !controller.blocked, + onTap: () => controller.update( + 'appScope', + choice, + section: 'android', + ), ), - ), - ], + ], + ), ), - ), - ], - ), - if (mode != 'all') ...[ - const SizedBox(height: 12), - Material( - color: palette.card, - child: InkWell( - onTap: controller.blocked - ? null - : () => controller.selectApps(context, widget.openApps), - child: Container( - constraints: const BoxConstraints(minHeight: 64), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide(color: palette.border), + ], + ), + if (mode != 'all') ...[ + const SizedBox(height: 12), + Material( + color: palette.card, + child: InkWell( + onTap: controller.blocked + ? null + : () => + controller.selectApps(context, widget.openApps), + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadii.control), ), - ), - child: Row( - spacing: 11, - children: [ - Icon( - LucideIcons.appWindow, - size: 19, - color: palette.primary, - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - included - ? l.prototypeAppsUsingVpn - : l.prototypeAppsBypassingVpn, - style: AppTypography.androidRowTitle, - ), - const SizedBox(height: 3), - Text( - names.isEmpty - ? l.prototypeNoAppsSelected - : names - .map(controller.androidAppName) - .join(', '), - style: AppTypography.androidRowHint.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ), - ), - Container( - constraints: const BoxConstraints(minWidth: 26), - padding: const EdgeInsets.symmetric( - horizontal: 7, - vertical: 4, + child: Row( + spacing: 12, + children: [ + Icon( + LucideIcons.appWindow, + size: 19, + color: palette.primary, ), - decoration: BoxDecoration( - color: palette.selectedSurface, - borderRadius: BorderRadius.circular( - AppRadii.pill, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + included + ? l.prototypeAppsUsingVpn + : l.prototypeAppsBypassingVpn, + style: AppTypography.androidRowTitle, + ), + const SizedBox(height: 4), + Text( + names.isEmpty + ? l.prototypeNoAppsSelected + : names + .map(controller.androidAppName) + .join(', '), + style: AppTypography.androidRowHint + .copyWith( + color: palette.mutedForeground, + ), + ), + ], ), ), - child: Text( - '${names.length}', - textAlign: TextAlign.center, - style: AppTypography.androidBadge.copyWith( - color: palette.primary, + Container( + constraints: const BoxConstraints(minWidth: 26), + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 4, + ), + decoration: BoxDecoration( + color: palette.selectedSurface, + borderRadius: BorderRadius.circular( + AppRadii.pill, + ), + ), + child: Text( + '${names.length}', + textAlign: TextAlign.center, + style: AppTypography.androidBadge.copyWith( + color: palette.primary, + ), ), ), - ), - const Icon(LucideIcons.chevronRightDir, size: 18), - ], + const Icon(LucideIcons.chevronRightDir, size: 18), + ], + ), ), ), ), - ), - ], - if (widget.openAutomation != null) ...[ - const SizedBox(height: 24), - SettingRow( - title: l.automationTitle, - leading: const Icon(LucideIcons.zap), - showChevron: true, - onTap: () => widget.openAutomation!(context), - ), + ], + if (widget.openAutomation != null) ...[ + const SizedBox(height: 24), + SettingRow( + title: l.automationTitle, + leading: const Icon(LucideIcons.zap), + showChevron: true, + onTap: () => widget.openAutomation!(context), + ), + ], ], - ], + ), ), ); }, @@ -221,15 +220,13 @@ class _AndroidModeRow extends StatelessWidget { inMutuallyExclusiveGroup: true, child: MergeSemantics( child: Material( - color: palette.card, + color: selected ? palette.selectedSurface : palette.card, + borderRadius: BorderRadius.circular(AppRadii.control), child: InkWell( onTap: enabled ? onTap : null, child: Container( constraints: const BoxConstraints(minHeight: 68), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), + padding: const EdgeInsets.all(12), child: Row( spacing: 12, children: [ @@ -238,8 +235,8 @@ class _AndroidModeRow extends StatelessWidget { child: ShadRadio( value: value, enabled: enabled, - size: 11, - circleSize: 7, + size: 15, + circleSize: 10, radioPadding: EdgeInsets.zero, decoration: ShadDecoration( border: ShadBorder.all( diff --git a/lib/pages/advanced/tunnel/apple/page.dart b/lib/pages/advanced/tunnel/apple/page.dart index ffb6062e4..b38f799d3 100644 --- a/lib/pages/advanced/tunnel/apple/page.dart +++ b/lib/pages/advanced/tunnel/apple/page.dart @@ -56,7 +56,6 @@ class AppleVpnPage extends StatelessWidget { l.prototypeBypassCellularServicesHint, l.prototypeBypassApplePushHint, l.prototypeAllowDeviceCommunicationHint, - l.prototypeUseDnsOverTlsHint, l.prototypeAlwaysOnHint, l.prototypeConnectOnDemandHint, ], @@ -104,7 +103,7 @@ class AppleVpnView extends StatelessWidget { final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; final gutter = mobile - ? 14.0 + ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); Widget toggle( String field, @@ -124,12 +123,7 @@ class AppleVpnView extends StatelessWidget { : (value) => controller.update(field, value, section: 'apple'), ); return Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - mobile ? 18 : 24, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, mobile ? 18 : 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -149,7 +143,7 @@ class AppleVpnView extends StatelessWidget { ), ), SettingSection( - title: '', + title: l.prototypeSystemVpnPolicy, padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -204,59 +198,40 @@ class AppleVpnView extends StatelessWidget { supported: apple['captureAllTraffic'] == false, showDescription: true, ), - toggle( - 'dnsOverTls', - l.prototypeUseDnsOverTls, - l.prototypeUseDnsOverTlsHint, - ), ], ), - SizedBox(height: mobile ? 16 : 20), - Text( - l.prototypeBypassNetworks, - style: mobile - ? AppTypography.appleAutoTitle - : AppTypography.appleAutoTitleDesktop, - ), - const SizedBox(height: 8), - Text( - apple['captureAllTraffic'] == true + const SizedBox(height: 20), + SettingSection( + title: l.prototypeBypassNetworks, + description: apple['captureAllTraffic'] == true ? l.appleExcludedNetworksInactive : l.appleExcludedNetworksHint, - style: AppTypography.platformDetailBody.copyWith( - color: palette.mutedForeground, - ), - ), - if (apple['captureAllTraffic'] == false) ...[ - const SizedBox(height: 16), - ExcludedNetworks( - rowKeyPrefix: 'apple-cidr-row', - values: controller.strings('apple', 'excludedCidrs'), - enabled: !controller.blocked, - onChanged: (values) => controller.update( - 'excludedCidrs', - values, - section: 'apple', - ), - ), - const SizedBox(height: 14), - Text( - l.appleExcludedNetworksInputHint, - style: AppTypography.windowsNetworkNote.copyWith( - color: palette.mutedForeground, - ), - ), - ], - SizedBox(height: mobile ? 16 : 20), - Text( - l.prototypeAutomaticConnectionDisconnection, - style: mobile - ? AppTypography.appleAutoTitle - : AppTypography.appleAutoTitleDesktop, + padding: EdgeInsets.zero, + children: [ + if (apple['captureAllTraffic'] == false) ...[ + ExcludedNetworks( + rowKeyPrefix: 'apple-cidr-row', + values: controller.strings('apple', 'excludedCidrs'), + enabled: !controller.blocked, + onChanged: (values) => controller.update( + 'excludedCidrs', + values, + section: 'apple', + ), + ), + const SizedBox(height: 12), + Text( + l.appleExcludedNetworksInputHint, + style: AppTypography.windowsNetworkNote.copyWith( + color: palette.mutedForeground, + ), + ), + ], + ], ), - SizedBox(height: mobile ? 8 : 10), + const SizedBox(height: 20), SettingSection( - title: '', + title: l.prototypeAutomaticConnectionDisconnection, padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -273,9 +248,7 @@ class AppleVpnView extends StatelessWidget { ), if (apple['onDemandEnabled'] == true) Padding( - padding: mobile - ? const EdgeInsets.all(10) - : const EdgeInsets.fromLTRB(16, 12, 16, 16), + padding: const EdgeInsets.only(top: 12), child: AppleWifiPreview( controller: controller, showNetwork: true, diff --git a/lib/pages/advanced/tunnel/apple/widgets.dart b/lib/pages/advanced/tunnel/apple/widgets.dart index 96b65efa4..0f67cf6bb 100644 --- a/lib/pages/advanced/tunnel/apple/widgets.dart +++ b/lib/pages/advanced/tunnel/apple/widgets.dart @@ -2,6 +2,7 @@ import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -32,13 +33,8 @@ class AppleSettingToggle extends StatelessWidget { final palette = ColorManager.palette(context); return MergeSemantics( child: Container( - constraints: BoxConstraints(minHeight: mobile ? 64 : 61), - padding: EdgeInsetsDirectional.fromSTEB( - nested ? (mobile ? 20 : 32) : (mobile ? 12 : 16), - mobile ? 9 : 10, - mobile ? 12 : 16, - mobile ? 9 : 10, - ), + constraints: BoxConstraints(minHeight: mobile ? 56 : 48), + padding: EdgeInsetsDirectional.fromSTEB(nested ? 24 : 12, 12, 12, 12), child: Row( children: [ Expanded( @@ -52,7 +48,7 @@ class AppleSettingToggle extends StatelessWidget { : AppTypography.appleSettingTitleDesktop, ), if (description != null) ...[ - const SizedBox(height: 3), + const SizedBox(height: 4), Text( description!, style: @@ -65,7 +61,7 @@ class AppleSettingToggle extends StatelessWidget { ], ), ), - SizedBox(width: mobile ? 12 : 16), + const SizedBox(width: 12), AppSwitch( value: value, enabled: onChanged != null, @@ -144,18 +140,12 @@ class AppleWifiPreview extends StatelessWidget { return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: palette.card, - border: Border.all(color: palette.border), + color: palette.secondary, borderRadius: BorderRadius.circular(AppRadii.control), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var index = 0; index < sections.length; index++) ...[ - if (index > 0) Divider(height: 0, color: palette.border), - sections[index], - ], - ], + children: [...sections], ), ); } @@ -585,76 +575,47 @@ class _AppleWifiEditorSectionState extends State { final mobile = _mobile(context); final palette = ColorManager.palette(context); final l = AppLocalizations.of(context)!; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return SettingSection( + title: widget.title, + description: widget.description, + padding: EdgeInsets.zero, children: [ - Text( - widget.title, - style: mobile - ? AppTypography.appleWifiHeading - : AppTypography.appleWifiHeadingDesktop, - ), - SizedBox(height: mobile ? 4 : 5), - Text( - widget.description, - style: - (mobile - ? AppTypography.appleWifiDescription - : AppTypography.appleWifiDescriptionDesktop) - .copyWith(color: palette.mutedForeground), - ), - SizedBox(height: mobile ? 13 : 17), - Container( - padding: EdgeInsets.all(mobile ? 12 : 20), - decoration: BoxDecoration( - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppRadii.card), + for (var index = 0; index < widget.values.length; index++) ...[ + _WifiInputRow( + key: ObjectKey(fields[index]), + controller: fields[index], + label: '${widget.title} ${index + 1}', + removeLabel: '${l.prototypeRemoveWifi} ${index + 1}', + enabled: widget.enabled, + conflict: + widget.values[index].trim().isNotEmpty && + widget.otherValues.contains(widget.values[index]), + onChanged: (_) => _publish(), + onRemove: () => _remove(index), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + const SizedBox(height: 12), + ], + OutlinedButton( + onPressed: widget.enabled ? _add : null, + style: OutlinedButton.styleFrom( + foregroundColor: palette.foreground, + side: BorderSide(color: palette.border), + minimumSize: Size(0, mobile ? 48 : 42), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.control), + ), + textStyle: mobile + ? AppTypography.appleWifiAdd + : AppTypography.appleWifiAddDesktop, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - for (var index = 0; index < widget.values.length; index++) ...[ - _WifiInputRow( - key: ObjectKey(fields[index]), - controller: fields[index], - label: '${widget.title} ${index + 1}', - removeLabel: '${l.prototypeRemoveWifi} ${index + 1}', - enabled: widget.enabled, - conflict: - widget.values[index].trim().isNotEmpty && - widget.otherValues.contains(widget.values[index]), - onChanged: (_) => _publish(), - onRemove: () => _remove(index), - ), - SizedBox(height: mobile ? 9 : 12), - ], - OutlinedButton( - onPressed: widget.enabled ? _add : null, - style: OutlinedButton.styleFrom( - foregroundColor: palette.primary, - side: BorderSide(color: palette.primary), - minimumSize: Size(0, mobile ? 47 : 54), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.control), - ), - textStyle: mobile - ? AppTypography.appleWifiAdd - : AppTypography.appleWifiAddDesktop, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(LucideIcons.plus, size: 18), - const SizedBox(width: 9), - Flexible(child: Text(l.prototypeAddWifi)), - ], - ), - ), + const Icon(LucideIcons.plus, size: 18), + const SizedBox(width: 9), + Flexible(child: Text(l.prototypeAddWifi)), ], ), ), diff --git a/lib/pages/advanced/tunnel/apple/wifi.dart b/lib/pages/advanced/tunnel/apple/wifi.dart index 0355c98cb..d6c291b77 100644 --- a/lib/pages/advanced/tunnel/apple/wifi.dart +++ b/lib/pages/advanced/tunnel/apple/wifi.dart @@ -46,17 +46,12 @@ class AppleWifiView extends StatelessWidget { final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; final gutter = mobile - ? 14.0 + ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); final palette = ColorManager.palette(context); - final gap = mobile ? 18.0 : 24.0; + const gap = 20.0; return Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - mobile ? 18 : 24, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, mobile ? 18 : 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/pages/advanced/tunnel/controller.dart b/lib/pages/advanced/tunnel/controller.dart index 5f4b8da1f..9be32cc48 100644 --- a/lib/pages/advanced/tunnel/controller.dart +++ b/lib/pages/advanced/tunnel/controller.dart @@ -12,7 +12,7 @@ import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/advanced/tunnel/interface.dart'; import 'package:onexray/service/shared/failure.dart'; -enum TunnelDestination { apple, android, windows, interface } +enum TunnelDestination { dns, network, apple, android, windows, interface } typedef OpenTunnelPage = Future Function( BuildContext context, diff --git a/lib/pages/advanced/tunnel/details.dart b/lib/pages/advanced/tunnel/details.dart new file mode 100644 index 000000000..3f62607ac --- /dev/null +++ b/lib/pages/advanced/tunnel/details.dart @@ -0,0 +1,160 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/tunnel/controller.dart'; +import 'package:onexray/pages/advanced/tunnel/widgets.dart'; +import 'package:onexray/pages/shared/widgets/dns_text_field.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/service/advanced/platform_policy.dart'; +import 'package:onexray/service/advanced/policy_editor.dart'; +import 'package:onexray/service/connect/settings.dart'; + +class TunnelDnsPage extends StatelessWidget { + const TunnelDnsPage({super.key, required this.draft, this.createController}); + + final PolicyEditorDraft draft; + final PolicyEditorController Function()? createController; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => + createController?.call() ?? PolicyEditorController(draft: draft), + child: BlocBuilder( + builder: (context, state) { + final controller = context.read(); + final l = AppLocalizations.of(context)!; + final apple = + controller.platform == ConnectionPlatform.ios || + controller.platform == ConnectionPlatform.macos; + final dot = apple && controller.group('apple')['dnsOverTls'] == true; + Widget field( + TunnelDnsField field, + String key, + String label, + String name, + ) => DnsTextField( + key: ValueKey('tunnel-dns-$name'), + label: label, + value: controller.value[key] as String, + enabled: !controller.blocked, + focusNode: controller.dnsFocusNode(field), + errorText: controller.dnsErrorText(l, field), + onChanged: (value) => controller.update(key, value), + ); + return PolicyDetailScaffold( + title: l.prototypeTunnelDns, + controller: controller, + help: [ + l.tunnelDnsServerNameHint, + if (apple) l.prototypeUseDnsOverTlsHint, + ], + contentPadding: EdgeInsets.zero, + body: _bodyPadding( + context, + SettingSection( + title: l.prototypeTunnelDns, + padding: EdgeInsets.zero, + children: [ + field( + TunnelDnsField.ipv4, + 'dnsIpv4Address', + l.prototypeIpv4Dns, + 'ipv4', + ), + if (controller.value['ipv6Enabled'] == true || + controller.supportsWindowsSystemVpn) ...[ + const SizedBox(height: 16), + field( + TunnelDnsField.ipv6, + 'dnsIpv6Address', + l.prototypeIpv6Dns, + 'ipv6', + ), + ], + if (apple) ...[ + const SizedBox(height: 16), + PolicyToggle( + key: const ValueKey('tunnel-dns-over-tls'), + controller: controller, + section: 'apple', + field: 'dnsOverTls', + title: l.prototypeUseDnsOverTls, + ), + ], + if (dot) ...[ + const SizedBox(height: 16), + field( + TunnelDnsField.serverName, + 'dnsServerName', + l.prototypeDomain, + 'server-name', + ), + ], + ], + ), + ), + ); + }, + ), + ); +} + +class TunnelNetworkPage extends StatelessWidget { + const TunnelNetworkPage({ + super.key, + required this.draft, + this.createController, + }); + + final PolicyEditorDraft draft; + final PolicyEditorController Function()? createController; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => + createController?.call() ?? PolicyEditorController(draft: draft), + child: BlocBuilder( + builder: (context, state) { + final controller = context.read(); + final l = AppLocalizations.of(context)!; + return PolicyDetailScaffold( + title: 'IPv6', + controller: controller, + canSave: controller.validationHint(l) == null, + contentPadding: EdgeInsets.zero, + body: _bodyPadding( + context, + SettingSection( + title: 'IPv6', + description: controller.ipv6Conflict + ? l.prototypeIpv6BypassConflict + : null, + descriptionBelow: true, + padding: EdgeInsets.zero, + children: [ + PolicyToggle( + key: const ValueKey('tunnel-ipv6-enabled'), + controller: controller, + field: 'ipv6Enabled', + title: l.prototypeUseIpv6, + ), + ], + ), + ), + ); + }, + ), + ); +} + +Widget _bodyPadding(BuildContext context, Widget child) { + final width = MediaQuery.sizeOf(context).width; + final gutter = width <= AppLayout.mobileBreakpoint + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); + return Padding( + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), + child: child, + ); +} diff --git a/lib/pages/advanced/tunnel/excluded_networks.dart b/lib/pages/advanced/tunnel/excluded_networks.dart index a948d016f..2e22ff26c 100644 --- a/lib/pages/advanced/tunnel/excluded_networks.dart +++ b/lib/pages/advanced/tunnel/excluded_networks.dart @@ -126,7 +126,7 @@ class _ExcludedNetworksState extends State { enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.symmetric(vertical: 8), + contentPadding: EdgeInsets.symmetric(vertical: 12), ), onChanged: (_) => _publish(), ), diff --git a/lib/pages/advanced/tunnel/interface/page.dart b/lib/pages/advanced/tunnel/interface/page.dart index 4098af46a..220ac53c4 100644 --- a/lib/pages/advanced/tunnel/interface/page.dart +++ b/lib/pages/advanced/tunnel/interface/page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -81,7 +82,9 @@ class OutboundInterfaceView extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); final selected = controller.value['xrayOutboundInterfaceName'] as String; return PolicyDetailScaffold( title: l.prototypeXrayOutboundInterface, @@ -90,24 +93,12 @@ class OutboundInterfaceView extends StatelessWidget { !controller.loading && !controller.failed && selected.isNotEmpty, contentPadding: EdgeInsets.zero, body: Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - mobile ? 18 : 24, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, mobile ? 18 : 24), + child: SettingSection( + title: l.prototypeChooseInterface, + description: l.prototypeInterfaceSelectionNotice, + padding: EdgeInsets.zero, children: [ - Text( - l.prototypeChooseInterface, - style: AppTypography.platformDetailTitle, - ), - Text( - l.prototypeInterfaceSelectionNotice, - style: AppTypography.platformDetailBody, - ), - SizedBox(height: mobile ? 12 : 14), if (controller.loading) const Center(child: CircularProgressIndicator()) else if (controller.failed || controller.interfaces.isEmpty) @@ -131,11 +122,8 @@ class OutboundInterfaceView extends StatelessWidget { } }, items: [ - Container( + SizedBox( width: double.infinity, - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), child: Column( children: [ for (final value in controller.interfaces) @@ -210,10 +198,7 @@ class _InterfaceRow extends StatelessWidget { onTap: enabled ? onTap : null, child: Container( constraints: const BoxConstraints(minHeight: 68), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), + padding: const EdgeInsets.all(12), child: Row( children: [ Padding( diff --git a/lib/pages/advanced/tunnel/page.dart b/lib/pages/advanced/tunnel/page.dart index 15551c90f..ad4d2eaf1 100644 --- a/lib/pages/advanced/tunnel/page.dart +++ b/lib/pages/advanced/tunnel/page.dart @@ -1,26 +1,24 @@ +import 'package:collection/collection.dart'; import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/shared/alert.dart'; -import 'package:onexray/pages/shared/widgets/dns_text_field.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/setting_row.dart'; -import 'package:onexray/pages/shared/widgets/settings_page.dart'; -import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; +import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/settings.dart'; -/// The tunnel destination owns its platform draft and fixed action bar. +/// The root summarizes settings; each destination owns its editing draft. class VpnTunnelPane extends StatelessWidget { final OpenTunnelPage? openTunnel; final PolicyEditorController Function()? createController; - const VpnTunnelPane({super.key, this.openTunnel, this.createController}); @override @@ -37,8 +35,11 @@ class VpnTunnelPane extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 15.0 : AppSpacing.advancedDesktopGutter(width); - if (controller.draft == null) { + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); + final draft = controller.draft; + if (draft == null) { return Center( child: controller.busy ? const CircularProgressIndicator() @@ -48,301 +49,199 @@ class VpnTunnelPane extends StatelessWidget { ), ); } + final ipv6 = controller.value['ipv6Enabled'] == true; + final pending = !const DeepCollectionEquality().equals( + draft.original.policy.toJson(), + draft.policy, + ); return Scaffold( body: SettingsPageScroll( desktopMaxWidth: AppLayout.advancedMaxWidth, padding: EdgeInsets.zero, child: Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - mobile ? 24 : 28, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ - _section( - icon: LucideIcons.activity, - title: l.prototypeConnectionStatus, + SettingSection( + key: const ValueKey('tunnel-system-capture'), + icon: LucideIcons.shield, + title: l.tunnelSystemCapture, + padding: EdgeInsets.zero, children: [ + PolicyValueRow( + title: l.tunnelCurrentPlatform, + value: switch (controller.platform) { + ConnectionPlatform.ios => 'iOS', + ConnectionPlatform.macos => 'macOS', + ConnectionPlatform.android => 'Android', + ConnectionPlatform.windows => 'Windows', + ConnectionPlatform.linux => 'Linux', + }, + ), PolicyValueRow( title: l.prototypeSystemVpn, leading: Icon( LucideIcons.shield, color: palette.mutedStrong, ), - minHeight: mobile ? 52 : 56, - titleStyle: - (mobile - ? AppTypography.settingsStatus - : AppTypography.desktopSettingsStatus) - .copyWith(color: palette.foreground), - valueStyle: - (mobile - ? AppTypography.settingsStatus - : AppTypography.desktopSettingsStatus) - .copyWith( - color: switch (state.connection.phase) { - ConnectionPhase.connected => - palette.runningText, - ConnectionPhase.preparing || - ConnectionPhase.connecting || - ConnectionPhase.disconnecting => - palette.restartingText, - ConnectionPhase.failed => - palette.destructive, - ConnectionPhase.disconnected => - palette.mutedStrong, - }, - ), + minHeight: mobile ? 56 : 48, + titleStyle: AppTypography.rowTitle.copyWith( + color: palette.foreground, + ), + valueStyle: AppTypography.badge.copyWith( + color: switch (state.connection.phase) { + ConnectionPhase.connected => palette.runningText, + ConnectionPhase.preparing || + ConnectionPhase.connecting || + ConnectionPhase.disconnecting => + palette.restartingText, + ConnectionPhase.failed => palette.destructive, + ConnectionPhase.disconnected => palette.mutedStrong, + }, + ), value: _status(controller, l), valueTextDirection: null, ), - ], - ), - _section( - icon: LucideIcons.network, - title: l.prototypeTunAddress, - children: [ PolicyValueRow( title: l.prototypeIpv4TunAddress, value: '${PlatformPolicy.tunIpv4Address}/15', ), PolicyValueRow( title: l.prototypeIpv6TunAddress, - value: '${PlatformPolicy.tunIpv6Address}/64', + value: ipv6 + ? '${PlatformPolicy.tunIpv6Address}/64' + : l.tunnelIpv6Disabled, + valueTextDirection: ipv6 ? TextDirection.ltr : null, ), ], ), - _section( - icon: LucideIcons.globe2, - title: l.prototypeTunnelDns, - action: PageHelpButton( - title: l.prototypeTunnelDns, - messages: [l.tunnelDnsServerNameHint], - ), + SettingSection( + key: const ValueKey('tunnel-connection-parameters'), + title: l.tunnelConnectionParameters, + padding: EdgeInsets.zero, children: [ - Padding( - padding: const EdgeInsets.all(14), - child: Column( - spacing: 18, - children: [ - DnsTextField( - key: const ValueKey('tunnel-dns-ipv4'), - label: l.prototypeIpv4Dns, - value: - controller.value['dnsIpv4Address'] as String, - enabled: !controller.blocked, - focusNode: controller.dnsFocusNode( - TunnelDnsField.ipv4, - ), - errorText: controller.dnsErrorText( - l, - TunnelDnsField.ipv4, - ), - onChanged: (value) => - controller.update('dnsIpv4Address', value), - ), - DnsTextField( - key: const ValueKey('tunnel-dns-ipv6'), - label: l.prototypeIpv6Dns, - value: - controller.value['dnsIpv6Address'] as String, - enabled: !controller.blocked, - focusNode: controller.dnsFocusNode( - TunnelDnsField.ipv6, - ), - errorText: controller.dnsErrorText( - l, - TunnelDnsField.ipv6, - ), - onChanged: (value) => - controller.update('dnsIpv6Address', value), - ), - DnsTextField( - key: const ValueKey('tunnel-dns-server-name'), - label: l.prototypeDomain, - value: - controller.value['dnsServerName'] as String, - enabled: !controller.blocked, - focusNode: controller.dnsFocusNode( - TunnelDnsField.serverName, - ), - errorText: controller.dnsErrorText( - l, - TunnelDnsField.serverName, - ), - onChanged: (value) => - controller.update('dnsServerName', value), - ), - ], - ), + _entry( + context, + controller, + destination: TunnelDestination.dns, + title: l.prototypeTunnelDns, + subtitle: [ + controller.value['dnsIpv4Address'], + if (ipv6) controller.value['dnsIpv6Address'], + ].join(' · '), + icon: LucideIcons.network, ), - ], - ), - _section( - icon: LucideIcons.network, - title: 'IPv6', - description: controller.ipv6Conflict - ? l.prototypeIpv6BypassConflict - : null, - children: [ - PolicyToggle( - controller: controller, - field: 'ipv6Enabled', - title: l.prototypeUseIpv6, + _entry( + context, + controller, + destination: TunnelDestination.network, + title: 'IPv6', + subtitle: ipv6 + ? l.tunnelIpv6Enabled + : l.tunnelIpv6Disabled, + icon: LucideIcons.globe2, ), - ], - ), - if (controller.service.requiresInterface) - _section( - icon: LucideIcons.network, - title: l.prototypeXrayOutboundInterface, - action: PageHelpButton( - title: l.prototypeXrayOutboundInterface, - messages: [l.prototypeManagedInterfaceNotice], - ), - children: [ - SettingRow( + if (controller.platform == ConnectionPlatform.ios || + controller.platform == ConnectionPlatform.macos) + _entry( + context, + controller, + destination: TunnelDestination.apple, + title: l.prototypeAppleSystemVpn, + subtitle: l.prototypeAppleVpnDescription, + icon: LucideIcons.apple, + ), + if (controller.platform == ConnectionPlatform.android) + _entry( + context, + controller, + destination: TunnelDestination.android, + title: l.prototypeAndroidSystemVpn, + subtitle: l.prototypeAndroidVpnDescription, + icon: LucideIcons.appWindow, + ), + if (controller.supportsWindowsSystemVpn) + _entry( + context, + controller, + destination: TunnelDestination.windows, + title: l.prototypeWindowsSystemVpn, + subtitle: l.prototypeWindowsVpnDescription, + icon: LucideIcons.monitor, + ), + if (controller.service.requiresInterface) + _entry( + context, + controller, + destination: TunnelDestination.interface, title: l.prototypeXrayOutboundInterface, - value: + subtitle: (controller.value['xrayOutboundInterfaceName'] as String) .isEmpty ? l.prototypeChooseInterface : controller.value['xrayOutboundInterfaceName'] as String, - showChevron: true, - minHeight: mobile ? 52 : 56, - titleStyle: mobile - ? AppTypography.settingsValueLabel - : AppTypography.desktopSettingsValueLabel, - valueStyle: - (mobile - ? AppTypography.settingsValue - : AppTypography.desktopSettingsValue) - .copyWith( - color: ColorManager.primaryText(context), - ), - enabled: !controller.blocked && openTunnel != null, - onTap: () => _open( - context, - controller, - TunnelDestination.interface, - ), + icon: LucideIcons.network, ), - ], - ), - if (controller.platform == ConnectionPlatform.ios || - controller.platform == ConnectionPlatform.macos) - _platformEntry( - context, - controller, - l.prototypeAppleSystemVpn, - l.prototypeAppleVpnDescription, - TunnelDestination.apple, - ), - if (controller.platform == ConnectionPlatform.android) - _platformEntry( - context, - controller, - l.prototypeAndroidSystemVpn, - l.prototypeAndroidVpnDescription, - TunnelDestination.android, - ), - if (controller.supportsWindowsSystemVpn) - _platformEntry( - context, - controller, - l.prototypeWindowsSystemVpn, - l.prototypeWindowsVpnDescription, - TunnelDestination.windows, + ], + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + key: const ValueKey('tunnel-restore-defaults'), + onPressed: controller.blocked + ? null + : () { + controller.restoreDefaults(); + ContextAlert.showToast( + context, + l.settingsDefaultsRestored, + ); + }, + child: Text(l.prototypeRestoreDefaults), ), + ), ], ), ), ), - bottomNavigationBar: PolicyActions( - root: true, - controller: controller, - cancel: () { - controller.restoreDefaults(); - ContextAlert.showToast(context, l.settingsDefaultsRestored); - }, - cancelLabel: l.prototypeRestoreDefaults, - cancelIcon: LucideIcons.rotateCcw, - save: () => controller.save(context, pop: false), - ), + bottomNavigationBar: pending + ? PolicyActions( + root: true, + controller: controller, + cancel: () => controller.load(context), + cancelLabel: l.prototypeCancel, + save: () => controller.save(context, pop: false), + ) + : null, ); }, ), ); - Widget _platformEntry( + Widget _entry( BuildContext context, - PolicyEditorController controller, - String title, - String description, - TunnelDestination destination, - ) => _section( - icon: switch (destination) { - TunnelDestination.apple => LucideIcons.apple, - TunnelDestination.android => LucideIcons.appWindow, - _ => LucideIcons.monitor, - }, - title: title, - children: [ - SettingRow( - title: description, - titleStyle: AppTypography.settingsRow, - minHeight: - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint - ? 52 - : 56, - contentPadding: EdgeInsets.symmetric( - horizontal: - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint - ? 13 - : 14, - vertical: 10, - ), - showChevron: true, - enabled: !controller.blocked && openTunnel != null, - onTap: () => _open(context, controller, destination), - ), - ], - ); - - Widget _section({ + PolicyEditorController controller, { + required TunnelDestination destination, required String title, + required String subtitle, required IconData icon, - String? description, - Widget? action, - required List children, - }) => SettingSection( + }) => SettingRow( + key: ValueKey('tunnel-open-${destination.name}'), title: title, - icon: icon, - description: description, - action: action, - descriptionBelow: true, - padding: EdgeInsets.zero, - dividerIndent: 0, - children: children, + subtitle: subtitle, + leading: Icon(icon), + showChevron: true, + enabled: !controller.blocked && openTunnel != null, + onTap: () { + final open = openTunnel; + if (open != null) controller.openPlatform(context, destination, open); + }, ); - void _open( - BuildContext context, - PolicyEditorController controller, - TunnelDestination destination, - ) { - final open = openTunnel; - if (open != null) { - controller.openPlatform(context, destination, open); - } - } - String _status(PolicyEditorController controller, AppLocalizations l) => switch (controller.state.connection.phase) { ConnectionPhase.disconnected => l.prototypeDisconnected, diff --git a/lib/pages/advanced/tunnel/widgets.dart b/lib/pages/advanced/tunnel/widgets.dart index 7c5e6ca33..3a61948c7 100644 --- a/lib/pages/advanced/tunnel/widgets.dart +++ b/lib/pages/advanced/tunnel/widgets.dart @@ -14,6 +14,7 @@ import 'package:onexray/pages/shared/widgets/settings_page.dart'; class PolicyDetailScaffold extends StatelessWidget { final String title; + final String? description; final PolicyEditorController controller; final Widget body; final bool canSave; @@ -23,6 +24,7 @@ class PolicyDetailScaffold extends StatelessWidget { const PolicyDetailScaffold({ super.key, required this.title, + this.description, required this.controller, required this.body, this.canSave = true, @@ -34,8 +36,10 @@ class PolicyDetailScaffold extends StatelessWidget { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(title), + description: description, leading: BackButton(onPressed: () => controller.cancel(context)), actions: [ if (help.isNotEmpty) PageHelpButton(title: title, messages: help), @@ -103,7 +107,7 @@ class PolicyActions extends StatelessWidget { ), PageActionBar( maxWidth: AppLayout.advancedMaxWidth, - expandDesktop: true, + expandDesktop: false, horizontalPadding: mobile ? (root ? 15 : null) : AppSpacing.advancedDesktopGutter(width), @@ -164,14 +168,9 @@ class PolicyToggle extends StatelessWidget { title: title, subtitle: subtitle, titleMaxLines: 4, - minHeight: mobile ? 52 : 56, - titleStyle: mobile - ? AppTypography.settingsFieldTitle - : AppTypography.settingsRow, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 10, - ), + minHeight: mobile ? 56 : 48, + titleStyle: AppTypography.rowTitle, + contentPadding: const EdgeInsets.all(12), trailing: AppSwitch( value: (section == null @@ -210,36 +209,26 @@ class PolicyValueRow extends StatelessWidget { Widget build(BuildContext context) { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final padding = EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: mobile ? 9 : 10, - ); - final labelStyle = - (mobile - ? AppTypography.settingsValueLabel - : AppTypography.desktopSettingsValueLabel) - .merge(titleStyle); - final textStyle = - (mobile - ? AppTypography.settingsValue - : AppTypography.desktopSettingsValue) - .copyWith(color: ColorManager.primaryText(context)) - .merge(valueStyle); + const padding = EdgeInsets.all(12); + final labelStyle = AppTypography.rowTitle.merge(titleStyle); + final textStyle = AppTypography.rowValue + .copyWith(color: ColorManager.primaryText(context)) + .merge(valueStyle); final renderedTitleStyle = AppTypography.rowTitle .copyWith(color: ColorManager.primaryText(context)) .merge(labelStyle); - final renderedValueStyle = AppTypography.code + final renderedValueStyle = AppTypography.rowValue .copyWith(color: ColorManager.secondaryText(context)) .merge(textStyle); - final height = minHeight ?? (mobile ? 46.0 : 50.0); + final height = minHeight ?? (mobile ? 56.0 : 48.0); return SelectionArea( child: LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth - padding.horizontal - - (leading == null ? 0 : 31) - - 11; + (leading == null ? 0 : 32) - + 12; final stacked = _policyTextWidth(context, title, renderedTitleStyle) > available * 5 / 9 || @@ -278,7 +267,7 @@ class PolicyValueRow extends StatelessWidget { ), child: leading!, ), - const SizedBox(width: 11), + const SizedBox(width: 12), ], Expanded( child: Column( diff --git a/lib/pages/advanced/tunnel/windows/page.dart b/lib/pages/advanced/tunnel/windows/page.dart index 4734d15ed..7c676aad2 100644 --- a/lib/pages/advanced/tunnel/windows/page.dart +++ b/lib/pages/advanced/tunnel/windows/page.dart @@ -5,6 +5,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/tunnel/excluded_networks.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -50,7 +51,9 @@ class WindowsVpnView extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); final cidrs = controller.strings('windows', 'excludedCidrs'); return PolicyDetailScaffold( title: l.prototypeWindowsSystemVpn, @@ -64,87 +67,65 @@ class WindowsVpnView extends StatelessWidget { canSave: controller.validationHint(l) == null, contentPadding: EdgeInsets.zero, body: Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - mobile ? 18 : 24, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, mobile ? 18 : 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - l.prototypeSystemVpnPolicy, - style: AppTypography.platformDetailTitle, + SettingSection( + title: l.prototypeSystemVpnPolicy, + padding: EdgeInsets.zero, + children: [ + _toggle(context, 'alwaysOn', l.prototypeAlwaysOn), + _toggle( + context, + 'allowLocalNetwork', + l.prototypeBypassLocalSubnets, + ), + ], ), - SizedBox(height: mobile ? 12 : 14), - DecoratedBox( - decoration: BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide(color: palette.border), + const SizedBox(height: 20), + SettingSection( + title: l.prototypeBypassNetworks, + padding: EdgeInsets.zero, + action: Text( + '${cidrs.where((value) => value.trim().isNotEmpty).length} / 64', + textDirection: TextDirection.ltr, + style: AppTypography.windowsNetworkMeta.copyWith( + color: palette.mutedForeground, ), ), - child: Column( - children: [ - _toggle(context, 'alwaysOn', l.prototypeAlwaysOn), - Divider(height: 1, thickness: 1, color: palette.border), - _toggle( - context, - 'allowLocalNetwork', - l.prototypeBypassLocalSubnets, - ), - ], - ), - ), - const SizedBox(height: 28), - Row( children: [ - Expanded( - child: Text( - l.prototypeBypassNetworks, - style: AppTypography.windowsNetworkTitle, + ExcludedNetworks( + rowKeyPrefix: 'windows-cidr-row', + maxEntries: 64, + values: cidrs, + enabled: !controller.blocked, + onChanged: (values) => controller.update( + 'excludedCidrs', + values, + section: 'windows', ), ), - const SizedBox(width: 12), + const SizedBox(height: 14), Text( - '${cidrs.where((value) => value.trim().isNotEmpty).length} / 64', - textDirection: TextDirection.ltr, - style: AppTypography.windowsNetworkMeta.copyWith( + l.prototypeBypassNetworkInputHint, + style: AppTypography.windowsNetworkNote.copyWith( color: palette.mutedForeground, ), ), + if (controller.value['ipv6Enabled'] == false) ...[ + const SizedBox(height: 14), + Text( + controller.ipv6Conflict + ? l.prototypeIpv6BypassConflict + : l.prototypeEnableIpv6ForBypass, + style: AppTypography.windowsNetworkNote.copyWith( + color: palette.mutedForeground, + ), + ), + ], ], ), - const SizedBox(height: 16), - ExcludedNetworks( - rowKeyPrefix: 'windows-cidr-row', - maxEntries: 64, - values: cidrs, - enabled: !controller.blocked, - onChanged: (values) => controller.update( - 'excludedCidrs', - values, - section: 'windows', - ), - ), - const SizedBox(height: 14), - Text( - l.prototypeBypassNetworkInputHint, - style: AppTypography.windowsNetworkNote.copyWith( - color: palette.mutedForeground, - ), - ), - if (controller.value['ipv6Enabled'] == false) ...[ - const SizedBox(height: 14), - Text( - controller.ipv6Conflict - ? l.prototypeIpv6BypassConflict - : l.prototypeEnableIpv6ForBypass, - style: AppTypography.windowsNetworkNote.copyWith( - color: palette.mutedForeground, - ), - ), - ], if ((controller.value['xrayOutboundInterfaceName'] as String) .isEmpty) ...[ const SizedBox(height: 14), @@ -173,13 +154,13 @@ class WindowsVpnView extends StatelessWidget { Widget _toggle(BuildContext context, String field, String title) { return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.all(12), child: Row( children: [ Expanded( child: Text(title, style: AppTypography.platformChoiceTitle), ), - const SizedBox(width: 16), + const SizedBox(width: 12), AppSwitch( value: controller.group('windows')[field] as bool, onChanged: controller.blocked diff --git a/lib/pages/advanced/xray/config/page.dart b/lib/pages/advanced/xray/config/page.dart index be2b28d67..5211811c1 100644 --- a/lib/pages/advanced/xray/config/page.dart +++ b/lib/pages/advanced/xray/config/page.dart @@ -67,7 +67,8 @@ class ConfigFileViewerView extends StatelessWidget { ? AppTypography.runtimeCodeMobile : AppTypography.runtimeCodeDesktop) .copyWith( - color: ColorManager.palette(context).mutedStrong, + color: ColorManager.palette(context).foreground, + fontFamily: AppFontFamily.mono, ), ), ), diff --git a/lib/pages/advanced/xray/controller.dart b/lib/pages/advanced/xray/controller.dart index dbe2527f5..e32b7f881 100644 --- a/lib/pages/advanced/xray/controller.dart +++ b/lib/pages/advanced/xray/controller.dart @@ -9,6 +9,7 @@ import 'package:onexray/pages/advanced/xray/config/params.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; import 'package:onexray/service/advanced/policy_editor.dart'; @@ -220,25 +221,12 @@ class XrayRuntimeController extends PageCubit { coordinator: coordinator, base: base!, log: log, - confirmReconnect: () async => - await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(l.prototypeSaveAndReconnect), - content: Text(l.prototypeReconnectNotice), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(l.prototypeCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, true), - child: Text(l.prototypeSaveAndReconnect), - ), - ], - ), - ) == - true, + confirmReconnect: () => AppConfirmationDialog( + title: l.prototypeSaveAndReconnect, + content: l.prototypeReconnectNotice, + cancelLabel: l.prototypeCancel, + confirmLabel: l.prototypeSaveAndReconnect, + ).show(context), ); if (saved && isPageActive) { await load(showLoading: false); diff --git a/lib/pages/advanced/xray/data_update/page.dart b/lib/pages/advanced/xray/data_update/page.dart index ece91f6e4..bbc48b8d3 100644 --- a/lib/pages/advanced/xray/data_update/page.dart +++ b/lib/pages/advanced/xray/data_update/page.dart @@ -28,9 +28,12 @@ class AutoUpdatePage extends StatelessWidget { final value = state.autoUpdateState; final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.prototypeDataUpdates), actions: [ const AppActivityIndicator(pinging: false), @@ -47,7 +50,7 @@ class AutoUpdatePage extends StatelessWidget { ), bottomNavigationBar: PageActionBar( maxWidth: AppLayout.advancedMaxWidth, - expandDesktop: true, + expandDesktop: false, horizontalPadding: mobile ? null : gutter, spacing: mobile ? AppSpacing.actionGap : 13, children: [ @@ -88,13 +91,13 @@ class AutoUpdatePage extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 16 : 24, + 20, gutter, mobile ? 26 : 24, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ SettingSection( title: l.prototypeSubscriptions, @@ -145,7 +148,7 @@ class AutoUpdatePage extends StatelessWidget { if (state.failed) Text( appFailureMessage(l, state.failure), - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: Theme.of(context).colorScheme.error, ), ), @@ -166,12 +169,9 @@ class AutoUpdatePage extends StatelessWidget { required ValueChanged? onChanged, }) => SettingRow( title: title, - titleStyle: AppTypography.settingsRow, - minHeight: mobile ? 58 : 64, - contentPadding: EdgeInsetsDirectional.symmetric( - horizontal: mobile ? 13 : 14, - vertical: mobile ? 7 : 9, - ), + titleStyle: AppTypography.rowTitle, + minHeight: mobile ? 56 : 48, + contentPadding: const EdgeInsets.all(12), enabled: onChanged != null, onTap: onChanged == null ? null : () => onChanged(!value), trailing: AppSwitch( @@ -189,14 +189,9 @@ class AutoUpdatePage extends StatelessWidget { required bool mobile, }) => SettingRow( title: l.prototypeUpdateInterval, - titleStyle: mobile - ? AppTypography.settingsRow - : AppTypography.settingsSelect, - minHeight: 62, - contentPadding: const EdgeInsetsDirectional.symmetric( - horizontal: 14, - vertical: 10, - ), + titleStyle: AppTypography.rowTitle, + minHeight: mobile ? 56 : 48, + contentPadding: const EdgeInsets.all(12), trailing: SettingSelect( value: value, entries: { diff --git a/lib/pages/advanced/xray/diagnostics.dart b/lib/pages/advanced/xray/diagnostics.dart index f86791af8..3b44e0ef9 100644 --- a/lib/pages/advanced/xray/diagnostics.dart +++ b/lib/pages/advanced/xray/diagnostics.dart @@ -43,8 +43,10 @@ class XrayDiagnosticsPage extends StatelessWidget { tabIndex: 1, onChanged: controller.reader.setVisible, child: Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.xrayDiagnostics), + description: l.xrayDiagnosticsHint, actions: [ IconButton( tooltip: l.xrayRefreshStatus, @@ -57,16 +59,11 @@ class XrayDiagnosticsPage extends StatelessWidget { ), body: SafeArea( child: SettingsPageScroll( - padding: EdgeInsetsDirectional.fromSTEB(gutter, 16, gutter, 24), + padding: EdgeInsetsDirectional.fromSTEB(gutter, 20, gutter, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ - Text( - l.xrayDiagnosticsHint, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: palette.mutedForeground), - ), SettingSection( title: l.prototypeRuntimeStatus, padding: EdgeInsets.zero, @@ -124,7 +121,7 @@ class XrayDiagnosticsPage extends StatelessWidget { children: [ if (controller.runtime == null) Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(12), child: Text( l.xrayNoRuntimeConfiguration, style: Theme.of(context).textTheme.bodyMedium diff --git a/lib/pages/advanced/xray/dialing/page.dart b/lib/pages/advanced/xray/dialing/page.dart index 0e69d6926..e4d9fe7aa 100644 --- a/lib/pages/advanced/xray/dialing/page.dart +++ b/lib/pages/advanced/xray/dialing/page.dart @@ -27,7 +27,9 @@ class ServerDialingPage extends StatelessWidget { final l = AppLocalizations.of(context)!; final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return PolicyDetailScaffold( title: l.serverDialingTitle, controller: controller, @@ -53,13 +55,10 @@ class ServerDialingPage extends StatelessWidget { ), ) : Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - 24, - ), - child: Column( + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), + child: SettingSection( + title: l.serverDialingTitle, + padding: EdgeInsets.zero, children: [ SettingRow( title: l.serverDialingDomainStrategy, @@ -85,7 +84,6 @@ class ServerDialingPage extends StatelessWidget { ), ), ), - const Divider(height: 1), SettingRow( title: 'Happy Eyeballs', titleMaxLines: 3, diff --git a/lib/pages/advanced/xray/geodata/add.dart b/lib/pages/advanced/xray/geodata/add.dart new file mode 100644 index 000000000..becf08226 --- /dev/null +++ b/lib/pages/advanced/xray/geodata/add.dart @@ -0,0 +1,180 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/model/geo_data_type.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/xray/geodata/controller.dart'; +import 'package:onexray/pages/shared/widgets/app_activity.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadInput; + +class GeoDataAddPage extends StatelessWidget { + const GeoDataAddPage({super.key, this.createController}); + + final GeoDataController Function()? createController; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) { + final controller = + createController?.call() ?? (GeoDataController()..initialize()); + if (!controller.state.adding) controller.toggleAdd(); + return controller; + }, + child: BlocConsumer( + listenWhen: (previous, current) => + previous.formBusy && + !current.formBusy && + !current.adding && + current.formError == null, + listener: (context, _) { + if (ModalRoute.of(context)?.isCurrent == true) { + Navigator.of(context).pop(true); + } + }, + builder: (context, state) { + final controller = context.read(); + final l = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + void cancel() { + if (!state.formBusy) Navigator.of(context).pop(); + } + + Widget field(String label, Widget input) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Text( + label, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, + ), + ), + Semantics(label: label, child: input), + ], + ); + return PopScope( + canPop: !state.formBusy, + child: Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(l.prototypeAddDataSource), + leading: BackButton(onPressed: cancel), + ), + body: SafeArea( + child: SettingsPageScroll( + desktopMaxWidth: AppLayout.advancedMaxWidth, + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 20, + mobile ? 16 : 28, + 24, + ), + child: SettingSection( + title: l.prototypeCustomRoutingData, + padding: EdgeInsets.zero, + children: [ + field( + l.prototypeDataType, + SettingSelect( + value: state.type, + entries: const { + GeoDataType.ip: 'GeoIP', + GeoDataType.domain: 'GeoSite', + }, + textStyle: AppTypography.rowValue, + onChanged: state.formBusy + ? null + : controller.changeType, + ), + ), + const SizedBox(height: 16), + field( + l.prototypeSavedFileName, + ShadInput( + key: const ValueKey('geodata-source-name'), + controller: controller.name, + enabled: !state.formBusy, + autofocus: true, + autocorrect: false, + textDirection: TextDirection.ltr, + style: AppTypography.rowValue, + placeholderStyle: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), + placeholder: Text( + state.type == GeoDataType.ip + ? 'geoip.dat' + : 'geosite.dat', + ), + ), + ), + const SizedBox(height: 16), + field( + l.prototypeHttpsDownloadAddress, + ShadInput( + key: const ValueKey('geodata-source-url'), + controller: controller.url, + enabled: !state.formBusy, + keyboardType: TextInputType.url, + autocorrect: false, + textDirection: TextDirection.ltr, + style: AppTypography.rowValue, + placeholderStyle: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), + placeholder: const Text( + 'https://example.com/geoip.dat', + ), + ), + ), + if (state.formError != null) ...[ + const SizedBox(height: 12), + Semantics( + liveRegion: true, + child: SelectableText( + state.formError!, + style: AppTypography.supporting.copyWith( + color: palette.destructive, + ), + ), + ), + ], + ], + ), + ), + ), + bottomNavigationBar: PageActionBar( + children: [ + OutlinedButton( + onPressed: state.formBusy ? null : cancel, + child: Text(l.prototypeCancel), + ), + FilledButton( + key: const ValueKey('geodata-source-add'), + onPressed: state.formBusy + ? null + : () => controller.add(context), + child: AppActivityBuilder( + builder: (context, activity) => ButtonProgress( + busy: activity.downloading || state.formBusy, + child: Text(l.prototypeAdd), + ), + ), + ), + ], + ), + ), + ); + }, + ), + ); +} diff --git a/lib/pages/advanced/xray/geodata/detail.dart b/lib/pages/advanced/xray/geodata/detail.dart index f60461cb9..5999f5bcd 100644 --- a/lib/pages/advanced/xray/geodata/detail.dart +++ b/lib/pages/advanced/xray/geodata/detail.dart @@ -48,7 +48,8 @@ class _GeoDataFilePageState extends State { MediaQuery.sizeOf(context).width, ); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(file?.fileName ?? l.prototypeRoutingData), actions: const [AppActivityIndicator(pinging: false)], ), @@ -76,7 +77,7 @@ class _GeoDataFilePageState extends State { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 16 : 24, + 20, gutter, 12, ), @@ -113,7 +114,7 @@ class _GeoDataFilePageState extends State { dividerIndent: 0, children: [ Padding( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(12), child: Row( spacing: 12, children: [ @@ -165,7 +166,7 @@ class _GeoDataFilePageState extends State { file.row.type == 'ip' ? 'GeoIP' : 'GeoSite', - style: AppTypography.settingsRow, + style: AppTypography.rowTitle, ), ), SettingRow( @@ -177,7 +178,7 @@ class _GeoDataFilePageState extends State { ), trailing: Text( '${file.index.categoryCount}', - style: AppTypography.settingsRow, + style: AppTypography.rowTitle, ), ), ], @@ -187,13 +188,6 @@ class _GeoDataFilePageState extends State { label: l.prototypeSearchCategories, child: ShadInput( onChanged: controller.searchChanged, - constraints: const BoxConstraints( - minHeight: 43, - ), - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), gap: 10, style: AppTypography.geodataBody, placeholderStyle: AppTypography @@ -213,7 +207,7 @@ class _GeoDataFilePageState extends State { border: ShadBorder.all( color: palette.border, radius: BorderRadius.circular( - AppRadii.card, + AppRadii.control, ), ), ), @@ -232,13 +226,14 @@ class _GeoDataFilePageState extends State { ), sliver: DecoratedSliver( decoration: BoxDecoration( + color: palette.card, border: Border.all(color: palette.border), borderRadius: BorderRadius.circular( AppRadii.card, ), ), sliver: SliverPadding( - padding: const EdgeInsets.all(1), + padding: const EdgeInsets.all(18), sliver: codes.isEmpty ? SliverToBoxAdapter( child: Container( @@ -316,14 +311,9 @@ class GeoDataCategorySliver extends StatelessWidget { final palette = ColorManager.palette(context); return Container( constraints: const BoxConstraints(minHeight: 66), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - border: index == codes.length - 1 - ? null - : Border(bottom: BorderSide(color: palette.border)), - ), + padding: const EdgeInsets.all(12), child: Row( - spacing: 10, + spacing: 12, children: [ Expanded( child: Column( @@ -337,7 +327,7 @@ class GeoDataCategorySliver extends StatelessWidget { overflow: TextOverflow.ellipsis, style: AppTypography.geodataCategory, ), - const SizedBox(height: 5), + const SizedBox(height: 4), Text( reference, textDirection: TextDirection.ltr, diff --git a/lib/pages/advanced/xray/geodata/page.dart b/lib/pages/advanced/xray/geodata/page.dart index d0f0730a7..f95c4ee11 100644 --- a/lib/pages/advanced/xray/geodata/page.dart +++ b/lib/pages/advanced/xray/geodata/page.dart @@ -2,7 +2,6 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:onexray/core/model/geo_data_type.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/xray/geodata/controller.dart'; import 'package:onexray/pages/advanced/xray/geodata/view.dart'; @@ -14,11 +13,11 @@ import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; -import 'package:shadcn_ui/shadcn_ui.dart' show ShadInput; class GeoDataPage extends StatefulWidget { - const GeoDataPage({super.key, required this.openFile}); + const GeoDataPage({super.key, required this.openFile, this.openAdd}); final void Function(BuildContext, int) openFile; + final Future Function(BuildContext)? openAdd; @override State createState() => _GeoDataPageState(); } @@ -43,7 +42,11 @@ class _GeoDataPageState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); return Scaffold( - appBar: PageAppBar(title: Text(l.prototypeRoutingData)), + appBar: PageAppBar.forPage( + context, + title: Text(l.prototypeRoutingData), + description: l.prototypeRoutingDataHint, + ), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: AppLayout.advancedMaxWidth, @@ -67,12 +70,12 @@ class _GeoDataPageState extends State { child: ListView( controller: scroll, padding: mobile - ? const EdgeInsets.fromLTRB(14, 10, 14, 18) + ? const EdgeInsets.fromLTRB(16, 20, 16, 24) : EdgeInsets.fromLTRB( AppSpacing.advancedDesktopGutter( MediaQuery.sizeOf(context).width, ), - 54, + 20, AppSpacing.advancedDesktopGutter( MediaQuery.sizeOf(context).width, ), @@ -80,11 +83,11 @@ class _GeoDataPageState extends State { ), children: [ _intro(context, state, controller), - SizedBox(height: mobile ? 7 : 16), - _heading( - context, - l.prototypeDefaultRoutingData, - OutlinedButton.icon( + const SizedBox(height: 20), + SettingSection( + title: l.prototypeDefaultRoutingData, + padding: EdgeInsets.zero, + action: OutlinedButton.icon( style: _headingButtonStyle(context), onPressed: state.fileBusy(-1) ? null @@ -100,106 +103,97 @@ class _GeoDataPageState extends State { ), label: Text(l.prototypeUpdate), ), - ), - GeoDataRows( - files: state.defaults, - custom: false, - busy: state.updatingAll, - onOpen: (file) => - widget.openFile(context, file.row.id), - onUpdate: (file) => - controller.update(context, file.row), - onDelete: (file) => - controller.delete(context, file.row), - ), - if (state.errors[-1] != null) - _error(context, state.errors[-1]!), - for (final file in state.defaults) - if (file.failure != null) - _error( - context, - '${file.fileName}: ${appFailureMessage(l, file.failure)}', + children: [ + GeoDataRows( + files: state.defaults, + custom: false, + busy: state.updatingAll, + onOpen: (file) => + widget.openFile(context, file.row.id), + onUpdate: (file) => + controller.update(context, file.row), + onDelete: (file) => + controller.delete(context, file.row), ), - SizedBox(height: mobile ? 18 : 24), - _heading( - context, - l.prototypeCustomRoutingData, - OutlinedButton.icon( + if (state.errors[-1] != null) + _error(context, state.errors[-1]!), + for (final file in state.defaults) + if (file.failure != null) + _error( + context, + '${file.fileName}: ${appFailureMessage(l, file.failure)}', + ), + ], + ), + const SizedBox(height: 20), + SettingSection( + title: l.prototypeCustomRoutingData, + padding: EdgeInsets.zero, + action: OutlinedButton.icon( style: _headingButtonStyle(context), onPressed: state.formBusy ? null - : controller.toggleAdd, + : widget.openAdd == null + ? null + : () => widget.openAdd!(context), icon: const Icon(LucideIcons.plus, size: 16), label: Text(l.prototypeAddDataSource), ), - ), - if (state.adding) _form(context, state, controller), - if (state.custom.isEmpty && state.pending.isEmpty) - Container( - constraints: const BoxConstraints(minHeight: 72), - alignment: Alignment.center, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 16, - ), - decoration: BoxDecoration( - border: Border( - top: BorderSide(color: palette.border), - bottom: BorderSide(color: palette.border), + children: [ + if (state.custom.isEmpty && state.pending.isEmpty) + Padding( + padding: const EdgeInsets.all(12), + child: Text( + l.prototypeNoCustomRoutingData, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), ), - ), - child: Text( - l.prototypeNoCustomRoutingData, - textAlign: TextAlign.center, - style: - (mobile - ? AppTypography.settingsDetailNote - : AppTypography.geodataTableBody) - .copyWith( - color: palette.mutedForeground, - ), - ), - ), - if (state.custom.isNotEmpty) - GeoDataRows( - files: state.custom, - custom: true, - busy: state.updatingAll, - updating: state.updating, - deleting: state.deleting, - onOpen: (file) => - widget.openFile(context, file.row.id), - onUpdate: (file) => - controller.update(context, file.row), - onDelete: (file) => - controller.delete(context, file.row), - ), - for (final file in state.custom) ...[ - if (file.failure != null) - _error( - context, - '${file.fileName}: ${appFailureMessage(l, file.failure)}', - ), - if (state.errors[file.row.id] != null) - _error( - context, - '${file.fileName}: ${state.errors[file.row.id]}', - ), - ], - for (final row in state.pending) ...[ - PendingGeoDataRow( - row: row, - busy: state.fileBusy(row.id), - deleting: state.deleting.contains(row.id), - onDownload: () => controller.update(context, row), - onDelete: () => controller.delete(context, row), - ), - if (state.errors[row.id] != null) - _error( - context, - '${row.name}.dat: ${state.errors[row.id]}', - ), - ], + if (state.custom.isNotEmpty) + GeoDataRows( + files: state.custom, + custom: true, + busy: state.updatingAll, + updating: state.updating, + deleting: state.deleting, + onOpen: (file) => + widget.openFile(context, file.row.id), + onUpdate: (file) => + controller.update(context, file.row), + onDelete: (file) => + controller.delete(context, file.row), + ), + for (final file in state.custom) ...[ + if (file.failure != null) + _error( + context, + '${file.fileName}: ${appFailureMessage(l, file.failure)}', + ), + if (state.errors[file.row.id] != null) + _error( + context, + '${file.fileName}: ${state.errors[file.row.id]}', + ), + ], + for (final row in state.pending) ...[ + PendingGeoDataRow( + row: row, + busy: state.fileBusy(row.id), + deleting: state.deleting.contains(row.id), + onDownload: () => + controller.update(context, row), + onDelete: () => + controller.delete(context, row), + ), + if (state.errors[row.id] != null) + _error( + context, + '${row.name}.dat: ${state.errors[row.id]}', + ), + ], + ], + ), ], ), ), @@ -219,14 +213,6 @@ class _GeoDataPageState extends State { final palette = ColorManager.palette(context); final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final description = Text( - l.prototypeRoutingDataHint, - style: - (mobile - ? AppTypography.geodataIntro - : AppTypography.geodataDesktopIntro) - .copyWith(color: palette.mutedForeground), - ); final actions = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: mobile @@ -236,7 +222,7 @@ class _GeoDataPageState extends State { FilledButton.icon( style: mobile ? FilledButton.styleFrom( - minimumSize: const Size(0, 38), + minimumSize: const Size(0, 48), padding: const EdgeInsets.symmetric(horizontal: 7), tapTargetSize: MaterialTapTargetSize.shrinkWrap, textStyle: AppTypography.geodataPrimaryAction, @@ -262,54 +248,20 @@ class _GeoDataPageState extends State { ), ], ); - return mobile - ? Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [description, const SizedBox(height: 10), actions], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: description), - const SizedBox(width: 20), - actions, - ], - ); + return Align(alignment: AlignmentDirectional.centerEnd, child: actions); } ButtonStyle _headingButtonStyle(BuildContext context) { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return OutlinedButton.styleFrom( - minimumSize: Size(0, mobile ? 34 : 36), + minimumSize: Size(0, mobile ? 48 : 42), padding: EdgeInsets.symmetric(horizontal: mobile ? 9 : 12), textStyle: mobile ? AppTypography.geodataAction : null, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ); } - Widget _heading(BuildContext context, String title, Widget action) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - return ConstrainedBox( - constraints: BoxConstraints(minHeight: mobile ? 42 : 44), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: mobile - ? AppTypography.geodataTitle - : AppTypography.geodataDesktopTitle, - ), - ), - SizedBox(width: mobile ? 12 : 16), - action, - ], - ), - ); - } - Widget _error(BuildContext context, String text) => Padding( padding: const EdgeInsets.only(top: 12), child: SelectableText( @@ -318,133 +270,4 @@ class _GeoDataPageState extends State { ?.copyWith(color: Theme.of(context).colorScheme.error), ), ); - - Widget _form( - BuildContext context, - GeoDataPageState state, - GeoDataController controller, - ) { - final viewport = MediaQuery.sizeOf(context).width; - final mobile = viewport <= AppLayout.mobileBreakpoint; - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - Widget field(String label, Widget input) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 6, - children: [ - Text( - label, - style: AppTypography.geodataField.copyWith( - color: palette.mutedStrong, - ), - ), - input, - ], - ); - final buttonStyle = FilledButton.styleFrom( - minimumSize: Size(mobile ? 84 : 0, 38), - padding: const EdgeInsets.symmetric(horizontal: 13), - textStyle: AppTypography.control, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); - final typeField = field( - l.prototypeDataType, - SizedBox( - width: double.infinity, - child: SettingSelect( - value: state.type, - entries: const { - GeoDataType.ip: 'GeoIP', - GeoDataType.domain: 'GeoSite', - }, - textStyle: AppTypography.geodataField, - onChanged: state.formBusy ? null : controller.changeType, - ), - ), - ); - final nameField = field( - l.prototypeSavedFileName, - ShadInput( - controller: controller.name, - enabled: !state.formBusy, - autofocus: true, - autocorrect: false, - textDirection: TextDirection.ltr, - constraints: const BoxConstraints(minHeight: 38), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - style: AppTypography.geodataField, - placeholderStyle: AppTypography.geodataField.copyWith( - color: palette.mutedForeground, - ), - placeholder: Text( - state.type == GeoDataType.ip ? 'geoip.dat' : 'geosite.dat', - ), - ), - ); - final urlField = field( - l.prototypeHttpsDownloadAddress, - ShadInput( - controller: controller.url, - enabled: !state.formBusy, - keyboardType: TextInputType.url, - autocorrect: false, - textDirection: TextDirection.ltr, - constraints: const BoxConstraints(minHeight: 38), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - style: AppTypography.geodataField, - placeholderStyle: AppTypography.geodataField.copyWith( - color: palette.mutedForeground, - ), - placeholder: const Text('https://example.com/geoip.dat'), - ), - ); - final actions = Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - spacing: 7, - children: [ - Flexible( - child: OutlinedButton( - style: buttonStyle, - onPressed: state.formBusy ? null : controller.toggleAdd, - child: Text(l.prototypeCancel), - ), - ), - Flexible( - child: FilledButton( - style: buttonStyle, - onPressed: state.formBusy ? null : () => controller.add(context), - child: AppActivityBuilder( - builder: (context, activity) => ButtonProgress( - busy: activity.downloading || state.formBusy, - child: Text(l.prototypeAdd), - ), - ), - ), - ), - ], - ); - return Container( - margin: EdgeInsets.only(bottom: mobile ? 12 : 16), - padding: mobile - ? const EdgeInsets.all(11) - : const EdgeInsets.symmetric(horizontal: 14, vertical: 13), - decoration: BoxDecoration( - color: palette.muted, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppRadii.card), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: mobile ? 10 : 12, - children: [ - typeField, - nameField, - urlField, - Align(alignment: AlignmentDirectional.centerEnd, child: actions), - if (state.formError != null) _error(context, state.formError!), - ], - ), - ); - } } diff --git a/lib/pages/advanced/xray/geodata/view.dart b/lib/pages/advanced/xray/geodata/view.dart index 3932af202..650898348 100644 --- a/lib/pages/advanced/xray/geodata/view.dart +++ b/lib/pages/advanced/xray/geodata/view.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:material_ui/material_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/database.dart'; @@ -37,8 +35,8 @@ class PendingGeoDataRow extends StatelessWidget { margin: const EdgeInsets.only(top: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(6), + color: palette.secondary, + borderRadius: BorderRadius.circular(AppRadii.control), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -56,7 +54,7 @@ class PendingGeoDataRow extends StatelessWidget { ), Text( l.geodataPendingDownload, - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: palette.mutedForeground, ), ), @@ -115,271 +113,70 @@ class GeoDataRows extends StatelessWidget { busy || updating.contains(file.row.id) || deleting.contains(file.row.id); @override - Widget build(BuildContext context) { - if (MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint) { - return _mobileList(context); - } - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final headers = [ - l.prototypeFileName, - l.prototypeSource, - l.prototypeSize, - l.prototypeLastSuccessfulUpdate, - if (custom) l.prototypeAction, - ]; - return LayoutBuilder( - builder: (context, constraints) { - final usableWidth = - constraints.maxWidth - 24 - (headers.length - 1) * 14; - final actionsWidth = custom - ? math.max(84.0, usableWidth * .8 / 5) - : 0.0; - final dataWidth = usableWidth - actionsWidth; - final widths = [ - for (final ratio in const [1.05, 1.3, .7, 1.15]) - dataWidth * ratio / 4.2, - if (custom) actionsWidth, - ]; - Widget line(List cells, {bool header = false}) => Container( - constraints: BoxConstraints(minHeight: header ? 42 : 56), - padding: const EdgeInsets.symmetric(horizontal: 12), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Row( - children: [ - for (var index = 0; index < cells.length; index++) ...[ - if (index > 0) const SizedBox(width: 14), - SizedBox(width: widths[index], child: cells[index]), - ], - ], - ), - ); - Widget value(String text, {bool ltr = false}) => Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textDirection: ltr ? TextDirection.ltr : null, - style: AppTypography.geodataTableBody, - ); - return DecoratedBox( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: Column( - children: [ - line([ - for (final title in headers) - Text( - title, - style: AppTypography.geodataTableHeading.copyWith( - color: palette.mutedForeground, - ), - ), - ], header: true), - for (final file in files) - Semantics( - container: true, - label: file.builtIn - ? l.prototypeDefaultRoutingData - : l.prototypeCustomRuleDataset(file.row.id), - child: line([ - _name(context, file), - value(file.sourceHost, ltr: true), - value( - file.bytes == null ? "—" : formatTraffic(file.bytes!), - ltr: true, - ), - value( - DateFormat.yMd(Localizations.localeOf(context).toString()) - .add_Hm() - .format(file.row.timestamp.toLocal()), - ), - if (custom) _actions(context, file), - ]), - ), - ], - ), - ); - }, - ); - } - - Widget _mobileList(BuildContext context) { - final palette = ColorManager.palette(context); - Widget group(List rows) => Container( - decoration: BoxDecoration( - color: palette.card, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(6), - ), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for (var index = 0; index < rows.length; index++) ...[ - if (index > 0) Divider(height: 0, color: palette.border), - _mobileRow(context, rows[index]), - ], - ], - ), - ); - if (!custom) return group(files); - return Column( - spacing: 8, - children: [ - for (final file in files) group([file]), - ], - ); - } + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [for (final file in files) _resourceRow(context, file)], + ); - Widget _mobileRow(BuildContext context, GeoDataFile file) { + Widget _resourceRow(BuildContext context, GeoDataFile file) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - final date = file.row.timestamp.toLocal(); - Widget field(String label, String value, {bool ltr = false}) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 3, - children: [ - Text( - label, - style: AppTypography.geodataMeta.copyWith( - color: palette.mutedForeground, - ), - ), - Text( - value, - style: AppTypography.geodataValue, - textDirection: ltr ? TextDirection.ltr : null, - ), - ], - ); return Semantics( container: true, label: file.builtIn ? l.prototypeDefaultRoutingData : l.prototypeCustomRuleDataset(file.row.id), - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 9, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + _name(context, file), + Semantics( + label: l.prototypeSource, + child: Text( + file.sourceHost, + textDirection: TextDirection.ltr, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + ), + Wrap( + spacing: 16, + runSpacing: 8, children: [ - Padding( - padding: EdgeInsetsDirectional.only( - end: custom - ? updating.contains(file.row.id) - ? 106 - : 82 - : 0, - ), - child: InkWell( - onTap: () => onOpen(file), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 3, - children: [ - Text( - l.prototypeFileName, - style: AppTypography.geodataMeta.copyWith( - color: palette.mutedForeground, - ), - ), - Row( - children: [ - Flexible( - child: Text( - file.fileName, - textDirection: TextDirection.ltr, - style: AppTypography.geodataValue.copyWith( - color: palette.primary, - ), - ), - ), - const SizedBox(width: 6), - Icon( - LucideIcons.chevronRightDir, - size: 15, - color: palette.primary, - ), - ], - ), - ], - ), + Semantics( + label: l.prototypeSize, + child: Text( + file.bytes == null ? '—' : formatTraffic(file.bytes!), + textDirection: TextDirection.ltr, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, ), ), ), - field(l.prototypeSource, file.sourceHost, ltr: true), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - Expanded( - child: field( - l.prototypeSize, - file.bytes == null ? "—" : formatTraffic(file.bytes!), - ltr: true, - ), - ), - Expanded( - child: field( - l.prototypeLastSuccessfulUpdate, - DateFormat.yMd( - Localizations.localeOf(context).toString(), - ).add_Hm().format(date), - ), + Semantics( + label: l.prototypeLastSuccessfulUpdate, + child: Text( + DateFormat.yMd(Localizations.localeOf(context).toString()) + .add_Hm() + .format(file.row.timestamp.toLocal()), + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, ), - ], + ), ), ], ), - ), - if (custom) - PositionedDirectional( - top: 7, - end: 8, - child: Row( - children: [ - TextButton( - style: TextButton.styleFrom( - minimumSize: const Size(0, 30), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: AppTypography.geodataAction, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.standard, - ), - onPressed: _busy(file) ? null : () => onUpdate(file), - child: AppActivityBuilder( - builder: (context, activity) => ButtonProgress( - busy: - activity.downloading && - (busy || updating.contains(file.row.id)), - child: Text(l.prototypeUpdate), - ), - ), - ), - IconButton( - tooltip: l.prototypeDeleteCustomDataset, - style: IconButton.styleFrom( - foregroundColor: palette.destructive, - minimumSize: const Size.square(30), - maximumSize: const Size.square(30), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: _busy(file) ? null : () => onDelete(file), - icon: deleting.contains(file.row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.trash2, size: 16), - ), - ], + if (custom) + Align( + alignment: AlignmentDirectional.centerStart, + child: _actions(context, file), ), - ), - ], + ], + ), ), ); } @@ -387,9 +184,18 @@ class GeoDataRows extends StatelessWidget { Widget _name(BuildContext context, GeoDataFile file) => TextButton( style: TextButton.styleFrom( alignment: AlignmentDirectional.centerStart, - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric(vertical: 8), - textStyle: AppTypography.geodataTableBody, + minimumSize: Size( + 0, + AppLayout.touchExtent( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 48 + : 42, + platform: Theme.of(context).platform, + ), + ), + padding: EdgeInsets.zero, + textStyle: AppTypography.rowTitle, + foregroundColor: ColorManager.palette(context).foreground, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), onPressed: () => onOpen(file), @@ -406,30 +212,33 @@ class GeoDataRows extends StatelessWidget { Widget _actions(BuildContext context, GeoDataFile file) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - return Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - spacing: 2, + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 8, + runSpacing: 8, children: [ - Flexible( - child: TextButton( - style: TextButton.styleFrom( - minimumSize: const Size(0, 36), - padding: const EdgeInsets.symmetric(horizontal: 4), - textStyle: AppTypography.geodataDesktopAction, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + TextButton( + style: TextButton.styleFrom( + minimumSize: Size( + 0, + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 48 + : 42, ), - onPressed: _busy(file) ? null : () => onUpdate(file), - child: AppActivityBuilder( - builder: (context, activity) => ButtonProgress( - busy: - activity.downloading && - (busy || updating.contains(file.row.id)), - child: Text( - l.prototypeUpdate, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + padding: const EdgeInsets.symmetric(horizontal: 4), + textStyle: AppTypography.geodataDesktopAction, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: _busy(file) ? null : () => onUpdate(file), + child: AppActivityBuilder( + builder: (context, activity) => ButtonProgress( + busy: + activity.downloading && + (busy || updating.contains(file.row.id)), + child: Text( + l.prototypeUpdate, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ), @@ -438,9 +247,9 @@ class GeoDataRows extends StatelessWidget { tooltip: l.prototypeDeleteCustomDataset, style: IconButton.styleFrom( foregroundColor: palette.destructive, - fixedSize: const Size.square(30), - minimumSize: const Size.square(30), - maximumSize: const Size.square(30), + minimumSize: Size.square( + AppLayout.touchExtent(40, platform: Theme.of(context).platform), + ), padding: EdgeInsets.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), diff --git a/lib/pages/advanced/xray/lan_proxy/page.dart b/lib/pages/advanced/xray/lan_proxy/page.dart index 73db3b663..f0efc5fe3 100644 --- a/lib/pages/advanced/xray/lan_proxy/page.dart +++ b/lib/pages/advanced/xray/lan_proxy/page.dart @@ -29,9 +29,12 @@ class LanProxyPage extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return PolicyDetailScaffold( title: l.lanProxyTitle, + description: l.lanProxyNextStartNotice, help: [ l.lanProxyProtocolHint, l.lanProxyRawJsonNotice, @@ -53,22 +56,11 @@ class LanProxyPage extends StatelessWidget { ), ) : Padding( - padding: EdgeInsets.fromLTRB( - gutter, - mobile ? 16 : 24, - gutter, - 24, - ), + padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ - Text( - l.lanProxyNextStartNotice, - style: AppTypography.settingsDetailNote.copyWith( - color: palette.mutedForeground, - ), - ), SettingSection( title: l.lanProxyTitle, icon: LucideIcons.network, @@ -81,14 +73,16 @@ class LanProxyPage extends StatelessWidget { valueTextDirection: TextDirection.ltr, ), Padding( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: 9, + spacing: 8, children: [ Text( l.lanProxyPort, - style: AppTypography.settingsInput, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, + ), ), TextField( key: const ValueKey('lan-proxy-port'), @@ -113,7 +107,7 @@ class LanProxyPage extends StatelessWidget { ), Text( l.lanProxySecurityHint, - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: palette.mutedForeground, ), ), diff --git a/lib/pages/advanced/xray/log/page.dart b/lib/pages/advanced/xray/log/page.dart index 67373f8a0..ea5863b6f 100644 --- a/lib/pages/advanced/xray/log/page.dart +++ b/lib/pages/advanced/xray/log/page.dart @@ -132,7 +132,10 @@ class _LogFileViewerViewState extends State { (mobile ? AppTypography.runtimeCodeMobile : AppTypography.runtimeCodeDesktop) - .copyWith(color: palette.mutedStrong), + .copyWith( + color: palette.foreground, + fontFamily: AppFontFamily.mono, + ), ); }, ), diff --git a/lib/pages/advanced/xray/log/settings.dart b/lib/pages/advanced/xray/log/settings.dart new file mode 100644 index 000000000..8d2bfe1a2 --- /dev/null +++ b/lib/pages/advanced/xray/log/settings.dart @@ -0,0 +1,234 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/xray/controller.dart'; +import 'package:onexray/pages/advanced/xray/log/params.dart'; +import 'package:onexray/pages/shared/alert.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_help.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/service/shared/failure.dart'; + +class XrayLogSettingsPage extends StatelessWidget { + const XrayLogSettingsPage({ + super.key, + required this.onLog, + this.createController, + }); + + final void Function(BuildContext, LogFileViewerParams) onLog; + final XrayRuntimeController Function()? createController; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => createController?.call() ?? XrayRuntimeController(), + child: BlocBuilder( + builder: (context, state) { + final controller = context.read(); + final l = AppLocalizations.of(context)!; + void cancel() { + if (!controller.busy) Navigator.of(context).pop(); + } + + final available = + !controller.loading && + controller.base != null && + !state.systemExtension; + Widget toggle(String title, String field, bool value) => SettingRow( + title: title, + trailing: AppSwitch( + value: value, + enabled: !controller.busy, + onChanged: controller.busy + ? null + : (value) => controller.setLog(field, value), + ), + ); + return PopScope( + canPop: !controller.busy, + child: Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(l.prototypeLogs), + leading: BackButton(onPressed: cancel), + actions: [ + PageHelpButton( + title: l.prototypeLogs, + messages: [l.prototypeManagedLogNotice], + ), + ], + ), + body: SafeArea( + child: controller.loading + ? const Center(child: CircularProgressIndicator()) + : state.systemExtension + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text(l.logsUnavailableSystemExtension), + ), + ) + : SettingsPageScroll( + desktopMaxWidth: AppLayout.advancedMaxWidth, + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? 16 + : 28, + 20, + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? 16 + : 28, + 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 20, + children: [ + if (available) ...[ + SettingSection( + title: l.xrayLogPreferences, + padding: EdgeInsets.zero, + children: [ + toggle( + l.prototypeRecordXrayLogs, + 'enabled', + controller.logsEnabled, + ), + if (controller.logsEnabled) ...[ + SettingRow( + title: l.prototypeErrorLogLevel, + trailing: SettingSelect( + value: controller.level, + textStyle: AppTypography.rowValue, + entries: { + 'error': l.prototypeErrorsOnly, + 'warning': l.prototypeWarning, + 'info': 'Info', + 'debug': 'Debug', + }, + onChanged: controller.busy + ? null + : controller.setLevel, + ), + ), + toggle( + l.prototypeRecordDnsQueries, + 'recordDns', + controller.recordDns, + ), + toggle( + l.prototypeHideLogIpAddresses, + 'maskIp', + controller.maskIp, + ), + ], + ], + ), + SettingSection( + title: l.xrayLogFiles, + padding: EdgeInsets.zero, + children: [ + for (final access in [true, false]) + SettingRow( + title: access + ? l.prototypeAccessLog + : l.prototypeErrorLog, + leading: Icon( + access + ? LucideIcons.fileText + : LucideIcons.terminal, + ), + enabled: controller.logPath(access) != null, + showChevron: true, + onTap: controller.logPath(access) == null + ? null + : () => controller.openLog( + context, + access, + onLog, + ), + ), + ], + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: controller.busy + ? null + : () { + controller.restoreDefaults(); + ContextAlert.showToast( + context, + l.settingsDefaultsRestored, + ); + }, + icon: const Icon( + LucideIcons.rotateCcw, + size: 16, + ), + label: Text(l.prototypeRestoreDefaults), + ), + ), + ], + if (controller.failed) + SelectableText( + appFailureMessage(l, state.failure), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + if (controller.base == null) + TextButton( + onPressed: controller.load, + child: Text(l.prototypeRetry), + ), + ], + ), + ), + ), + bottomNavigationBar: available + ? PageActionBar( + children: [ + OutlinedButton( + onPressed: controller.busy ? null : cancel, + child: Text(l.prototypeCancel), + ), + FilledButton( + onPressed: controller.busy || controller.runtimeBusy + ? null + : () async { + await controller.save(context); + if (context.mounted && + controller.isPageActive && + !controller.dirty && + !controller.failed) { + Navigator.of(context).pop(true); + } + }, + child: ButtonProgress( + busy: controller.saving, + child: Text( + controller.connected + ? l.prototypeSaveAndReconnect + : l.prototypeSave, + ), + ), + ), + ], + ) + : null, + ), + ); + }, + ), + ); +} diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index 63d7f8d07..cf2ee7265 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -8,14 +8,13 @@ import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tab_visibility.dart'; +import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; import 'package:onexray/pages/advanced/xray/config/params.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; -import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -30,6 +29,7 @@ class XrayRuntimePage extends StatelessWidget { required this.onLog, required this.onConfig, this.onDiagnostics, + this.onLogSettings, this.onLocalApi, this.onLanProxy, this.onServerDialing, @@ -41,6 +41,7 @@ class XrayRuntimePage extends StatelessWidget { final void Function(BuildContext, LogFileViewerParams) onLog; final void Function(BuildContext, ConfigFileViewerParams) onConfig; final void Function(BuildContext)? onDiagnostics; + final Future Function(BuildContext)? onLogSettings; final void Function(BuildContext)? onLocalApi; final Future Function(BuildContext)? onLanProxy; final void Function(BuildContext)? onServerDialing; @@ -55,48 +56,13 @@ class XrayRuntimePage extends StatelessWidget { final disabled = controller.busy; final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 15.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return AdvancedTabVisibility( tabIndex: 1, onChanged: controller.reader.setVisible, child: Scaffold( - bottomNavigationBar: - controller.base == null || state.systemExtension - ? null - : PageActionBar( - maxWidth: AppLayout.advancedMaxWidth, - expandDesktop: true, - horizontalPadding: gutter, - spacing: 13, - children: [ - OutlinedButton.icon( - onPressed: disabled - ? null - : () { - controller.restoreDefaults(); - ContextAlert.showToast( - context, - l.settingsDefaultsRestored, - ); - }, - icon: const Icon(LucideIcons.rotateCcw, size: 16), - label: Text(l.prototypeRestoreDefaults), - ), - FilledButton( - onPressed: disabled || controller.runtimeBusy - ? null - : () => controller.save(context), - child: ButtonProgress( - busy: controller.saving, - child: Text( - controller.connected - ? l.prototypeSaveAndReconnect - : l.prototypeSave, - ), - ), - ), - ], - ), body: controller.loading ? const Center(child: CircularProgressIndicator()) : controller.base == null @@ -118,19 +84,19 @@ class XrayRuntimePage extends StatelessWidget { child: Padding( padding: EdgeInsetsDirectional.fromSTEB( gutter, - mobile ? 16 : 24, + 20, gutter, mobile ? 24 : 28, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ _section( icon: LucideIcons.activity, title: l.prototypeRuntimeStatus, children: [ - _runtimeCard(context, controller, l, mobile), + _runtimeCard(context, controller, l), if (onDiagnostics != null) _pushRow( context, @@ -207,83 +173,22 @@ class XrayRuntimePage extends StatelessWidget { _section( icon: LucideIcons.fileText, title: l.prototypeLogs, - action: PageHelpButton( - title: l.prototypeLogs, - messages: [l.prototypeManagedLogNotice], - ), children: [ - _logToggle( - controller: controller, - title: l.prototypeRecordXrayLogs, - field: 'enabled', - value: controller.logsEnabled, - mobile: mobile, - ), - if (controller.logsEnabled) ...[ - SettingRow( - title: l.prototypeErrorLogLevel, - titleStyle: mobile - ? AppTypography.settingsFieldTitle - : AppTypography.settingsRow, - minHeight: mobile ? 52 : 56, - trailing: SizedBox( - width: mobile - ? (MediaQuery.sizeOf(context).width * - .48) - .clamp(0.0, 190.0) - .toDouble() - : 190, - child: SettingSelect( - value: controller.level, - minHeight: mobile ? 36 : 38, - textStyle: mobile - ? AppTypography.runtimeSelector - : AppTypography - .runtimeDesktopSelector, - entries: { - 'error': l.prototypeErrorsOnly, - 'warning': l.prototypeWarning, - 'info': 'Info', - 'debug': 'Debug', - }, - onChanged: disabled - ? null - : controller.setLevel, - ), - ), - ), - _logToggle( - controller: controller, - title: l.prototypeRecordDnsQueries, - field: 'recordDns', - value: controller.recordDns, - mobile: mobile, - ), - _logToggle( - controller: controller, - title: l.prototypeHideLogIpAddresses, - field: 'maskIp', - value: controller.maskIp, - mobile: mobile, - ), - ], _pushRow( context, mobile: mobile, - title: l.prototypeAccessLog, + title: l.prototypeLogs, icon: LucideIcons.fileText, - enabled: controller.logPath(true) != null, - onTap: () => - controller.openLog(context, true, onLog), - ), - _pushRow( - context, - mobile: mobile, - title: l.prototypeErrorLog, - icon: LucideIcons.terminal, - enabled: controller.logPath(false) != null, - onTap: () => - controller.openLog(context, false, onLog), + enabled: !disabled && onLogSettings != null, + onTap: () async { + final saved = await onLogSettings?.call( + context, + ); + if (saved == true && + controller.isPageActive) { + await controller.load(showLoading: false); + } + }, ), ], ), @@ -303,12 +208,9 @@ class XrayRuntimePage extends StatelessWidget { SettingRow( title: l.lanProxyTitle, titleMaxLines: 4, - titleStyle: AppTypography.settingsRow, + titleStyle: AppTypography.rowTitle, minHeight: 64, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 12, - ), + contentPadding: const EdgeInsets.all(12), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -415,117 +317,31 @@ class XrayRuntimePage extends StatelessWidget { BuildContext context, XrayRuntimeController controller, AppLocalizations l, - bool mobile, ) { - Widget metric( - String label, - String value, { - bool first = false, - bool last = false, - }) { - final text = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 4, - children: [ - Text( - label, - style: - (mobile - ? AppTypography.runtimeLabel - : AppTypography.runtimeDesktopLabel) - .copyWith(color: ColorManager.secondaryText(context)), + final palette = ColorManager.palette(context); + return Column( + children: [ + PolicyValueRow( + title: l.prototypeXrayCore, + value: controller.connected + ? l.prototypeRunningNormally + : controller.statusLabel(l), + valueTextDirection: null, + valueStyle: AppTypography.rowTitle.copyWith( + color: controller.connected + ? palette.runningText + : palette.foreground, ), - Text( - value, - style: - (mobile - ? AppTypography.runtimeValue - : AppTypography.runtimeDesktopValue) - .copyWith(color: ColorManager.primaryText(context)), - maxLines: first ? null : 1, - overflow: first ? null : TextOverflow.ellipsis, - textDirection: first ? null : TextDirection.ltr, - ), - ], - ); - return Container( - padding: EdgeInsets.symmetric( - horizontal: mobile ? 10 : 18, - vertical: 4, ), - decoration: BoxDecoration( - border: last - ? null - : BorderDirectional( - end: BorderSide(color: ColorManager.border(context)), - ), + PolicyValueRow( + title: l.prototypeVersion, + value: controller.xrayVersion, ), - child: first - ? Row( - children: [ - Icon( - LucideIcons.activity, - size: 20, - color: ColorManager.palette(context).primary, - ), - const SizedBox(width: 9), - Expanded(child: text), - ], - ) - : text, - ); - } - - return ConstrainedBox( - constraints: BoxConstraints(minHeight: mobile ? 70 : 76), - child: Row( - children: [ - Expanded( - flex: mobile ? 125 : 145, - child: metric( - l.prototypeXrayCore, - controller.connected - ? l.prototypeRunningNormally - : controller.statusLabel(l), - first: true, - ), - ), - Expanded( - flex: mobile ? 72 : 80, - child: metric(l.prototypeVersion, controller.xrayVersion), - ), - Expanded( - flex: mobile ? 85 : 90, - child: metric(l.prototypeUptime, controller.uptime, last: true), - ), - ], - ), + PolicyValueRow(title: l.prototypeUptime, value: controller.uptime), + ], ); } - Widget _logToggle({ - required XrayRuntimeController controller, - required String title, - required String field, - required bool value, - required bool mobile, - }) => SettingRow( - title: title, - titleMaxLines: 4, - titleStyle: mobile - ? AppTypography.settingsFieldTitle - : AppTypography.settingsRow, - minHeight: mobile ? 52 : 56, - trailing: AppSwitch( - value: value, - enabled: !controller.busy, - onChanged: controller.busy - ? null - : (value) => controller.setLog(field, value), - ), - ); - Widget _pushRow( BuildContext context, { required bool mobile, @@ -538,19 +354,16 @@ class XrayRuntimePage extends StatelessWidget { }) => SettingRow( title: title, subtitle: subtitle, - titleStyle: mobile - ? AppTypography.settingsFieldTitle - : AppTypography.settingsRow, - subtitleStyle: mobile - ? AppTypography.runtimePushHint - : AppTypography.runtimeDesktopPushHint, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 10 : 16, - vertical: 10, - ), - minHeight: 64, + titleStyle: AppTypography.rowTitle, + subtitleStyle: AppTypography.supporting, + contentPadding: const EdgeInsets.all(12), + minHeight: mobile ? 56 : 48, decorateLeading: false, - leading: Icon(icon, size: 19, color: ColorManager.palette(context).primary), + leading: Icon( + icon, + size: 20, + color: ColorManager.palette(context).mutedForeground, + ), trailing: trailing ?? _chevron(context), enabled: enabled, onTap: onTap, diff --git a/lib/pages/advanced/xray/ping/page.dart b/lib/pages/advanced/xray/ping/page.dart index 10892ea2c..d55e8c31d 100644 --- a/lib/pages/advanced/xray/ping/page.dart +++ b/lib/pages/advanced/xray/ping/page.dart @@ -28,9 +28,12 @@ class PingPage extends StatelessWidget { final palette = ColorManager.palette(context); final width = MediaQuery.sizeOf(context).width; final mobile = width <= AppLayout.mobileBreakpoint; - final gutter = mobile ? 14.0 : AppSpacing.advancedDesktopGutter(width); + final gutter = mobile + ? AppSpacing.mobilePage + : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.prototypeSpeedTest), actions: [ const AppActivityIndicator(downloading: false), @@ -45,7 +48,7 @@ class PingPage extends StatelessWidget { ), bottomNavigationBar: PageActionBar( maxWidth: AppLayout.advancedMaxWidth, - expandDesktop: true, + expandDesktop: false, horizontalPadding: mobile ? null : gutter, spacing: mobile ? AppSpacing.actionGap : 13, children: [ @@ -80,8 +83,9 @@ class PingPage extends StatelessWidget { liveRegion: true, child: SelectableText( state.error!, - style: AppTypography.settingsDetailNote - .copyWith(color: palette.destructive), + style: AppTypography.supporting.copyWith( + color: palette.destructive, + ), ), ), TextButton( @@ -98,7 +102,7 @@ class PingPage extends StatelessWidget { child: Padding( padding: EdgeInsets.fromLTRB( gutter, - mobile ? 16 : 24, + 20, gutter, mobile ? 26 : 24, ), @@ -115,8 +119,8 @@ class PingPage extends StatelessWidget { titleStyle: AppTypography.settingsSelect, contentPadding: const EdgeInsetsDirectional.symmetric( - horizontal: 14, - vertical: 10, + horizontal: 12, + vertical: 12, ), trailing: SettingSelect( value: value.timeout, @@ -153,8 +157,8 @@ class PingPage extends StatelessWidget { titleStyle: AppTypography.settingsSelect, contentPadding: const EdgeInsetsDirectional.symmetric( - horizontal: 14, - vertical: 10, + horizontal: 12, + vertical: 12, ), trailing: SettingSelect( value: value.url.name, @@ -175,19 +179,21 @@ class PingPage extends StatelessWidget { ), if (value.url == PingUrl.custom) Padding( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric( + vertical: 12, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l.prototypeCustomUrl, - style: AppTypography.settingsInput + style: AppTypography.fieldLabel .copyWith( color: palette.foreground, ), ), - const SizedBox(height: 9), + const SizedBox(height: 8), TextField( key: const ValueKey('ping-custom-url'), controller: @@ -209,14 +215,6 @@ class PingPage extends StatelessWidget { : null, errorMaxLines: 3, isDense: true, - constraints: const BoxConstraints( - minHeight: 40, - ), - contentPadding: - const EdgeInsets.symmetric( - horizontal: 10, - vertical: 8, - ), hintText: 'https://example.com/generate_204', hintTextDirection: TextDirection.ltr, hintStyle: AppTypography.settingsInput @@ -259,7 +257,7 @@ class PingPage extends StatelessWidget { const SizedBox(height: 16), Text( l.pingVpnAccuracyHint, - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: palette.mutedForeground, ), ), @@ -272,8 +270,9 @@ class PingPage extends StatelessWidget { liveRegion: true, child: SelectableText( state.error!, - style: AppTypography.settingsDetailNote - .copyWith(color: palette.destructive), + style: AppTypography.supporting.copyWith( + color: palette.destructive, + ), ), ), ), diff --git a/lib/pages/advanced/xray/runtime_code_view.dart b/lib/pages/advanced/xray/runtime_code_view.dart index 58e6e33fc..8c5ceb59a 100644 --- a/lib/pages/advanced/xray/runtime_code_view.dart +++ b/lib/pages/advanced/xray/runtime_code_view.dart @@ -40,11 +40,11 @@ class RuntimeCodeScaffold extends StatelessWidget { final horizontal = mobile ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); - final top = mobile ? AppSpacing.mobilePage : AppSpacing.desktopPageTop; + const top = 20.0; final bottom = mobile ? 18.0 : 24.0; final pill = Material( color: palette.surfaceHover, - borderRadius: BorderRadius.circular(AppRadii.pill), + borderRadius: BorderRadius.circular(AppRadii.control), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onStatusPressed, @@ -82,7 +82,10 @@ class RuntimeCodeScaffold extends StatelessWidget { style: OutlinedButton.styleFrom( minimumSize: Size( 0, - AppLayout.touchExtent(36, platform: Theme.of(context).platform), + AppLayout.touchExtent( + mobile ? 48 : 42, + platform: Theme.of(context).platform, + ), ), padding: const EdgeInsets.symmetric(horizontal: 11), textStyle: AppTypography.runtimeCodeAction, @@ -102,7 +105,7 @@ class RuntimeCodeScaffold extends StatelessWidget { ), ); return Scaffold( - appBar: PageAppBar(title: Text(title)), + appBar: PageAppBar.forPage(context, title: Text(title)), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: AppLayout.advancedMaxWidth, @@ -143,7 +146,7 @@ class RuntimeCodeScaffold extends StatelessWidget { key: const ValueKey('runtime-code-panel'), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: palette.muted, + color: palette.card, border: Border.all(color: palette.border), borderRadius: BorderRadius.circular( AppRadii.card, diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index 43f79d6f6..879ff3179 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -87,7 +87,8 @@ class _JsonConfigurationEditorState extends State { if (!didPop) controller.closePage(context); }, child: Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text( controller.advanced ? l10n.routingAdvancedJson @@ -121,18 +122,15 @@ class _JsonConfigurationEditorState extends State { bottom: false, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( - mobile ? 14 : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageTop, - mobile ? 14 : AppSpacing.page, + mobile ? 16 : AppSpacing.page, + 16, + mobile ? 16 : AppSpacing.page, mobile ? 12 : AppSpacing.desktopPageBottom, ), child: ResponsiveContent( desktopMaxWidth: AppLayout.routingMaxWidth, child: RoutingCard( - padding: EdgeInsets.symmetric( - horizontal: mobile ? 12 : 20, - vertical: mobile ? 14 : 20, - ), + padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -153,21 +151,23 @@ class _JsonConfigurationEditorState extends State { SizedBox(height: gap), Text( l10n.prototypeConfigurationName, - style: AppTypography.rawField.copyWith( - color: palette.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), ), const SizedBox(height: 8), - SizedBox( - height: AppLayout.touchExtent(44), + ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent(44), + ), child: TextField( controller: controller.name, maxLength: 32, enabled: state.loaded, - style: AppTypography.rawField, + style: AppTypography.rowValue, decoration: InputDecoration( hintText: l10n.prototypeConfigurationName, - hintStyle: AppTypography.rawField + hintStyle: AppTypography.rowValue .copyWith( color: palette.mutedForeground, ), @@ -184,8 +184,8 @@ class _JsonConfigurationEditorState extends State { controller.advanced ? l10n.routingAdvancedTemplate : l10n.prototypeCompleteXrayConfiguration, - style: AppTypography.rawField.copyWith( - color: palette.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), ), const SizedBox(height: 8), diff --git a/lib/pages/connect/page.dart b/lib/pages/connect/page.dart index fe84de03c..d513505e7 100644 --- a/lib/pages/connect/page.dart +++ b/lib/pages/connect/page.dart @@ -39,7 +39,8 @@ class _ConnectPageState extends State { child: PageVisibility( onChanged: controller.setPageVisible, child: Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(AppLocalizations.of(context)!.prototypeConnect), actions: const [AppActivityIndicator()], ), diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index 50b209497..e7e0826df 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -70,7 +70,8 @@ class _CustomRoutingEditorPageState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final name = state.name.trim(); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(name.isEmpty ? l.prototypeCustomRouting : name), leading: BackButton(onPressed: () => controller.cancel(context)), actions: [ @@ -88,9 +89,9 @@ class _CustomRoutingEditorPageState extends State { absorbing: state.editingBlocked, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( - mobile ? 14 : 28, - mobile ? 12 : AppSpacing.desktopPageTop, - mobile ? 14 : 28, + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, mobile ? 18 : AppSpacing.desktopPageBottom, ), child: ResponsiveContent( @@ -343,7 +344,7 @@ class _CustomRoutingEditorPageState extends State { maxLength: 32, constraints: const BoxConstraints(minHeight: 40), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - style: AppTypography.routeIdentityLabel, + style: AppTypography.rowValue, decoration: error == null ? null : ShadDecoration( @@ -365,9 +366,7 @@ class _CustomRoutingEditorPageState extends State { label: Text(l.prototypeDeleteRoute), ); return RoutingCard( - padding: mobile - ? const EdgeInsets.all(12) - : const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 10, @@ -380,8 +379,8 @@ class _CustomRoutingEditorPageState extends State { Expanded( child: Text( l.prototypeRouteName, - style: AppTypography.routeIdentityLabel.copyWith( - color: palette.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), ), ), diff --git a/lib/pages/connect/routing/custom/rule_page.dart b/lib/pages/connect/routing/custom/rule_page.dart index 1ba78a4c5..fd8def369 100644 --- a/lib/pages/connect/routing/custom/rule_page.dart +++ b/lib/pages/connect/routing/custom/rule_page.dart @@ -52,17 +52,17 @@ class _CustomRoutingRulePageState extends State { return BlocProvider.value( value: controller, child: Scaffold( - appBar: PageAppBar(title: Text(l.prototypeEditRule)), + appBar: PageAppBar.forPage(context, title: Text(l.prototypeEditRule)), body: SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( - mobile ? 14 : 28, - 12, - mobile ? 14 : 28, + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, 18, ), child: ResponsiveContent( - desktopMaxWidth: 800, + desktopMaxWidth: AppLayout.routingMaxWidth, child: CustomRoutingRuleForm(controller: controller), ), ), @@ -121,8 +121,8 @@ class CustomRoutingRuleForm extends StatelessWidget { children: [ Text( l.prototypeRuleName, - style: AppTypography.routeIdentityLabel.copyWith( - color: palette.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), ), ShadInput( @@ -134,7 +134,7 @@ class CustomRoutingRuleForm extends StatelessWidget { horizontal: 10, vertical: 8, ), - style: AppTypography.routeIdentityLabel, + style: AppTypography.rowValue, ), ], ), @@ -539,9 +539,9 @@ class CustomRoutingRuleForm extends StatelessWidget { focusNode: focus, constraints: const BoxConstraints(minHeight: 40), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - style: AppTypography.routingInput, + style: AppTypography.rowValue, placeholder: Text(hint), - placeholderStyle: AppTypography.routingInput.copyWith( + placeholderStyle: AppTypography.rowValue.copyWith( color: ColorManager.palette(context).mutedForeground, ), autocorrect: false, diff --git a/lib/pages/connect/routing/options_form.dart b/lib/pages/connect/routing/options_form.dart index 4391de218..221f49e91 100644 --- a/lib/pages/connect/routing/options_form.dart +++ b/lib/pages/connect/routing/options_form.dart @@ -446,7 +446,13 @@ class _OptionPicker extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 6, children: [ - if (label != null) Text(label!, style: AppTypography.routingRowTitle), + if (label != null) + Text( + label!, + style: AppTypography.fieldLabel.copyWith( + color: ColorManager.palette(context).foreground, + ), + ), AppMenuButton( entries: [ for (final option in labels.entries) @@ -460,6 +466,7 @@ class _OptionPicker extends StatelessWidget { Expanded( child: Text( labels[value] ?? value, + style: AppTypography.rowValue, semanticsLabel: semanticLabel == null ? null : '$semanticLabel: ${labels[value] ?? value}', diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart index a4cc0988a..5ed2a8a4e 100644 --- a/lib/pages/connect/routing/page.dart +++ b/lib/pages/connect/routing/page.dart @@ -9,6 +9,7 @@ import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/shared/failure.dart'; @@ -43,7 +44,17 @@ class _RoutingPageState extends State { final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; final palette = ColorManager.palette(context); return Scaffold( - appBar: PageAppBar(title: Text(l.navigationRouting)), + appBar: PageAppBar.forPage( + context, + title: Text(l.navigationRouting), + description: l.routingPageDescription, + headingAction: ShadButton( + enabled: !state.busy && state.profiles.length < 3, + onPressed: () => controller.edit(context), + leading: const Icon(LucideIcons.plus, size: 18), + child: Text(l.prototypeNewCustomRoute), + ), + ), body: SafeArea( child: settings == null ? Center( @@ -75,114 +86,91 @@ class _RoutingPageState extends State { ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ - Text( - l.routingPageDescription, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: palette.mutedForeground), - ), if (state.completeConfiguration) ShadAlert( icon: const Icon(LucideIcons.info, size: 18), description: Text(l.routingStoredPreferenceNotice), ), - SettingSection( - title: l.prototypeTrafficMethod, - padding: EdgeInsets.zero, - dividerIndent: 0, - children: [ - _choice( - context, - title: l.prototypeSmartRouting, - description: l.prototypeSmartRoutingDescription, - selected: - settings.trafficMode == TrafficMode.smart, - icon: LucideIcons.sparkles, - useKey: const ValueKey('routing-use-smart'), - onUse: () => - controller.select(context, TrafficMode.smart), - onEdit: () => context.pushScoped( - AppPageDestination.smartRouting, - ), - ), - _choice( + _choice( + context, + title: l.prototypeSmartRouting, + description: l.prototypeSmartRoutingDescription, + selected: settings.trafficMode == TrafficMode.smart, + icon: LucideIcons.route, + useKey: const ValueKey('routing-use-smart'), + onUse: () => + controller.select(context, TrafficMode.smart), + onEdit: () => context.pushScoped( + AppPageDestination.smartRouting, + ), + ), + _choice( + context, + title: l.prototypeAllViaVpn, + description: l.prototypeAllViaVpnDescription, + selected: settings.trafficMode == TrafficMode.allVpn, + icon: LucideIcons.shield, + useKey: const ValueKey('routing-use-all'), + onUse: () => + controller.select(context, TrafficMode.allVpn), + ), + Text( + l.prototypeCustomRouting, + style: AppTypography.sectionTitle, + ), + Text( + l.prototypeCustomRouteLimit, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + for (final row in state.profiles) + _choice( + context, + key: ValueKey('routing-profile-${row.id}'), + title: row.name, + description: state.profileErrors.containsKey(row.id) + ? l.prototypeTemporarilyUnavailable + : row.advanced + ? l.routingAdvancedJson + : '${l.routingNormalConfiguration} · ${l.prototypeRuleCount(controller.configurationFor(row)?.ruleCount ?? 0)}', + canUse: controller.canUseProfile(row.id), + useKey: ValueKey('routing-use-profile-${row.id}'), + diagnostic: state.profileErrors[row.id] == null + ? null + : appFailureMessage( + l, + state.profileErrors[row.id], + ), + selected: + settings.trafficMode == TrafficMode.custom && + settings.customId == row.id, + icon: row.advanced + ? LucideIcons.fileJson + : LucideIcons.listOrdered, + onUse: () => controller.select( context, - title: l.prototypeAllViaVpn, - description: l.prototypeAllViaVpnDescription, - selected: - settings.trafficMode == TrafficMode.allVpn, - icon: LucideIcons.shield, - useKey: const ValueKey('routing-use-all'), - onUse: () => controller.select( - context, - TrafficMode.allVpn, - ), + TrafficMode.custom, + id: row.id, ), - ], - ), - SettingSection( - title: l.prototypeCustomRouting, - description: l.prototypeCustomRouteLimit, - action: ShadButton.outline( - size: ShadButtonSize.sm, - enabled: !state.busy && state.profiles.length < 3, - onPressed: () => controller.edit(context), - leading: const Icon(LucideIcons.plus, size: 16), - child: Text(l.prototypeNewCustomRoute), + onEdit: () => controller.edit(context, row), + onDelete: () => controller.delete(context, row), ), - padding: EdgeInsets.zero, - dividerIndent: 0, - children: [ - for (final row in state.profiles) - _choice( - context, - key: ValueKey('routing-profile-${row.id}'), - title: row.name, - description: - state.profileErrors.containsKey(row.id) - ? l.prototypeTemporarilyUnavailable - : row.advanced - ? l.routingAdvancedJson - : '${l.routingNormalConfiguration} · ${l.prototypeRuleCount(controller.configurationFor(row)?.ruleCount ?? 0)}', - canUse: controller.canUseProfile(row.id), - useKey: ValueKey( - 'routing-use-profile-${row.id}', - ), - diagnostic: state.profileErrors[row.id] == null - ? null - : appFailureMessage( - l, - state.profileErrors[row.id], - ), - selected: - settings.trafficMode == - TrafficMode.custom && - settings.customId == row.id, - icon: row.advanced - ? LucideIcons.fileJson - : LucideIcons.listOrdered, - onUse: () => controller.select( - context, - TrafficMode.custom, - id: row.id, - ), - onEdit: () => controller.edit(context, row), - onDelete: () => controller.delete(context, row), - ), - if (state.profiles.isEmpty) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - l.prototypeCustomRoutingDescription, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: palette.mutedForeground, - ), + if (state.profiles.isEmpty) + SettingSection( + title: l.prototypeNoCustomRoutes, + padding: EdgeInsets.zero, + children: [ + Text( + l.prototypeCustomRoutingDescription, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, ), ), - ], - ), + ], + ), SettingSection( title: l.prototypeRoutingData, padding: EdgeInsets.zero, @@ -247,9 +235,17 @@ class _RoutingPageState extends State { return Semantics( key: key, selected: selected, - child: Container( - color: selected ? palette.selectedSurface : null, - padding: const EdgeInsets.all(16), + child: ShadCard( + width: double.infinity, + radius: BorderRadius.circular(AppRadii.card), + shadows: const [], + backgroundColor: selected ? palette.selectedSurface : palette.card, + border: ShadBorder.all( + color: selected + ? palette.primary.withValues(alpha: .2) + : palette.border, + ), + padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 12, @@ -270,25 +266,35 @@ class _RoutingPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, spacing: 4, children: [ - Text( - title, - style: Theme.of(context).textTheme.titleSmall, - ), + Text(title, style: AppTypography.sectionTitle), Text( description, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: palette.mutedForeground), + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), ), ], ), ), if (selected) - Padding( - padding: const EdgeInsetsDirectional.only(start: 8), - child: Icon( - LucideIcons.circleCheck, - size: 20, - color: palette.primary, + Flexible( + child: Container( + key: useKey, + margin: const EdgeInsetsDirectional.only(start: 8), + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 4, + ), + decoration: BoxDecoration( + color: palette.selectedSurface, + borderRadius: BorderRadius.circular(AppRadii.control), + ), + child: Text( + l.prototypeCurrentSelection, + style: AppTypography.badge.copyWith( + color: palette.primaryText, + ), + ), ), ), ], @@ -306,15 +312,14 @@ class _RoutingPageState extends State { spacing: 8, runSpacing: 8, children: [ - ShadButton( - key: useKey, - size: ShadButtonSize.sm, - enabled: !controller.state.busy && !selected && canUse, - onPressed: onUse, - child: Text( - selected ? l.prototypeCurrentSelection : l.prototypeUse, + if (!selected) + ShadButton.outline( + key: useKey, + size: ShadButtonSize.sm, + enabled: !controller.state.busy && canUse, + onPressed: onUse, + child: Text(l.prototypeUse), ), - ), if (onEdit != null) ShadButton.outline( size: ShadButtonSize.sm, @@ -323,7 +328,7 @@ class _RoutingPageState extends State { child: Text(l.prototypeEdit), ), if (onDelete != null) - ShadButton.ghost( + ShadButton.outline( size: ShadButtonSize.sm, enabled: !controller.state.busy, onPressed: onDelete, diff --git a/lib/pages/connect/routing/smart/exit_picker.dart b/lib/pages/connect/routing/smart/exit_picker.dart index 877aee793..07c8cf5ca 100644 --- a/lib/pages/connect/routing/smart/exit_picker.dart +++ b/lib/pages/connect/routing/smart/exit_picker.dart @@ -62,7 +62,8 @@ class ServerExitPickerView extends StatelessWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final groups = controller.selectionGroups(l); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.prototypeVpnFinalExit), actions: const [AppActivityIndicator()], ), @@ -77,7 +78,7 @@ class ServerExitPickerView extends StatelessWidget { child: ListView( padding: EdgeInsets.fromLTRB( mobile ? AppSpacing.mobilePage : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageTop, + 16, mobile ? AppSpacing.mobilePage : AppSpacing.page, mobile ? 18 : AppSpacing.desktopPageBottom, ), @@ -131,8 +132,9 @@ class ServerExitPickerView extends StatelessWidget { child: Text( l.prototypeNoMatchingServers, textAlign: TextAlign.center, - style: AppTypography.routingSelectionInput - .copyWith(color: palette.mutedForeground), + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), ), ), Container( @@ -241,8 +243,6 @@ class _SelectionRow extends StatelessWidget { @override Widget build(BuildContext context) { final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Opacity( opacity: onTap == null ? .48 : 1, child: Semantics( @@ -251,17 +251,14 @@ class _SelectionRow extends StatelessWidget { enabled: onTap != null, child: Material( color: selected ? palette.selectedSurface : palette.card, + borderRadius: BorderRadius.circular(AppRadii.control), + clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: Container( constraints: const BoxConstraints(minHeight: 62), - padding: EdgeInsets.symmetric( - horizontal: mobile ? 12 : 14, - vertical: 9, - ), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Row( children: [ Icon( @@ -281,36 +278,14 @@ class _SelectionRow extends StatelessWidget { color: palette.foreground, ), ), - for (final label in [ - ?type, - if (protocol != null && protocol!.isNotEmpty) - protocol!, - ]) ...[ - const SizedBox(height: 3), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: palette.surfaceHover, - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - label, - textDirection: TextDirection.ltr, - style: AppTypography.serverProtocol.copyWith( - color: palette.mutedStrong, - ), - ), - ), - ], - const SizedBox(height: 3), + const SizedBox(height: 4), Text( - detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, + [ + ?type, + if (protocol != null && protocol!.isNotEmpty) + protocol!, + detail, + ].join(' · '), style: AppTypography.routingSelectionDescription .copyWith(color: palette.mutedForeground), ), diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index 3c59ae470..d4a9d89c6 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -56,7 +56,8 @@ class _SmartRoutingEditorPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.prototypeSmartRouting), leading: BackButton(onPressed: () => controller.cancel(context)), ), @@ -74,7 +75,7 @@ class _SmartRoutingEditorPageState extends State { desktopMaxWidth: AppLayout.routingMaxWidth, padding: EdgeInsetsDirectional.fromSTEB( mobile ? AppSpacing.mobilePage : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageTop, + 16, mobile ? AppSpacing.mobilePage : AppSpacing.page, mobile ? 18 : AppSpacing.desktopPageBottom, ), diff --git a/lib/pages/connect/routing/smart/regions.dart b/lib/pages/connect/routing/smart/regions.dart index fecdd7015..2e8ff6e43 100644 --- a/lib/pages/connect/routing/smart/regions.dart +++ b/lib/pages/connect/routing/smart/regions.dart @@ -161,7 +161,10 @@ class _DirectRegionsPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar(title: Text(l.prototypeDirectRegions)), + appBar: PageAppBar.forPage( + context, + title: Text(l.prototypeDirectRegions), + ), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: @@ -218,10 +221,9 @@ class _DirectRegionsPageState extends State { ), child: Text( l.prototypeNoRegionsFound, - style: AppTypography.routingSelectionInput - .copyWith( - color: palette.mutedForeground, - ), + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), ), ), ) @@ -318,7 +320,13 @@ class _DirectRegionsPageState extends State { TextButton( onPressed: state.selected.isEmpty ? null : controller.clear, style: TextButton.styleFrom( - minimumSize: const Size(0, 32), + minimumSize: Size( + 0, + AppLayout.touchExtent( + 32, + platform: Theme.of(context).platform, + ), + ), padding: EdgeInsets.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, textStyle: AppTypography.routingSelectionCount, diff --git a/lib/pages/connect/routing/widgets.dart b/lib/pages/connect/routing/widgets.dart index ad453dd65..87f04f294 100644 --- a/lib/pages/connect/routing/widgets.dart +++ b/lib/pages/connect/routing/widgets.dart @@ -18,7 +18,7 @@ class RoutingFakeDnsRow extends StatelessWidget { required this.value, required this.onChanged, this.enabled = true, - this.divider = true, + this.divider = false, }); @override @@ -49,7 +49,7 @@ class RoutingFragmentRow extends StatelessWidget { required this.value, required this.onChanged, this.enabled = true, - this.divider = true, + this.divider = false, }); @override @@ -109,41 +109,53 @@ class RoutingCardHeader extends StatelessWidget { @override Widget build(BuildContext context) { final palette = ColorManager.palette(context); - return Container( + return SizedBox( width: double.infinity, - padding: const EdgeInsetsDirectional.fromSTEB(18, 10, 18, 10), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Stack( children: [ - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 48), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: AppTypography.routingCardTitle.copyWith( - color: palette.foreground, + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(18, 16, 18, 16), + child: Padding( + padding: EdgeInsetsDirectional.only(end: help.isEmpty ? 0 : 58), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 24), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + title, + style: AppTypography.routingCardTitle.copyWith( + color: palette.foreground, + ), + ), ), ), - ), - if (help.isNotEmpty) - PageHelpButton(title: title, messages: help), - ], + if (description != null) ...[ + const SizedBox(height: 4), + Text( + description!, + style: AppTypography.routingCardDescription.copyWith( + color: palette.mutedForeground, + ), + ), + ], + ], + ), ), ), - if (description != null) ...[ - const SizedBox(height: 4), - Text( - description!, - style: AppTypography.routingCardDescription.copyWith( - color: palette.mutedForeground, + if (help.isNotEmpty) + PositionedDirectional( + top: 4, + end: 18, + child: SizedBox.square( + dimension: 48, + child: Center( + child: PageHelpButton(title: title, messages: help), + ), ), ), - ], ], ), ); @@ -167,13 +179,18 @@ class RoutingSearchField extends StatelessWidget { @override Widget build(BuildContext context) => Semantics( label: label, - child: SizedBox( - height: 38, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 38, + platform: Theme.of(context).platform, + ), + ), child: Center( child: TextField( controller: controller, onChanged: onChanged, - style: AppTypography.routingSelectionInput, + style: AppTypography.rowValue, decoration: InputDecoration( hintText: hint, isCollapsed: true, @@ -182,7 +199,7 @@ class RoutingSearchField extends StatelessWidget { enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.zero, - hintStyle: AppTypography.routingSelectionInput.copyWith( + hintStyle: AppTypography.rowValue.copyWith( color: ColorManager.palette(context).mutedForeground, ), ), @@ -216,7 +233,7 @@ class RoutingSettingRow extends StatelessWidget { this.below, this.onTap, this.enabled = true, - this.divider = true, + this.divider = false, }); @override @@ -259,19 +276,8 @@ class RoutingSettingRow extends StatelessWidget { ], ); final content = Container( - constraints: BoxConstraints( - minHeight: mobile - ? (navigation - ? 64 - : description == null - ? 56 - : 69) - : (description == null ? 60 : 74), - ), - padding: EdgeInsetsDirectional.symmetric( - horizontal: mobile ? 13 : 17, - vertical: 11, - ), + constraints: BoxConstraints(minHeight: mobile ? 56 : 48), + padding: EdgeInsetsDirectional.symmetric(horizontal: 18, vertical: 10), decoration: divider ? BoxDecoration( border: Border(bottom: BorderSide(color: palette.border)), diff --git a/lib/pages/connect/target_picker.dart b/lib/pages/connect/target_picker.dart index 08940fda4..793631f2c 100644 --- a/lib/pages/connect/target_picker.dart +++ b/lib/pages/connect/target_picker.dart @@ -1,8 +1,11 @@ +import 'dart:math' as math; + import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/servers/catalog.dart'; @@ -75,19 +78,53 @@ class _ConnectionTargetPickerState extends State { ) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Material( - color: active ? palette.selectedSurface : palette.muted, - borderRadius: BorderRadius.circular(12), + color: active ? palette.selectedSurface : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.control), + side: active + ? BorderSide(color: palette.primary.withValues(alpha: .2)) + : BorderSide.none, + ), + clipBehavior: Clip.antiAlias, child: Semantics( selected: active, button: true, child: ListTile( - minTileHeight: 64, - leading: Icon(icon, size: 19), - title: Text(title), - subtitle: Text(detail), + minTileHeight: 56, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + leading: Icon( + icon, + size: 20, + color: active ? palette.primaryText : palette.mutedForeground, + ), + title: Text( + title, + style: + (active + ? AppTypography.control + : AppTypography.serverNodeTitle) + .copyWith( + color: active + ? palette.primaryText + : palette.foreground, + ), + ), + subtitle: Text( + detail, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), trailing: active - ? Icon(LucideIcons.check, color: palette.primary, size: 20) - : null, + ? Icon( + LucideIcons.circleCheck, + color: palette.foreground, + size: 18, + ) + : const SizedBox(width: 18), onTap: onTap, ), ), @@ -178,7 +215,7 @@ class _ConnectionTargetPickerState extends State { ], ]; final footer = Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), child: LayoutBuilder( builder: (context, constraints) { final theme = Theme.of(context); @@ -203,17 +240,22 @@ class _ConnectionTargetPickerState extends State { final stacked = labelWidth(l.prototypeServers, manageStyle) + 48 > halfWidth || labelWidth(l.prototypeCancel, cancelStyle) + 48 > halfWidth; + final minimumHeight = AppLayout.touchExtent( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 48 + : 42, + platform: theme.platform, + ); final manage = OutlinedButton( + style: OutlinedButton.styleFrom( + minimumSize: Size(0, minimumHeight), + textStyle: AppTypography.control, + ), onPressed: () => choose(const ConnectionTargetChoice(manage: true)), child: Text(l.prototypeServers), ); final cancel = ConstrainedBox( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent( - AppLayout.buttonMinHeight, - platform: theme.platform, - ), - ), + constraints: BoxConstraints(minHeight: minimumHeight), child: ShadButton.outline( expands: true, height: 0, @@ -240,8 +282,10 @@ class _ConnectionTargetPickerState extends State { builder: (context, constraints) { final short = constraints.maxHeight < 380; return Material( - color: palette.card, - borderRadius: BorderRadius.circular(20), + color: palette.background, + borderRadius: BorderRadius.circular( + MediaQuery.sizeOf(context).width < 640 ? 0 : 8, + ), clipBehavior: Clip.antiAlias, child: SafeArea( top: false, @@ -249,13 +293,13 @@ class _ConnectionTargetPickerState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsetsDirectional.fromSTEB(20, 8, 12, 4), + padding: const EdgeInsetsDirectional.fromSTEB(24, 16, 8, 8), child: Row( children: [ Expanded( child: Text( l.prototypeServers, - style: Theme.of(context).textTheme.titleLarge, + style: AppTypography.dialogTitle, ), ), IconButton( @@ -272,31 +316,67 @@ class _ConnectionTargetPickerState extends State { slivers: [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in [ - l.serverSelectionAutomaticScope, - l.serverSelectionFixedNode, - l.serverCompleteConfigurationsTab, - ].indexed) - ChoiceChip( - label: Text(entry.$2), - selected: tab == entry.$1, - onSelected: (_) => - setState(() => tab = entry.$1), - ), - ], + padding: const EdgeInsets.symmetric(horizontal: 24), + child: LayoutBuilder( + builder: (context, tabConstraints) => Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in [ + l.serverSelectionAutomaticScope, + l.serverSelectionFixedNode, + l.serverCompleteConfigurationsTab, + ].indexed) + ChoiceChip( + label: ConstrainedBox( + constraints: BoxConstraints( + minHeight: 36, + maxWidth: math.max( + 1.0, + tabConstraints.maxWidth - 24, + ), + ), + child: Align( + widthFactor: 1, + heightFactor: 1, + child: Text( + entry.$2, + textAlign: TextAlign.center, + ), + ), + ), + labelStyle: AppTypography.control.copyWith( + color: palette.foreground, + ), + labelPadding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + showCheckmark: false, + side: BorderSide.none, + backgroundColor: palette.muted, + selectedColor: palette.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(4), + ), + ), + selected: tab == entry.$1, + onSelected: (_) => + setState(() => tab = entry.$1), + ), + ], + ), ), ), ), SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), child: TextField( key: const ValueKey('connection-target-search'), + style: AppTypography.rowValue, decoration: InputDecoration( prefixIcon: const Icon( LucideIcons.search, @@ -317,7 +397,7 @@ class _ConnectionTargetPickerState extends State { ))) SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 24), child: TextButton( onPressed: () => choose( const ConnectionTargetChoice(restore: true), @@ -338,7 +418,7 @@ class _ConnectionTargetPickerState extends State { ), ), SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(horizontal: 24), sliver: SliverList.builder( itemCount: entries.length, itemBuilder: (context, index) { diff --git a/lib/pages/connect/view.dart b/lib/pages/connect/view.dart index 753c3fc2d..b4c0e7eef 100644 --- a/lib/pages/connect/view.dart +++ b/lib/pages/connect/view.dart @@ -8,7 +8,6 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; -import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/connect/runtime_observatory.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/failure.dart'; @@ -83,20 +82,28 @@ class ConnectView extends StatelessWidget { String label, String value, String? detail, - VoidCallback? action, - ) => Semantics( + VoidCallback? action, { + bool selected = false, + }) => Semantics( + selected: selected, button: action != null, child: InkWell( onTap: action, borderRadius: BorderRadius.circular(AppRadii.control), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + decoration: BoxDecoration( + color: selected ? palette.selectedSurface : null, + borderRadius: BorderRadius.circular(AppRadii.control), + border: selected ? Border.all(color: palette.border) : null, + ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Padding( - padding: const EdgeInsets.only(top: 3), - child: Icon(icon, size: 19, color: palette.mutedStrong), + Icon( + icon, + size: 20, + color: selected ? palette.primaryText : palette.mutedStrong, ), const SizedBox(width: 12), Expanded( @@ -104,33 +111,31 @@ class ConnectView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, - style: AppTypography.supporting.copyWith( - color: palette.mutedStrong, + value, + style: AppTypography.rowTitle.copyWith( + color: selected + ? palette.primaryText + : palette.foreground, ), ), const SizedBox(height: 4), - Text(value, style: AppTypography.rowTitle), - if (detail != null) ...[ - const SizedBox(height: 4), - Text( - detail, - style: AppTypography.supporting.copyWith( - color: palette.mutedStrong, - ), + Text( + [ + if (label != value) label, + if (detail != null && detail.isNotEmpty) detail, + ].join(' · '), + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, ), - ], + ), ], ), ), if (action != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Icon( - LucideIcons.chevronRightDir, - size: 18, - color: palette.mutedForeground, - ), + Icon( + LucideIcons.chevronRightDir, + size: 18, + color: palette.mutedForeground, ), ], ), @@ -151,7 +156,7 @@ class ConnectView extends StatelessWidget { } return SingleChildScrollView( key: const ValueKey('connection-single-column'), - padding: EdgeInsets.fromLTRB(mobile ? 16 : 28, 12, mobile ? 16 : 28, 28), + padding: EdgeInsets.fromLTRB(mobile ? 16 : 28, 16, mobile ? 16 : 28, 28), child: ResponsiveContent( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -168,16 +173,16 @@ class ConnectView extends StatelessWidget { ), ), ), - const SizedBox(height: 18), + const SizedBox(height: 16), Card( margin: EdgeInsets.zero, child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.connectionPlan, style: AppTypography.panelTitle), - const SizedBox(height: 6), + const SizedBox(height: 14), choice( expert || !methodEnabled ? LucideIcons.fileJson @@ -189,7 +194,9 @@ class ConnectView extends StatelessWidget { expert ? (raw?.name ?? l.prototypeNotSelected) : location, locationDetail, _busy || pendingChange != null ? null : onServer, + selected: true, ), + const SizedBox(height: 8), choice( LucideIcons.route, l.navigationRouting, @@ -205,7 +212,7 @@ class ConnectView extends StatelessWidget { (runningPath != null || view.runtime?.profile != null || view.runtime?.configuration.connection.expert == true)) ...[ - const SizedBox(height: 18), + const SizedBox(height: 16), Card( margin: EdgeInsets.zero, child: Padding( @@ -243,7 +250,7 @@ class ConnectView extends StatelessWidget { ), ), ], - const SizedBox(height: 18), + const SizedBox(height: 16), Card( margin: EdgeInsets.zero, child: Padding( @@ -267,7 +274,7 @@ class ConnectView extends StatelessWidget { ); } - Widget _status(BuildContext context, {bool desktop = false}) { + Widget _status(BuildContext context) { final l = AppLocalizations.of(context)!; final connected = view.phase == ConnectionPhase.connected; final canDisconnect = view.canDisconnect; @@ -309,10 +316,10 @@ class ConnectView extends StatelessWidget { final color = failed ? palette.destructive : connected - ? palette.running + ? palette.runningText : _busy - ? palette.primary - : palette.mutedStrong; + ? palette.primaryText + : palette.secondaryForeground; final waitingForPhase = _connectionPending && !view.busy; final button = FilledButton( onPressed: @@ -320,152 +327,132 @@ class ConnectView extends StatelessWidget { (pendingChange != null && !view.busy) ? null : onConnection, - style: desktop - ? AppTheme.connectionButton( - context, - destructive: canDisconnect, - ).copyWith( - minimumSize: const WidgetStatePropertyAll( - Size( - AppLayout.connectDesktopButtonWidth, - AppLayout.connectDesktopButtonMinHeight, - ), - ), - textStyle: WidgetStatePropertyAll( - AppTypography.connectDesktopAction, - ), - ) - : AppTheme.connectionButton(context, destructive: canDisconnect), - child: ButtonProgress( - busy: desktop && _busy, - child: Text( - waitingForPhase - ? l.prototypePleaseWait - : canDisconnect - ? l.prototypeDisconnect - : view.phase == ConnectionPhase.disconnecting - ? l.prototypePleaseWait - : view.busy - ? l.prototypeCancel - : failed - ? l.prototypeTryAgain - : l.prototypeConnect, - ), + style: AppTheme.connectionButton(context, destructive: canDisconnect), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_busy ? LucideIcons.x : LucideIcons.power, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + waitingForPhase + ? l.prototypePleaseWait + : canDisconnect + ? l.prototypeDisconnect + : view.phase == ConnectionPhase.disconnecting + ? l.prototypePleaseWait + : view.busy + ? l.prototypeCancel + : failed + ? l.prototypeTryAgain + : l.prototypeConnect, + ), + ), + ], ), ); - final content = ConstrainedBox( - constraints: BoxConstraints( - minHeight: desktop - ? AppLayout.connectDesktopStatusMinHeight - : AppLayout.connectStatusMinHeight, - ), - child: Padding( - padding: desktop - ? const EdgeInsets.fromLTRB(20, 66, 20, 39) - : const EdgeInsets.fromLTRB(15, 25, 15, 17), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + final label = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 10, + runSpacing: 6, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (_busy) - SizedBox.square( - dimension: desktop ? 29 : 24, - child: MediaQuery.disableAnimationsOf(context) - ? Icon( - LucideIcons.loaderCircle, - color: color, - size: desktop ? 29 : 24, - ) - : CircularProgressIndicator( - strokeWidth: 2, - color: color, - ), - ) - else if (connected) - Container( - width: desktop ? 29 : 24, - height: desktop ? 29 : 24, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - child: Icon( - LucideIcons.check, - size: desktop ? 19 : 14, - color: palette.primaryForeground, - ), - ) - else - Icon( - failed ? LucideIcons.circleAlert : LucideIcons.shield, - color: color, - size: desktop ? 29 : 24, - ), - SizedBox(width: desktop ? 12 : 10), - Flexible( - child: Text( - title, - style: - (desktop - ? AppTypography.connectDesktopStatusTitle - : AppTypography.connectStatusTitle) - .copyWith(color: color), - textAlign: TextAlign.center, - ), - ), - ], + if (_busy) + SizedBox.square( + dimension: 17, + child: MediaQuery.disableAnimationsOf(context) + ? Icon(LucideIcons.loaderCircle, color: color, size: 17) + : CircularProgressIndicator(strokeWidth: 2, color: color), + ) + else + Icon(LucideIcons.shield, size: 17, color: palette.primary), + Text(l.prototypeSystemVpn, style: AppTypography.badge), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), + decoration: BoxDecoration( + color: failed + ? palette.destructiveSurface + : connected + ? palette.runningSurface + : _busy + ? palette.selectedSurface + : palette.secondary, + borderRadius: BorderRadius.circular(AppRadii.control), + ), + child: Text( + title, + style: AppTypography.badge.copyWith(color: color), + ), ), - SizedBox(height: desktop ? 16 : 8), - Text( - detail, - textAlign: TextAlign.center, - style: - (desktop - ? AppTypography.connectDesktopStatusDetail - : AppTypography.connectStatusDetail) - .copyWith(color: palette.mutedStrong), + ], + ), + const SizedBox(height: 5), + Text(title, style: AppTypography.connectStatusTitle), + if (connected || _busy || failed) ...[ + const SizedBox(height: 5), + Text( + detail, + style: AppTypography.connectStatusDetail.copyWith( + color: failed ? palette.destructive : palette.mutedForeground, ), - if (view.issue == 'selectionReset') - Padding( - padding: const EdgeInsets.only(top: 8), - child: Semantics( - liveRegion: true, - child: Text( - l.prototypeNameActive(l.prototypeAutomaticSelection), - textAlign: TextAlign.center, - style: AppTypography.metadata, - ), - ), + ), + ], + if (view.issue == 'selectionReset') + Padding( + padding: const EdgeInsets.only(top: 8), + child: Semantics( + liveRegion: true, + child: Text( + l.prototypeNameActive(l.prototypeAutomaticSelection), + style: AppTypography.metadata, ), - if (view.failed && !failed) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - l.prototypeCheckNetwork, - style: AppTypography.supporting.copyWith( - color: palette.destructive, - ), - textAlign: TextAlign.center, - ), + ), + ), + if (view.failed && !failed) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + l.prototypeCheckNetwork, + style: AppTypography.supporting.copyWith( + color: palette.destructive, ), - SizedBox(height: desktop ? 30 : 18), - if (desktop) - Align( - child: SizedBox( - width: AppLayout.connectDesktopButtonWidth, - child: button, - ), - ) - else - button, - ], + ), + ), + ], + ); + return Card( + margin: EdgeInsets.zero, + color: palette.card, + clipBehavior: Clip.antiAlias, + child: ColoredBox( + color: palette.primary.withValues(alpha: .055), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 520 || + MediaQuery.textScalerOf(context).scale(14) > 18) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [label, const SizedBox(height: 14), button], + ); + } + return Row( + children: [ + Expanded(child: label), + const SizedBox(width: 20), + SizedBox(width: 150, child: button), + ], + ); + }, + ), ), ), ); - return Card(margin: EdgeInsets.zero, child: content); } } @@ -488,10 +475,18 @@ class TrafficReadout extends StatelessWidget { final elapsed = duration == null ? '—' : '${duration.inHours.toString().padLeft(2, '0')}:${(duration.inMinutes % 60).toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; - Widget metric(String title, String value, String? detail) => Container( - padding: const EdgeInsets.all(12), + Widget metric( + String title, + String value, + String? detail, { + required int index, + }) => Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), decoration: BoxDecoration( - color: palette.muted, + color: Color.alphaBlend( + palette.primary.withValues(alpha: .045 + index * .025), + palette.card, + ), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -500,14 +495,18 @@ class TrafficReadout extends StatelessWidget { Text( title, style: AppTypography.supporting.copyWith( - color: palette.mutedStrong, + color: palette.mutedForeground, ), ), - const SizedBox(height: 8), - Text( - value, - textDirection: TextDirection.ltr, - style: AppTypography.metric, + const SizedBox(height: 7), + FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text( + value, + textDirection: TextDirection.ltr, + style: AppTypography.metric, + ), ), if (detail != null) Padding( @@ -516,7 +515,7 @@ class TrafficReadout extends StatelessWidget { detail, textDirection: TextDirection.ltr, style: AppTypography.supporting.copyWith( - color: palette.mutedStrong, + color: palette.mutedForeground, ), ), ), @@ -525,26 +524,29 @@ class TrafficReadout extends StatelessWidget { ); final items = [ metric( - l.prototypeDownload, - live ? '${formatTraffic(view.downloadSpeed, connection: true)}/s' : '—', + l.prototypeUpload, !live || view.traffic == null ? '—' - : formatTraffic(view.traffic!.downlink, connection: true), + : formatTraffic(view.traffic!.uplink, connection: true), + live ? '${formatTraffic(view.uploadSpeed, connection: true)}/s' : '—', + index: 0, ), metric( - l.prototypeUpload, - live ? '${formatTraffic(view.uploadSpeed, connection: true)}/s' : '—', + l.prototypeDownload, !live || view.traffic == null ? '—' - : formatTraffic(view.traffic!.uplink, connection: true), + : formatTraffic(view.traffic!.downlink, connection: true), + live ? '${formatTraffic(view.downloadSpeed, connection: true)}/s' : '—', + index: 1, ), - metric(l.prototypeUptime, elapsed, null), + metric(l.prototypeUptime, elapsed, null, index: 2), ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ LayoutBuilder( builder: (context, constraints) { + final gap = constraints.maxWidth < 520 ? 8.0 : 12.0; final large = MediaQuery.textScalerOf(context).scale(14) > 18; if (constraints.maxWidth < 300 || large) { return Column( @@ -558,7 +560,7 @@ class TrafficReadout extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final item in items.indexed) ...[ - if (item.$1 > 0) const SizedBox(width: 8), + if (item.$1 > 0) SizedBox(width: gap), Expanded(child: item.$2), ], ], diff --git a/lib/pages/launch/setup/view.dart b/lib/pages/launch/setup/view.dart index 39b9c868d..c7220c546 100644 --- a/lib/pages/launch/setup/view.dart +++ b/lib/pages/launch/setup/view.dart @@ -1,10 +1,10 @@ import 'package:material_ui/material_ui.dart'; -import 'package:onexray/gen/assets.gen.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/launch/setup/controller.dart'; import 'package:onexray/pages/launch/setup/selectors.dart'; import 'package:onexray/pages/launch/setup/widgets.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -33,155 +33,75 @@ class SetupView extends StatelessWidget { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final welcome = state.step == SetupStep.welcome; - final content = [ - ...switch (state.step) { - SetupStep.welcome => _welcome(context, mobile), - SetupStep.configuration => _configuration(context, mobile), - SetupStep.complete => const [], - }, - ..._feedback(context), - ]; return PopScope( canPop: false, child: Scaffold( body: SafeArea( - bottom: false, - child: mobile - ? SetupBody( - key: ValueKey(state.step), - top: welcome ? 32 : 24, - children: [ - if (welcome) ...[ - Text( - 'OneXray', - textAlign: TextAlign.center, - style: AppTypography.setupBrand, - ), - const SizedBox(height: 36), - ], - if (!welcome && state.step != SetupStep.complete) ...[ - _SetupProgress(step: state.step), - const SizedBox(height: 48), - ], - ...content, - ], - ) - : SetupDesktopBody( - key: ValueKey(state.step), - progress: state.step == SetupStep.complete - ? const SizedBox.shrink() - : _SetupProgress(step: state.step), - bodyTop: welcome ? 46 : 48, - children: content, - ), - ), - bottomNavigationBar: state.step == SetupStep.complete - ? null - : SetupFooter( - note: !mobile && state.step == SetupStep.configuration - ? l.prototypeSetupDoesNotStartVpn - : null, - children: _actions(l), - ), - ), - ); - } - - List _welcome(BuildContext context, bool mobile) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - return [ - Center(child: Assets.appIcon.blue.image(width: 96, height: 96)), - SizedBox(height: mobile ? 26 : 24), - Text( - l.prototypeWelcome, - textAlign: TextAlign.center, - style: mobile - ? AppTypography.setupWelcomeTitle - : AppTypography.setupDesktopTitle, - ), - const SizedBox(height: 12), - Text( - l.prototypeWelcomeSubtitle, - textAlign: TextAlign.center, - style: - (mobile - ? AppTypography.setupSubtitle - : AppTypography.setupDesktopSubtitle) - .copyWith(color: palette.mutedStrong), - ), - SizedBox(height: mobile ? 36 : 34), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 460), - child: IntrinsicWidth( - child: Column( - children: [ - SetupPoint( - icon: LucideIcons.shieldCheck, - text: l.prototypeNoDataCollection, + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: (constraints.maxHeight - 48).clamp( + 0, + double.infinity, + ), ), - SizedBox(height: mobile ? 24 : 20), - SetupPoint( - icon: LucideIcons.userRound, - text: l.prototypeBringOwnServers, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: SettingSection( + title: welcome + ? l.prototypeWelcome + : l.prototypeGetReadyToConnect, + description: welcome ? l.prototypeWelcomeSubtitle : null, + padding: EdgeInsets.zero, + children: [ + if (welcome) ...[ + Text( + l.prototypeNoDataCollection, + style: AppTypography.rowValue, + ), + const SizedBox(height: 16), + Text( + l.prototypeBringOwnServers, + style: AppTypography.rowValue, + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: _action(SetupAction.privacy), + child: Text(l.prototypePrivacyPolicy), + ), + ] else + ..._configuration(context, mobile), + ..._feedback(context), + if (state.step != SetupStep.complete) ...[ + const SizedBox(height: 24), + if (welcome) + ..._actions(l) + else + OverflowBar( + spacing: 10, + overflowSpacing: 10, + children: _actions(l), + ), + ], + ], + ), + ), ), - ], - ), - ), - ), - ), - SizedBox(height: mobile ? 30 : 28), - Center( - child: TextButton( - onPressed: _action(SetupAction.privacy), - style: TextButton.styleFrom( - minimumSize: const Size(0, 38), - padding: EdgeInsets.zero, - alignment: Alignment.topCenter, - textStyle: AppTypography.setupPrivacyLink.copyWith( - decoration: TextDecoration.underline, + ), ), ), - child: Text(l.prototypePrivacyPolicy), ), ), - ]; + ); } - List _heading( - BuildContext context, - bool mobile, - String title, [ - String? description, - ]) => [ - Text( - title, - textAlign: mobile ? TextAlign.start : TextAlign.center, - style: mobile - ? AppTypography.setupTitle - : AppTypography.setupDesktopTitle, - ), - if (description != null) ...[ - const SizedBox(height: 12), - Text( - description, - textAlign: mobile ? TextAlign.start : TextAlign.center, - style: - (mobile - ? AppTypography.setupSubtitle - : AppTypography.setupDesktopSubtitle) - .copyWith(color: ColorManager.palette(context).mutedStrong), - ), - ], - ]; - List _configuration(BuildContext context, bool mobile) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); return [ - ..._heading(context, mobile, l.prototypeGetReadyToConnect), - const SizedBox(height: 28), if (requiresInterface) ...[ _SetupRow( icon: LucideIcons.network, @@ -278,138 +198,6 @@ class SetupView extends StatelessWidget { }; } -class _SetupProgress extends StatelessWidget { - const _SetupProgress({required this.step}); - final SetupStep step; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final labels = [l.prototypeWelcomePrivacy, l.prototypeSystemSetup]; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - return Semantics( - label: l.prototypeSetupProgress, - child: mobile - ? Column( - children: [ - Row( - children: [ - Flexible( - child: Text( - '${labels[step.index]} ·', - style: AppTypography.setupProgress, - ), - ), - const SizedBox(width: 4), - Text( - '${step.index + 1} / ${labels.length}', - textDirection: TextDirection.ltr, - style: AppTypography.setupProgress, - ), - ], - ), - const SizedBox(height: 13), - Row( - children: [ - for (var index = 0; index < labels.length; index++) ...[ - if (index > 0) const SizedBox(width: 6), - Expanded( - child: Container( - height: 4, - decoration: BoxDecoration( - color: index <= step.index - ? palette.primary - : palette.border, - borderRadius: BorderRadius.circular(3), - ), - ), - ), - ], - ], - ), - ], - ) - : Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var index = 0; index < labels.length; index++) - Expanded( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: index == 0 - ? const SizedBox.shrink() - : Divider(height: 1, color: palette.border), - ), - const SizedBox(width: 14), - Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: index <= step.index - ? palette.primary - : palette.card, - border: Border.all( - color: index <= step.index - ? palette.primary - : palette.borderStrong, - ), - ), - child: index < step.index - ? Icon( - LucideIcons.check, - size: 15, - color: palette.primaryForeground, - ) - : Text( - '${index + 1}', - style: AppTypography.setupStepActive - .copyWith( - color: index == step.index - ? palette.primaryForeground - : palette.mutedForeground, - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: index == labels.length - 1 - ? const SizedBox.shrink() - : Divider(height: 1, color: palette.border), - ), - ], - ), - const SizedBox(height: 10), - Text( - labels[index], - textAlign: TextAlign.center, - style: - (index == step.index - ? AppTypography.setupStepActive - : AppTypography.setupStepLabel) - .copyWith( - color: index == step.index - ? palette.foreground - : index < step.index - ? palette.mutedStrong - : palette.mutedForeground, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - class _SetupRow extends StatelessWidget { const _SetupRow({ required this.icon, @@ -425,65 +213,12 @@ class _SetupRow extends StatelessWidget { final bool busy; @override - Widget build(BuildContext context) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final palette = ColorManager.palette(context); - final radius = BorderRadius.circular(AppRadii.card); - return Material( - color: palette.card, - shape: RoundedRectangleBorder( - borderRadius: radius, - side: BorderSide(color: palette.borderStrong), - ), - child: InkWell( - onTap: onTap, - borderRadius: radius, - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: mobile ? 62 : 68), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 14, - vertical: mobile ? 16 : 18, - ), - child: Row( - children: [ - if (busy) - const ButtonProgressIndicator() - else - Icon(icon, size: mobile ? 24 : 26, color: palette.primary), - SizedBox(width: mobile ? 12 : 18), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: mobile - ? AppTypography.setupRowTitle - : AppTypography.setupDesktopRowTitle, - ), - const SizedBox(height: 4), - Text( - description, - style: mobile - ? AppTypography.setupSelectorDetail - : AppTypography.setupDesktopRowDetail, - ), - ], - ), - ), - const SizedBox(width: 12), - Icon( - LucideIcons.chevronRightDir, - size: 18, - color: palette.mutedStrong, - ), - ], - ), - ), - ), - ), - ); - } + Widget build(BuildContext context) => SettingRow( + title: title, + subtitle: description, + leading: Icon(icon), + onTap: onTap, + trailing: busy ? const ButtonProgressIndicator() : null, + showChevron: !busy, + ); } diff --git a/lib/pages/launch/setup/widgets.dart b/lib/pages/launch/setup/widgets.dart index f405e517d..fbb39f08b 100644 --- a/lib/pages/launch/setup/widgets.dart +++ b/lib/pages/launch/setup/widgets.dart @@ -200,13 +200,11 @@ class SetupActionButton extends StatelessWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); final style = ButtonStyle( - minimumSize: WidgetStatePropertyAll(Size(0, mobile ? 46 : 44)), + minimumSize: WidgetStatePropertyAll(Size(0, mobile ? 48 : 42)), padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(horizontal: 12, vertical: 10), ), - textStyle: WidgetStatePropertyAll( - mobile ? AppTypography.setupAction : AppTypography.setupDesktopAction, - ), + textStyle: WidgetStatePropertyAll(AppTypography.control), backgroundColor: outline ? null : WidgetStateProperty.resolveWith( diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index ebd49b4a9..0f4c038ce 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -409,17 +409,25 @@ class _AdaptiveMainShellState extends State { ), ), Expanded( - child: - nativeSidebar && - Directionality.of(context) == TextDirection.ltr - ? MediaQuery.removePadding( - context: context, - removeTop: true, - child: navigationShell, - ) - // In RTL the content, not the sidebar, is below the native - // controls on the physical left. Keep its local top inset. - : navigationShell, + child: Container( + margin: const EdgeInsets.fromLTRB(0, 8, 8, 8), + decoration: BoxDecoration( + color: palette.background, + borderRadius: BorderRadius.circular(24), + ), + clipBehavior: Clip.antiAlias, + child: + nativeSidebar && + Directionality.of(context) == TextDirection.ltr + ? MediaQuery.removePadding( + context: context, + removeTop: true, + child: navigationShell, + ) + // In RTL the content, not the sidebar, is below the native + // controls on the physical left. Keep its local top inset. + : navigationShell, + ), ), ], ), diff --git a/lib/pages/main/advanced.dart b/lib/pages/main/advanced.dart index a9e33546f..9246e5937 100644 --- a/lib/pages/main/advanced.dart +++ b/lib/pages/main/advanced.dart @@ -16,12 +16,15 @@ class TunnelRootPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(AppLocalizations.of(context)!.prototypeVpnTunnel), ), body: VpnTunnelPane( openTunnel: (context, destination, draft) => context.pushScoped(switch (destination) { + TunnelDestination.dns => AppPageDestination.tunnelDns, + TunnelDestination.network => AppPageDestination.tunnelNetwork, TunnelDestination.apple => AppPageDestination.appleVpn, TunnelDestination.android => AppPageDestination.androidVpn, TunnelDestination.windows => AppPageDestination.windowsVpn, @@ -36,7 +39,8 @@ class XrayRootPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text( AppLocalizations.of(context)!.prototypeXrayRuntimeDiagnostics, ), @@ -48,6 +52,8 @@ class XrayRootPage extends StatelessWidget { context.pushScoped(AppPageDestination.routingData), onUpdates: (context) => context.pushScoped(AppPageDestination.autoUpdate), onSpeedTest: (context) => context.pushScoped(AppPageDestination.ping), + onLogSettings: (context) => + context.pushScoped(AppPageDestination.xrayLogs), onLocalApi: (context) => context.pushScoped(AppPageDestination.localApi), onLanProxy: (context) async { await context.pushScoped(AppPageDestination.lanProxy); diff --git a/lib/pages/main/navigation.dart b/lib/pages/main/navigation.dart index 460a40818..687460003 100644 --- a/lib/pages/main/navigation.dart +++ b/lib/pages/main/navigation.dart @@ -45,6 +45,7 @@ enum AppPageDestination { sourceDetails("source-details"), serverManualGroups("server-manual-groups"), xrayDiagnostics("xray-diagnostics"), + xrayLogs("xray-logs"), manualServerOrder("manual-server-order"), serverEditor("server-editor"), serverFinalExitPicker("server-final-exit-picker"), @@ -54,6 +55,8 @@ enum AppPageDestination { customRouting("custom-routing"), advancedRouting("advanced-routing"), customRule("custom-rule"), + tunnelDns("tunnel-dns"), + tunnelNetwork("tunnel-network"), appleVpn("apple-vpn"), appleWifi("apple-wifi"), androidVpn("android-vpn"), @@ -62,6 +65,7 @@ enum AppPageDestination { windowsVpn("windows-vpn"), outboundInterface("outbound-interface"), routingData("routing-data"), + routingDataAdd("routing-data-add"), routingDataFile("routing-data-file"), share("share"), subscriptionEdit("subscription-edit"), diff --git a/lib/pages/main/url.dart b/lib/pages/main/url.dart index 6e6543f44..e8398590c 100644 --- a/lib/pages/main/url.dart +++ b/lib/pages/main/url.dart @@ -5,6 +5,7 @@ import 'package:onexray/core/pigeon/host_api.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/xray/log/page.dart'; +import 'package:onexray/pages/advanced/xray/log/settings.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; import 'package:onexray/pages/advanced/xray/config/page.dart'; import 'package:onexray/pages/advanced/xray/config/params.dart'; @@ -15,6 +16,7 @@ import 'package:onexray/pages/advanced/local_api/page.dart'; import 'package:onexray/pages/settings/about/page.dart'; import 'package:onexray/pages/settings/backup/page.dart'; import 'package:onexray/pages/advanced/tunnel/apple/page.dart'; +import 'package:onexray/pages/advanced/tunnel/details.dart'; import 'package:onexray/pages/advanced/tunnel/apple/wifi.dart'; import 'package:onexray/pages/advanced/tunnel/android/page.dart'; import 'package:onexray/pages/advanced/tunnel/android/automation/page.dart'; @@ -22,6 +24,7 @@ import 'package:onexray/pages/advanced/tunnel/android/apps.dart'; import 'package:onexray/pages/advanced/tunnel/windows/page.dart'; import 'package:onexray/pages/advanced/tunnel/interface/page.dart'; import 'package:onexray/pages/advanced/xray/geodata/page.dart'; +import 'package:onexray/pages/advanced/xray/geodata/add.dart'; import 'package:onexray/pages/advanced/xray/geodata/detail.dart'; import 'package:onexray/service/advanced/policy_editor.dart'; import 'package:onexray/service/connect/routing/custom/state.dart'; @@ -265,12 +268,33 @@ final _pageRoutes = <_PageRoute>[ ), ), _route(AppPageDestination.localApi, (_, _) => const LocalApiPage()), + _route( + AppPageDestination.xrayLogs, + (_, _) => XrayLogSettingsPage( + onLog: (context, params) => + context.pushScoped(AppPageDestination.logFile, extra: params), + ), + ), _route(AppPageDestination.lanProxy, (_, _) => const LanProxyPage()), _route(AppPageDestination.serverDialing, (_, _) => const ServerDialingPage()), _route( AppPageDestination.androidAutomation, (_, _) => const AndroidAutomationPage(), ), + _route( + AppPageDestination.tunnelDns, + (_, state) => _withExtra( + state, + (draft) => TunnelDnsPage(draft: draft), + ), + ), + _route( + AppPageDestination.tunnelNetwork, + (_, state) => _withExtra( + state, + (draft) => TunnelNetworkPage(draft: draft), + ), + ), _route( AppPageDestination.appleVpn, (_, state) => _withExtra( @@ -340,8 +364,11 @@ final _pageRoutes = <_PageRoute>[ (_, _) => GeoDataPage( openFile: (context, id) => context.pushScoped(AppPageDestination.routingDataFile, extra: id), + openAdd: (context) => + context.pushScoped(AppPageDestination.routingDataAdd), ), ), + _route(AppPageDestination.routingDataAdd, (_, _) => const GeoDataAddPage()), _route( AppPageDestination.routingDataFile, (_, state) => _withExtra(state, (id) => GeoDataFilePage(fileId: id)), @@ -494,7 +521,8 @@ class _InvalidRoutePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text( AppLocalizations.of(context)!.prototypeTemporarilyUnavailable, ), @@ -555,7 +583,7 @@ class _LogRouteGateState extends State<_LogRouteGate> { } final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar(title: Text(l.prototypeLogs)), + appBar: PageAppBar.forPage(context, title: Text(l.prototypeLogs)), body: SafeArea( child: Center( child: snapshot.connectionState != ConnectionState.done diff --git a/lib/pages/servers/details.dart b/lib/pages/servers/details.dart index af43c8e29..39ff28f86 100644 --- a/lib/pages/servers/details.dart +++ b/lib/pages/servers/details.dart @@ -11,6 +11,7 @@ import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/servers/subscription/user_info.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:onexray/pages/main/navigation.dart'; @@ -65,11 +66,20 @@ class ServerResourcePage extends StatelessWidget { final busy = row != null && (controller.busy || controller.serverBusy(row)); return Scaffold( - appBar: PageAppBar(title: Text(name)), + appBar: PageAppBar.forPage(context, title: Text(name)), body: SafeArea( child: ResponsiveContent( child: ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 16, + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 28, + ), children: [ if (row == null) Padding( @@ -131,7 +141,7 @@ class ServerResourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 16), SettingSection( title: raw ? l.xrayRawPageTitle @@ -181,7 +191,7 @@ class ServerResourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 16), if (raw || provider) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -194,7 +204,7 @@ class ServerResourcePage extends StatelessWidget { ), ), ), - const SizedBox(height: 20), + const SizedBox(height: 16), ], SettingSection( title: l.prototypeMoreActions, @@ -301,13 +311,23 @@ class ServerSourcePage extends StatelessWidget { ? null : SubscriptionUserInfo.fromSubscription(source); return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(source?.name ?? l.prototypeSubscriptions), ), body: SafeArea( child: ResponsiveContent( child: ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 16, + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 28, + ), children: [ if (source == null) Padding( @@ -358,7 +378,7 @@ class ServerSourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 16), SettingSection( title: l.prototypeSubscriptions, padding: EdgeInsets.zero, @@ -393,14 +413,14 @@ class ServerSourcePage extends StatelessWidget { ], ), if (package != null) ...[ - const SizedBox(height: 20), + const SizedBox(height: 16), SettingSection( title: '', padding: EdgeInsets.zero, children: [SubscriptionPackageDetails(info: package)], ), ], - const SizedBox(height: 20), + const SizedBox(height: 16), SettingSection( title: controller.providerSource(source) ? l.providerConfigurationTitle @@ -435,7 +455,7 @@ class ServerSourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 20), + const SizedBox(height: 16), SettingSection( title: l.prototypeMoreActions, padding: EdgeInsets.zero, diff --git a/lib/pages/servers/editor/page.dart b/lib/pages/servers/editor/page.dart index 36de57233..067fd7f27 100644 --- a/lib/pages/servers/editor/page.dart +++ b/lib/pages/servers/editor/page.dart @@ -43,9 +43,9 @@ class ServerEditorPage extends StatelessWidget { onClose: () => controller.closePage(context), body: Padding( padding: EdgeInsets.fromLTRB( - mobile ? 16 : 20, - 20, - mobile ? 16 : 20, + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, 0, ), child: Column( @@ -53,8 +53,8 @@ class ServerEditorPage extends StatelessWidget { children: [ Text( l.prototypeXrayOutboundJson, - style: AppTypography.subscriptionField.copyWith( - color: p.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: p.foreground, ), ), const SizedBox(height: 7), diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart index dbd4553ab..4adc4458a 100644 --- a/lib/pages/servers/form_page.dart +++ b/lib/pages/servers/form_page.dart @@ -5,8 +5,6 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; -import 'package:onexray/pages/theme/color.dart'; -import 'package:onexray/pages/theme/font.dart'; /// Full content presentation for import and server editing. Choice and /// confirmation dialogs keep their existing modal surfaces. @@ -35,8 +33,10 @@ class ServerFormPage extends StatelessWidget { child: Focus( autofocus: true, child: Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(title), + description: subtitle, leading: BackButton(onPressed: onBack ?? onClose), actions: [ if (onBack != null && onClose != null) @@ -52,25 +52,7 @@ class ServerFormPage extends StatelessWidget { child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (subtitle?.isNotEmpty == true) - Padding( - padding: const EdgeInsetsDirectional.fromSTEB( - 16, - 16, - 16, - 0, - ), - child: Text( - subtitle!, - style: AppTypography.supporting.copyWith( - color: ColorManager.palette(context).mutedForeground, - ), - ), - ), - body, - const SizedBox(height: 24), - ], + children: [body, const SizedBox(height: 24)], ), ), ), diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 54c22cbf1..98752b474 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -9,6 +9,7 @@ import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/servers/import/controller.dart'; import 'package:onexray/pages/servers/subscription/form_view.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -71,7 +72,12 @@ class _ServersImportPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: EdgeInsets.all(mobile ? 14 : 20), + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, + 0, + ), child: LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; @@ -237,9 +243,9 @@ class ServerImportFormPage extends StatelessWidget { if (subscription) Padding( padding: EdgeInsets.fromLTRB( - mobile ? 16 : 20, - 20, - mobile ? 16 : 20, + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, 0, ), child: _subscription(context), @@ -292,47 +298,57 @@ class ServerImportFormPage extends StatelessWidget { Widget _paste(BuildContext context) { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); + final gutter = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16.0 + : 28.0; return Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), - child: Column( - spacing: 8, - crossAxisAlignment: CrossAxisAlignment.stretch, + padding: EdgeInsets.fromLTRB(gutter, 16, gutter, 0), + child: SettingSection( + title: l10n.prototypeImportLinks, + padding: EdgeInsets.zero, children: [ - Text( - l10n.prototypeImportLinks, - style: AppTypography.importField.copyWith( - color: palette.mutedStrong, - ), - ), - SizedBox( - height: 120, - child: Directionality( - textDirection: TextDirection.ltr, - child: ShadInput( - controller: controller.text, - placeholder: const Text( - 'vless://, vmess://, trojan://, ss://, https://, onexray://', + Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.prototypePasteLink, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), - expands: true, - editableTextSize: const Size(double.infinity, 98), - maxLines: null, - minLines: null, - autocorrect: false, - enableSuggestions: false, - keyboardType: TextInputType.multiline, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + ), + SizedBox( + height: 120, + child: Directionality( + textDirection: TextDirection.ltr, + child: ShadInput( + controller: controller.text, + placeholder: const Text( + 'vless://, vmess://, trojan://, ss://, https://, onexray://', + ), + expands: true, + editableTextSize: const Size(double.infinity, 98), + maxLines: null, + minLines: null, + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.multiline, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + style: AppTypography.rowValue, + ), ), - style: AppTypography.importField, ), - ), - ), - Text( - l10n.prototypeImportLinksHint, - style: AppTypography.importHint.copyWith( - color: palette.mutedForeground, - ), + Text( + l10n.prototypeImportLinksHint, + style: AppTypography.importHint.copyWith( + color: palette.mutedForeground, + ), + ), + ], ), ], ), @@ -343,16 +359,14 @@ class ServerImportFormPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); return Padding( - padding: EdgeInsets.fromLTRB(mobile ? 16 : 20, 20, mobile ? 16 : 20, 0), + padding: EdgeInsets.fromLTRB(mobile ? 16 : 28, 16, mobile ? 16 : 28, 0), child: Column( spacing: 7, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.prototypeXrayNodeJson, - style: AppTypography.subscriptionField.copyWith( - color: palette.mutedStrong, - ), + style: AppTypography.fieldLabel.copyWith(color: palette.foreground), ), OutboundJsonEditor( controller: controller.jsonText, @@ -732,7 +746,8 @@ class ServerImportScannerPage extends StatelessWidget { value: controller, child: BlocBuilder( builder: (context, state) => Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(AppLocalizations.of(context)!.prototypeScanQrCode), actions: [ IconButton( diff --git a/lib/pages/servers/order/page.dart b/lib/pages/servers/order/page.dart index d8449f776..afa734cda 100644 --- a/lib/pages/servers/order/page.dart +++ b/lib/pages/servers/order/page.dart @@ -28,7 +28,8 @@ class ManualOrderPage extends StatelessWidget { return PopScope( canPop: !state.busy, child: Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text( params.title, maxLines: 1, @@ -67,89 +68,107 @@ class ManualOrderPage extends StatelessWidget { ), ), Expanded( - child: ReorderableListView.builder( - key: const PageStorageKey('manual-order'), - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - onReorderItem: controller.reorder, - itemCount: state.items.length, - itemBuilder: (context, index) { - final item = state.items[index]; - final handle = Tooltip( - message: l.manualOrderDrag, - // A tooltip long press would compete with the drag handle. - triggerMode: TooltipTriggerMode.manual, - child: Container( - width: 40, - height: 48, - alignment: Alignment.center, - color: Colors.transparent, - child: Icon( - LucideIcons.gripVertical, - size: 20, - color: ColorManager.palette(context) - .mutedForeground, - ), + child: Padding( + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, + 28, + ), + child: Material( + color: ColorManager.palette(context).card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.card), + side: BorderSide( + color: ColorManager.palette(context).border, ), - ); - return Material( - key: ValueKey(item.id), - color: ColorManager.palette(context).card, - child: ListTile( - leading: mobile - ? ReorderableDelayedDragStartListener( - index: index, - enabled: !state.busy, - child: handle, - ) - : ReorderableDragStartListener( - index: index, - enabled: !state.busy, - child: handle, - ), - title: Text( - item.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - key: ValueKey('move-up-${item.id}'), - tooltip: l.manualOrderMoveUp, - onPressed: state.busy || index == 0 - ? null - : () => controller.reorder( - index, - index - 1, - ), - icon: const Icon( - LucideIcons.arrowUp, - size: 18, - ), + ), + clipBehavior: Clip.antiAlias, + child: ReorderableListView.builder( + key: const PageStorageKey('manual-order'), + padding: const EdgeInsets.all(18), + buildDefaultDragHandles: false, + onReorderItem: controller.reorder, + itemCount: state.items.length, + itemBuilder: (context, index) { + final item = state.items[index]; + final handle = Tooltip( + message: l.manualOrderDrag, + // A tooltip long press would compete with the drag handle. + triggerMode: TooltipTriggerMode.manual, + child: Container( + width: 40, + height: 48, + alignment: Alignment.center, + color: Colors.transparent, + child: Icon( + LucideIcons.gripVertical, + size: 20, + color: ColorManager.palette(context) + .mutedForeground, ), - IconButton( - key: ValueKey('move-down-${item.id}'), - tooltip: l.manualOrderMoveDown, - onPressed: - state.busy || - index == state.items.length - 1 - ? null - : () => controller.reorder( - index, - index + 1, - ), - icon: const Icon( - LucideIcons.arrowDown, - size: 18, - ), + ), + ); + return Material( + key: ValueKey(item.id), + color: ColorManager.palette(context).card, + child: ListTile( + leading: mobile + ? ReorderableDelayedDragStartListener( + index: index, + enabled: !state.busy, + child: handle, + ) + : ReorderableDragStartListener( + index: index, + enabled: !state.busy, + child: handle, + ), + title: Text( + item.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ], - ), - ), - ); - }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: ValueKey('move-up-${item.id}'), + tooltip: l.manualOrderMoveUp, + onPressed: state.busy || index == 0 + ? null + : () => controller.reorder( + index, + index - 1, + ), + icon: const Icon( + LucideIcons.arrowUp, + size: 18, + ), + ), + IconButton( + key: ValueKey('move-down-${item.id}'), + tooltip: l.manualOrderMoveDown, + onPressed: + state.busy || + index == state.items.length - 1 + ? null + : () => controller.reorder( + index, + index + 1, + ), + icon: const Icon( + LucideIcons.arrowDown, + size: 18, + ), + ), + ], + ), + ), + ); + }, + ), + ), ), ), ], diff --git a/lib/pages/servers/page.dart b/lib/pages/servers/page.dart index fb4a070be..90db79d93 100644 --- a/lib/pages/servers/page.dart +++ b/lib/pages/servers/page.dart @@ -50,30 +50,29 @@ class _ServersPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final page = Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(l.prototypeServers), - actions: [ - if (!mobile) - IconButton( - tooltip: l.manualGroupNew, - onPressed: () => controller.createManualGroup(context), - icon: const Icon(LucideIcons.folderPlus, size: 20), - ), - if (mobile) - IconButton( - key: const ValueKey('servers-add'), - tooltip: l.prototypeAddServer, - onPressed: () => controller.addServers(context), - icon: const Icon(LucideIcons.plus, size: 20), - ) - else + description: l.serversPageDescription, + headingAction: Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (!mobile) + IconButton( + tooltip: l.manualGroupNew, + onPressed: () => controller.createManualGroup(context), + icon: const Icon(LucideIcons.folderPlus, size: 20), + ), FilledButton.icon( key: const ValueKey('servers-add'), onPressed: () => controller.addServers(context), icon: const Icon(LucideIcons.plus, size: 18), label: Text(l.prototypeAddServer), ), - ], + ], + ), ), body: SafeArea( child: ServerLoadState( @@ -130,7 +129,8 @@ class ServerGroupPage extends StatelessWidget { } } return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text( group?.name ?? l.prototypeServers, maxLines: 1, diff --git a/lib/pages/servers/sources.dart b/lib/pages/servers/sources.dart index da0378bc7..6a6af1acf 100644 --- a/lib/pages/servers/sources.dart +++ b/lib/pages/servers/sources.dart @@ -46,24 +46,24 @@ class ServerSourcesView extends StatelessWidget { AppPageDestination.sourceDetails, extra: ServerSourceParams(controller: controller, sourceId: source.id), ); - return SettingSection( - title: l.prototypeSubscriptions, - padding: EdgeInsets.zero, - separated: false, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, children: [ if (sources.isEmpty) - Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Text( - query.isEmpty - ? l.prototypeNoServersYet - : l.prototypeNoMatchingSubscriptions, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - TextButton( + SettingSection( + title: l.prototypeSubscriptions, + padding: EdgeInsets.zero, + children: [ + Text( + query.isEmpty + ? l.prototypeNoServersYet + : l.prototypeNoMatchingSubscriptions, + ), + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton( onPressed: query.isEmpty ? () => controller.addServers(context) : controller.search.clear, @@ -73,32 +73,106 @@ class ServerSourcesView extends StatelessWidget { : l.prototypeClear, ), ), - ], - ), + ), + ], ), for (final source in sources) Material( type: MaterialType.transparency, child: InkWell( + borderRadius: BorderRadius.circular(AppRadii.card), onTap: () => open(source), - child: _SourceRow( - name: source.name, - package: SubscriptionUserInfo.fromSubscription(source), - detail: - '${controller.providerSource(source) ? l.providerConfigurationCount(controller.sourceCount(source.id)) : l.prototypeServerCount(controller.sourceCount(source.id))} · ${checkedAt(source)}', - status: state.sourceErrors[source.id] ?? l.prototypeUpdated, - failed: state.sourceErrors.containsKey(source.id), - showDivider: source != sources.last, - subscription: true, - busy: controller.sourceBusy(source.id), - onMore: () => open(source), - onUpdate: controller.sourceBusy(source.id) - ? null - : () => controller.sourceAction( - context, - source, - SourceAction.update, + child: SettingSection( + title: source.name, + description: + '${controller.providerSource(source) ? l.subscriptionModeProviderManaged : l.subscriptionModeAppManaged} · ${checkedAt(source)}', + padding: EdgeInsets.zero, + action: AppActivityBuilder( + builder: (context, activity) => + controller.sourceBusy(source.id) && activity.downloading + ? const ButtonProgressIndicator() + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 4, + ), + decoration: BoxDecoration( + color: state.sourceErrors.containsKey(source.id) + ? ColorManager.palette(context) + .destructiveSurface + : ColorManager.palette(context).secondary, + borderRadius: BorderRadius.circular( + AppRadii.control, + ), + ), + child: Text( + state.sourceErrors.containsKey(source.id) + ? l.resultFailed + : l.prototypeUpdated, + style: AppTypography.badge.copyWith( + color: state.sourceErrors.containsKey(source.id) + ? ColorManager.palette(context).destructive + : ColorManager.palette(context) + .secondaryForeground, + ), + ), + ), + ), + children: [ + Text( + controller.providerSource(source) + ? l.providerConfigurationCount( + controller.sourceCount(source.id), + ) + : l.prototypeServerCount( + controller.sourceCount(source.id), + ), + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).mutedForeground, + ), + ), + if (SubscriptionUserInfo.fromSubscription(source) + case final info?) ...[ + const SizedBox(height: 8), + SubscriptionPackageSummary(info: info), + ], + if (state.sourceErrors[source.id] case final error?) ...[ + const SizedBox(height: 8), + SelectableText( + error, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).destructive, + ), + ), + ], + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: controller.sourceBusy(source.id) + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.update, + ), + icon: const Icon(LucideIcons.refreshCw, size: 18), + label: Text(l.prototypeCheckForUpdates), ), + IconButton( + tooltip: '${l.prototypeMoreActions}: ${source.name}', + onPressed: () => open(source), + icon: const Icon( + LucideIcons.chevronRightDir, + size: 18, + ), + ), + ], + ), + ], ), ), ), @@ -359,8 +433,12 @@ class _SourceRow extends StatelessWidget { foregroundColor: palette.mutedStrong, iconSize: 18, padding: EdgeInsets.zero, - minimumSize: const Size.square(36), - fixedSize: const Size.square(36), + minimumSize: Size.square( + AppLayout.touchExtent(36, platform: Theme.of(context).platform), + ), + fixedSize: Size.square( + AppLayout.touchExtent(36, platform: Theme.of(context).platform), + ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ); return Container( diff --git a/lib/pages/servers/subscription/form_view.dart b/lib/pages/servers/subscription/form_view.dart index 000a1634e..d94f64086 100644 --- a/lib/pages/servers/subscription/form_view.dart +++ b/lib/pages/servers/subscription/form_view.dart @@ -129,21 +129,27 @@ class SubscriptionFormView extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _fields(context), - const SizedBox(height: 12), - _editableField( - context, - label: AppLocalizations.of(context)!.subscriptionUserAgentTitle, - controller: userAgentController, - hintText: AppLocalizations.of(context)!.subscriptionUserAgentHint, - textDirection: TextDirection.ltr, - errorText: userAgentErrorText, - focusNode: userAgentFocusNode, - onChanged: onUserAgentChanged, + SettingSection( + title: AppLocalizations.of(context)!.subscriptionInformationTitle, + padding: EdgeInsets.zero, + children: [ + _fields(context), + const SizedBox(height: 12), + _editableField( + context, + label: AppLocalizations.of(context)!.subscriptionUserAgentTitle, + controller: userAgentController, + hintText: AppLocalizations.of(context)!.subscriptionUserAgentHint, + textDirection: TextDirection.ltr, + errorText: userAgentErrorText, + focusNode: userAgentFocusNode, + onChanged: onUserAgentChanged, + ), + ], ), - const SizedBox(height: 14), + const SizedBox(height: 16), _configurationCard(context), - const SizedBox(height: 14), + const SizedBox(height: 16), _SubscriptionAdvanced( configured: hwidEnabled || hasAgeKeys, hasError: ageKeyPairErrorText != null, @@ -245,7 +251,9 @@ class SubscriptionFormView extends StatelessWidget { Expanded( child: Text( title, - style: AppTypography.subscriptionAgeTitle, + style: AppTypography.fieldLabel.copyWith( + color: ColorManager.palette(context).foreground, + ), ), ), if (help != null) @@ -266,7 +274,9 @@ class SubscriptionFormView extends StatelessWidget { ? null : PageHelpButton(title: title, messages: [help]), titleMaxLines: 3, - titleStyle: AppTypography.subscriptionAgeTitle, + titleStyle: AppTypography.fieldLabel.copyWith( + color: ColorManager.palette(context).foreground, + ), contentPadding: const EdgeInsets.all(13), trailing: trailing, ), @@ -617,8 +627,8 @@ class SubscriptionFormView extends StatelessWidget { ), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - style: AppTypography.subscriptionField, - placeholderStyle: AppTypography.subscriptionField.copyWith( + style: AppTypography.rowValue, + placeholderStyle: AppTypography.rowValue.copyWith( color: palette.mutedForeground, ), textDirection: textDirection, @@ -698,8 +708,8 @@ class SubscriptionFormView extends StatelessWidget { Widget _fieldLabel(BuildContext context, String text) { final label = Text( text, - style: AppTypography.subscriptionField.copyWith( - color: ColorManager.palette(context).mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: ColorManager.palette(context).foreground, ), ); // macOS's native text input bridge does not expose the merged field label. diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index 6ef444d9c..19119d68f 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -55,6 +55,9 @@ class _ServerBrowserState extends State { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; if (controller.servers.isEmpty && controller.sources.isEmpty && controller.rawConfigurations.isEmpty && @@ -92,7 +95,7 @@ class _ServerBrowserState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: EdgeInsets.fromLTRB(gutter, 16, gutter, 0), child: ShadTabs<_ServerBrowserTab>( key: const ValueKey('server-resource-tabs'), value: tab, @@ -105,17 +108,50 @@ class _ServerBrowserState extends State { ShadTab( value: _ServerBrowserTab.nodes, content: const SizedBox.shrink(), - child: Text(l.serverNodesTab), + height: 48, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Text(l.serverNodesTab), + Text( + '${controller.servers.where((row) => !controller.providerRow(row)).length}', + style: AppTypography.supporting, + ), + ], + ), ), ShadTab( value: _ServerBrowserTab.configurations, content: const SizedBox.shrink(), - child: Text(l.serverCompleteConfigurationsTab), + height: 48, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Text(l.serverCompleteConfigurationsTab), + Text( + '${controller.servers.where(controller.providerRow).length + controller.rawConfigurations.length}', + style: AppTypography.supporting, + ), + ], + ), ), ShadTab( value: _ServerBrowserTab.subscriptions, content: const SizedBox.shrink(), - child: Text(l.prototypeSubscriptions), + height: 48, + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 6, + children: [ + Text(l.prototypeSubscriptions), + Text( + '${controller.sources.length}', + style: AppTypography.supporting, + ), + ], + ), ), ], ), @@ -124,7 +160,7 @@ class _ServerBrowserState extends State { child: tab == _ServerBrowserTab.subscriptions ? SingleChildScrollView( controller: widget.scroll, - padding: const EdgeInsets.fromLTRB(16, 0, 16, 28), + padding: EdgeInsets.fromLTRB(gutter, 0, gutter, 28), child: _subscriptions(context), ) : _resources( @@ -142,6 +178,7 @@ class _ServerBrowserState extends State { key: const ValueKey('server-search'), controller: controller.search, onChanged: controller.searchChanged, + style: AppTypography.rowValue, decoration: InputDecoration( hintText: l.prototypeSearchSubscriptionsServers, prefixIcon: const Icon(LucideIcons.search, size: 18), @@ -171,6 +208,10 @@ class _ServerBrowserState extends State { Widget _resources(BuildContext context, {required bool complete}) { final l = AppLocalizations.of(context)!; + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; + final gap = mobile ? 12.0 : 16.0; final ordinary = controller.servers .where((row) => controller.providerRow(row) == complete) .where(_matchesSource) @@ -192,13 +233,13 @@ class _ServerBrowserState extends State { key: PageStorageKey(('server-resources', complete)), slivers: [ SliverPadding( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 10), + padding: EdgeInsets.fromLTRB(gutter, 18, gutter, 10), sliver: SliverToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _search(context), - const SizedBox(height: 12), + SizedBox(height: gap), Wrap( spacing: 8, runSpacing: 8, @@ -251,22 +292,24 @@ class _ServerBrowserState extends State { key: ValueKey('server-probe-activity'), downloading: false, ), - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.automatic(), + if (!mobile) + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.automatic(), + ), + icon: const Icon(LucideIcons.shuffle, size: 17), + label: Text(l.prototypeAutomaticSelection), ), - icon: const Icon(LucideIcons.shuffle, size: 17), - label: Text(l.prototypeAutomaticSelection), - ), - OutlinedButton.icon( - onPressed: () => controller.choose( - context, - const ServerSelection.source(0), + if (!mobile) + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.source(0), + ), + icon: const Icon(LucideIcons.layers, size: 17), + label: Text(l.prototypeManualAdditions), ), - icon: const Icon(LucideIcons.layers, size: 17), - label: Text(l.prototypeManualAdditions), - ), ] else FilledButton.icon( onPressed: () => controller.editRaw(context), @@ -275,34 +318,21 @@ class _ServerBrowserState extends State { ), ], ), - if (!complete) ...[ - const SizedBox(height: 16), - SettingSection( - title: '', - padding: EdgeInsets.zero, - children: [ - SettingRow( - title: l.manualGroupsTitle, - subtitle: l.manualGroupCount( - controller.catalog.manualGroups.length, - ), - leading: const Icon(LucideIcons.folder, size: 19), - onTap: () => context.pushScoped( - AppPageDestination.serverManualGroups, - extra: controller, - ), - ), - ], + if (!complete && !filtered) ...[ + SizedBox(height: gap), + SettingRow( + title: l.manualGroupsTitle, + subtitle: l.manualGroupCount( + controller.catalog.manualGroups.length, + ), + leading: const Icon(LucideIcons.folder, size: 19), + onTap: () => context.pushScoped( + AppPageDestination.serverManualGroups, + extra: controller, + ), ), ], - const SizedBox(height: 18), - Text( - complete - ? l.serverCompleteConfigurationsTab - : l.serverNodesTab, - style: AppTypography.sectionTitle, - ), - const SizedBox(height: 4), + SizedBox(height: gap), Text( complete ? l.providerConfigurationCount(count) @@ -317,7 +347,7 @@ class _ServerBrowserState extends State { ), if (count == 0) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: gutter), sliver: SliverToBoxAdapter( child: SettingSection( title: '', @@ -368,7 +398,7 @@ class _ServerBrowserState extends State { ), if (count > 0) SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(horizontal: gutter), sliver: DecoratedSliver( decoration: BoxDecoration( color: palette.card, @@ -376,39 +406,44 @@ class _ServerBrowserState extends State { borderRadius: BorderRadius.circular(AppRadii.card), ), sliver: SliverList.builder( - itemCount: count, + itemCount: count + 2, itemBuilder: (context, index) { - final first = index == 0, last = index == count - 1; - final child = index < ordinary.length - ? ServerNodeRow( - key: ValueKey(('server', ordinary[index].id)), - controller: controller, - row: ordinary[index], - showDivider: !last, - ) - : Column( - children: [ - ServerRawRow( - key: ValueKey(( - 'raw', - raw[index - ordinary.length].id, - )), - controller: controller, - row: raw[index - ordinary.length], - ), - if (!last) - Divider(height: 1, color: palette.border), - ], - ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: ClipRRect( - borderRadius: BorderRadius.vertical( - top: Radius.circular(first ? AppRadii.card - 1 : 0), - bottom: Radius.circular(last ? AppRadii.card - 1 : 0), + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 16, ), - child: child, - ), + child: Text( + complete + ? l.serverCompleteConfigurationsTab + : l.serverNodesTab, + style: AppTypography.sectionTitle, + ), + ); + } + if (index == count + 1) return const SizedBox(height: 18); + final resourceIndex = index - 1; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: resourceIndex < ordinary.length + ? ServerNodeRow( + key: ValueKey(( + 'server', + ordinary[resourceIndex].id, + )), + controller: controller, + row: ordinary[resourceIndex], + showDivider: false, + ) + : ServerRawRow( + key: ValueKey(( + 'raw', + raw[resourceIndex - ordinary.length].id, + )), + controller: controller, + row: raw[resourceIndex - ordinary.length], + ), ); }, ), @@ -447,11 +482,20 @@ class ServerManualGroupsPage extends StatelessWidget { .where((group) => group.manual) .toList(); return Scaffold( - appBar: PageAppBar(title: Text(l.manualGroupsTitle)), + appBar: PageAppBar.forPage(context, title: Text(l.manualGroupsTitle)), body: SafeArea( child: ResponsiveContent( child: ListView( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.fromLTRB( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 16, + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 16 + : 28, + 28, + ), children: [ Wrap( spacing: 8, @@ -463,6 +507,22 @@ class ServerManualGroupsPage extends StatelessWidget { label: Text(l.manualGroupNew), ), ManualGroupOrderButton(controller: controller), + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.automatic(), + ), + icon: const Icon(LucideIcons.shuffle, size: 17), + label: Text(l.prototypeAutomaticSelection), + ), + OutlinedButton.icon( + onPressed: () => controller.choose( + context, + const ServerSelection.source(0), + ), + icon: const Icon(LucideIcons.layers, size: 17), + label: Text(l.prototypeManualAdditions), + ), ], ), const SizedBox(height: 18), @@ -527,23 +587,29 @@ class ServerRawRow extends StatelessWidget { true && controller.connectionView.runtime?.configuration.connection.rawId == row.id; - return SettingRow( - title: row.name, - subtitle: l.xrayRawPageTitle, - leading: const Icon(LucideIcons.fileJson, size: 19), - titleTrailing: selected || running - ? Icon( - running ? LucideIcons.activity : LucideIcons.circleCheck, - size: 16, - color: ColorManager.palette(context).primary, - ) - : null, - onTap: () => context.pushScoped( - AppPageDestination.serverResource, - extra: ServerResourceParams( - controller: controller, - resourceId: row.id, - raw: true, + final palette = ColorManager.palette(context); + return Material( + color: selected || running ? palette.selectedSurface : palette.card, + borderRadius: BorderRadius.circular(AppRadii.control), + clipBehavior: Clip.antiAlias, + child: SettingRow( + title: row.name, + subtitle: l.xrayRawPageTitle, + leading: const Icon(LucideIcons.fileJson, size: 19), + titleTrailing: selected || running + ? Icon( + running ? LucideIcons.activity : LucideIcons.circleCheck, + size: 16, + color: ColorManager.palette(context).primary, + ) + : null, + onTap: () => context.pushScoped( + AppPageDestination.serverResource, + extra: ServerResourceParams( + controller: controller, + resourceId: row.id, + raw: true, + ), ), ), ); @@ -573,10 +639,10 @@ class ServerGroupView extends StatelessWidget { } return Padding( padding: mobile - ? const EdgeInsets.fromLTRB(15, 13, 15, 22) + ? const EdgeInsets.fromLTRB(16, 16, 16, 28) : const EdgeInsets.fromLTRB( AppSpacing.page, - AppSpacing.desktopPageTop, + 16, AppSpacing.page, AppSpacing.desktopPageBottom, ), @@ -610,14 +676,11 @@ class ServerGroupView extends StatelessWidget { itemBuilder: (context, index) { if (index == 0) { return Container( - constraints: BoxConstraints(minHeight: mobile ? 0 : 88), + constraints: const BoxConstraints(minHeight: 0), padding: const EdgeInsets.symmetric( horizontal: 17, vertical: 15, ), - decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: palette.border)), - ), child: mobile ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -710,12 +773,20 @@ class ServerGroupView extends StatelessWidget { } final rowIndex = index - headerCount; final row = group.visibleRows[rowIndex]; - return ServerNodeRow( - key: ValueKey(row.id), - controller: controller, - row: row, - detail: controller.sourceName(l, row), - showDivider: rowIndex != group.visibleRows.length - 1, + return Padding( + padding: EdgeInsets.fromLTRB( + 18, + 0, + 18, + rowIndex == group.visibleRows.length - 1 ? 18 : 0, + ), + child: ServerNodeRow( + key: ValueKey(row.id), + controller: controller, + row: row, + detail: controller.sourceName(l, row), + showDivider: false, + ), ); }, ), @@ -732,15 +803,6 @@ class ServerGroupView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (!mobile) ...[ - Text( - group.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverGroupTitle, - ), - const SizedBox(height: 4), - ], Text( controller.summary(l, group), style: AppTypography.serverGroupSummary.copyWith( @@ -762,15 +824,10 @@ class ServerGroupView extends StatelessWidget { return Row( mainAxisSize: mobile ? MainAxisSize.max : MainAxisSize.min, children: [ - Container( - width: 48, - height: 48, - alignment: Alignment.center, - decoration: BoxDecoration( - color: palette.surfaceHover, - borderRadius: BorderRadius.circular(9), - ), - child: Icon(LucideIcons.layers3, size: 20, color: palette.primary), + Icon( + group.source == null ? LucideIcons.folder : LucideIcons.rss, + size: 20, + color: palette.mutedForeground, ), const SizedBox(width: 13), if (mobile) Expanded(child: label) else Flexible(child: label), @@ -801,9 +858,12 @@ class ServerGroupView extends StatelessWidget { style: OutlinedButton.styleFrom( minimumSize: Size( 0, - mobile - ? AppLayout.mobileButtonMinHeight - : AppLayout.buttonMinHeight, + AppLayout.touchExtent( + mobile + ? AppLayout.mobileButtonMinHeight + : AppLayout.buttonMinHeight, + platform: Theme.of(context).platform, + ), ), padding: const EdgeInsets.symmetric(horizontal: 15), foregroundColor: palette.foreground, @@ -940,11 +1000,14 @@ class ServerUseButton extends StatelessWidget { style: OutlinedButton.styleFrom( minimumSize: Size( 0, - mobile - ? AppLayout.mobileButtonMinHeight - : inline - ? 32 - : 36, + AppLayout.touchExtent( + mobile + ? AppLayout.mobileButtonMinHeight + : inline + ? 32 + : 36, + platform: Theme.of(context).platform, + ), ), padding: EdgeInsets.symmetric( horizontal: !mobile && inline ? 7 : 9, @@ -1024,7 +1087,7 @@ class ServerNodeRow extends StatelessWidget { required this.controller, required this.row, this.detail, - this.showDivider = true, + this.showDivider = false, }); @override @@ -1088,102 +1151,138 @@ class ServerNodeRow extends StatelessWidget { }) { final l = AppLocalizations.of(context)!; final p = ColorManager.palette(context); - return Column( - children: [ - Material( - color: chosen || running ? p.selectedSurface : p.card, - child: InkWell( - onTap: () => context.pushScoped( - AppPageDestination.serverResource, - extra: ServerResourceParams( - controller: controller, - resourceId: row.id, + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 300; + return Column( + children: [ + Material( + color: chosen || running ? p.selectedSurface : p.card, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.control), + side: chosen || running + ? BorderSide(color: p.primary.withValues(alpha: .2)) + : BorderSide.none, ), - ), - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 8, 12), - child: Row( - children: [ - Icon( - controller.providerRow(row) - ? LucideIcons.fileJson - : LucideIcons.server, - size: 20, - color: p.foreground, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.pushScoped( + AppPageDestination.serverResource, + extra: ServerResourceParams( + controller: controller, + resourceId: row.id, ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - controller.serverName(row), - style: AppTypography.serverNodeTitle.copyWith( - color: p.foreground, + ), + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(12, 12, 0, 12), + child: Row( + children: [ + Icon( + controller.providerRow(row) + ? LucideIcons.fileJson + : LucideIcons.server, + size: 20, + color: chosen || running + ? p.primaryText + : p.mutedForeground, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.serverName(row), + style: AppTypography.serverNodeTitle.copyWith( + color: chosen || running + ? p.primaryText + : p.foreground, + ), + ), + const SizedBox(height: 4), + Text( + [ + if (protocol.isNotEmpty) protocol, + detail ?? controller.sourceName(l, row), + ].join(' · '), + style: AppTypography.metadata.copyWith( + color: p.mutedForeground, + ), + ), + if (compact && !controller.providerRow(row)) ...[ + const SizedBox(height: 4), + Text( + controller.health(l, row), + style: AppTypography.metadata.copyWith( + color: ColorManager.nodeLatency( + context, + row.delay, + ), + ), + ), + ], + ], + ), + ), + if (!compact && !controller.providerRow(row)) + Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 88), + child: Text( + controller.health(l, row), + textAlign: TextAlign.end, + style: AppTypography.metadata.copyWith( + color: ColorManager.nodeLatency( + context, + row.delay, + ), + ), + ), ), ), - const SizedBox(height: 4), - Text( - [ - if (protocol.isNotEmpty) protocol, - detail ?? controller.sourceName(l, row), - ].join(' · '), - style: AppTypography.metadata.copyWith( - color: p.mutedForeground, + if (running || chosen) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 8, + end: 4, + ), + child: Icon( + running + ? LucideIcons.activity + : LucideIcons.circleCheck, + size: 17, + color: p.primary, ), ), - if (!controller.providerRow(row)) ...[ - const SizedBox(height: 3), - Text( - controller.health(l, row), - style: AppTypography.metadata.copyWith( - color: ColorManager.nodeLatency( - context, - row.delay, - ), - ), + if (!controller.providerRow(row)) + IconButton( + tooltip: row.favorite + ? l.prototypeRemoveFavorite + : l.prototypeAddFavorite, + onPressed: controller.serverBusy(row) + ? null + : () => controller.toggleFavorite(context, row), + isSelected: row.favorite, + icon: controller.favoritingIds.contains(row.id) + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.star, size: 17), + selectedIcon: const Icon( + LucideIcons.star600, + size: 17, ), - ], - ], - ), + ), + ServerMenu(controller: controller, row: row), + ], ), - if (running || chosen) - Padding( - padding: const EdgeInsetsDirectional.only( - start: 8, - end: 4, - ), - child: Icon( - running - ? LucideIcons.activity - : LucideIcons.circleCheck, - size: 17, - color: p.primary, - ), - ), - if (!controller.providerRow(row)) - IconButton( - tooltip: row.favorite - ? l.prototypeRemoveFavorite - : l.prototypeAddFavorite, - onPressed: controller.serverBusy(row) - ? null - : () => controller.toggleFavorite(context, row), - isSelected: row.favorite, - icon: controller.favoritingIds.contains(row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.star, size: 17), - selectedIcon: const Icon(LucideIcons.star600, size: 17), - ), - ServerMenu(controller: controller, row: row), - ], + ), ), ), - ), - ), - if (showDivider) Divider(height: 1, color: p.border), - ], + if (showDivider) Divider(height: 1, color: p.border), + ], + ); + }, ); } } @@ -1223,7 +1322,9 @@ class ServerMenu extends StatelessWidget { foregroundColor: ColorManager.palette(context).mutedStrong, iconSize: 18, padding: EdgeInsets.zero, - minimumSize: const Size(42, 42), + minimumSize: Size.square( + AppLayout.touchExtent(36, platform: Theme.of(context).platform), + ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: const RoundedRectangleBorder(), ), @@ -1265,7 +1366,9 @@ class SourceMenu extends StatelessWidget { foregroundColor: ColorManager.palette(context).mutedStrong, iconSize: 18, padding: EdgeInsets.zero, - minimumSize: const Size(36, 42), + minimumSize: Size.square( + AppLayout.touchExtent(36, platform: Theme.of(context).platform), + ), tapTargetSize: MaterialTapTargetSize.shrinkWrap, shape: const RoundedRectangleBorder(), ), diff --git a/lib/pages/settings/about/page.dart b/lib/pages/settings/about/page.dart index f669f099a..34f778b09 100644 --- a/lib/pages/settings/about/page.dart +++ b/lib/pages/settings/about/page.dart @@ -24,60 +24,24 @@ class AboutOneXrayPage extends StatelessWidget { builder: (context, state) { final l10n = AppLocalizations.of(context)!; final controller = context.read(); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar(title: Text(l10n.prototypeAboutOneXray)), + appBar: PageAppBar.forPage( + context, + title: Text(l10n.prototypeAboutOneXray), + description: l10n.prototypeCrossPlatformXrayClient, + ), body: SafeArea( child: SettingsPageScroll( desktopMaxWidth: AppLayout.routingMaxWidth, alignment: AlignmentDirectional.topStart, padding: EdgeInsets.zero, child: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 14 : AppSpacing.page, - mobile ? 17 : AppSpacing.desktopPageTop, - mobile ? 14 : AppSpacing.page, - mobile ? 26 : AppSpacing.desktopPageBottom + 26, - ), + padding: settingsContentPadding(context), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (mobile) ...[ - Text( - l10n.prototypeAppInformation, - style: AppTypography.settingsDetailNote.copyWith( - color: ColorManager.secondaryText(context), - ), - ), - const SizedBox(height: 23), - ], - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 16), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(16), - child: state.appIcon.assetImage.image( - width: 74, - height: 74, - ), - ), - const SizedBox(height: 9), - Text('OneXray', style: AppTypography.aboutBrandTitle), - const SizedBox(height: 9), - Text( - l10n.prototypeCrossPlatformXrayClient, - style: AppTypography.aboutBrandDescription.copyWith( - color: ColorManager.secondaryText(context), - ), - ), - ], - ), - ), - SizedBox(height: mobile ? 23 : 24), SettingSection( - title: '', + title: 'OneXray', padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -85,53 +49,28 @@ class AboutOneXrayPage extends StatelessWidget { label: l10n.prototypeAppVersion, value: state.appVersion, compact: true, - style: mobile - ? AppTypography.settingsVersion - : AppTypography.desktopSettingsVersion, ), SettingsVersionRow( label: 'Xray-core', value: state.xrayVersion, compact: true, - style: mobile - ? AppTypography.settingsVersion - : AppTypography.desktopSettingsVersion, ), BlocBuilder( builder: (context, preferences) => SettingRow( title: l10n.prototypeCheckAppUpdates, - minHeight: 64, - titleStyle: AppTypography.settingsRow, - subtitleStyle: mobile - ? AppTypography.settingsChoiceDetail - : AppTypography.desktopSettingsHint, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 12, - ), subtitle: preferences.appUpdateInfo == null ? l10n.prototypeCheckNewVersions : l10n.prototypeVersionAvailable( preferences.appUpdateInfo!.latestVersion, ), + titleTrailing: preferences.appUpdateInfo == null + ? null + : const SettingsUpdateDot(), + showChevron: true, trailing: preferences.downloading || state.checkingUpdate ? const ButtonProgressIndicator(size: 20) - : Stack( - clipBehavior: Clip.none, - children: [ - const Icon( - LucideIcons.download, - size: 18, - ), - if (preferences.appUpdateInfo != null) - const PositionedDirectional( - end: -4, - top: -4, - child: SettingsUpdateDot(), - ), - ], - ), + : null, onTap: state.checkingUpdate ? null : () => controller.checkUpdate(context), @@ -139,57 +78,55 @@ class AboutOneXrayPage extends StatelessWidget { ), ], ), - SizedBox(height: mobile ? 23 : 24), + const SizedBox(height: 20), SettingSection( title: l10n.prototypeHelpCommunity, headerInset: 0, - icon: LucideIcons.circleHelp, padding: EdgeInsets.zero, dividerIndent: 0, children: [ _LinkRow( label: l10n.prototypeDocumentation, - icon: LucideIcons.bookOpen, + link: SettingsLink.documentation, ), if (AppPlatform.isMobile || AppPlatform.isMacOS) _LinkRow( label: l10n.prototypeRateOneXray, - icon: LucideIcons.star, + link: SettingsLink.review, ), _LinkRow( label: l10n.prototypeCommunity, - icon: LucideIcons.send, + link: SettingsLink.community, ), _LinkRow( label: l10n.prototypeSendFeedback, - icon: LucideIcons.bug, + link: SettingsLink.feedback, ), _LinkRow( label: l10n.prototypeSourceCode, - icon: LucideIcons.code2, + link: SettingsLink.source, ), _LinkRow( label: l10n.prototypeAcknowledgements, - icon: LucideIcons.circleHelp, link: SettingsLink.credits, ), _LinkRow( label: l10n.prototypePrivacyPolicy, - icon: LucideIcons.shieldCheck, + link: SettingsLink.privacy, ), ], ), Padding( - padding: EdgeInsets.only(top: mobile ? 13 : 14), + padding: const EdgeInsets.only(top: 20), child: Text( l10n.prototypeAboutPrivacyNotice, - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: ColorManager.secondaryText(context), ), ), @@ -207,21 +144,12 @@ class AboutOneXrayPage extends StatelessWidget { class _LinkRow extends StatelessWidget { final String label; - final IconData icon; final SettingsLink link; - const _LinkRow({required this.label, required this.icon, required this.link}); + const _LinkRow({required this.label, required this.link}); @override Widget build(BuildContext context) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return SettingRow( title: label, - minHeight: mobile ? 43 : 56, - titleStyle: AppTypography.settingsRow, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 10, - ), trailing: const Icon(LucideIcons.chevronRightDir, size: 17), onTap: () => context.read().openLink(context, link), ); diff --git a/lib/pages/settings/app_icon/page.dart b/lib/pages/settings/app_icon/page.dart index 2905330c5..d53509711 100644 --- a/lib/pages/settings/app_icon/page.dart +++ b/lib/pages/settings/app_icon/page.dart @@ -6,8 +6,9 @@ import 'package:onexray/gen/assets.gen.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/app_icon/controller.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; -import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/settings/widgets.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; @@ -26,7 +27,9 @@ class AppIconPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final useDockIconLabel = AppPlatform.isMacOS; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, + description: l10n.settingsDevicePreferenceDescription, title: Text(l10n.prototypeAppIcon), leading: BackButton(onPressed: () => controller.cancel(context)), ), @@ -44,7 +47,7 @@ class AppIconPage extends StatelessWidget { ), ), bottomNavigationBar: PageActionBar( - maxWidth: AppLayout.routingEditorMaxWidth, + maxWidth: AppLayout.standardMaxWidth, children: [ ShadButton.outline( onPressed: () => controller.cancel(context), @@ -86,82 +89,40 @@ class AppIconChoiceView extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return SettingsPageScroll( - desktopMaxWidth: AppLayout.routingMaxWidth, - alignment: AlignmentDirectional.topStart, padding: EdgeInsets.zero, child: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 14 : AppSpacing.page, - mobile ? 17 : AppSpacing.desktopPageTop, - mobile ? 14 : AppSpacing.page, - mobile ? 26 : AppSpacing.desktopPageBottom + 26, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + padding: settingsContentPadding(context), + child: SettingSection( + title: useDockIconAssets + ? l10n.settingsDockIcon + : l10n.settingsHomeScreenIcon, + padding: EdgeInsets.zero, children: [ - if (mobile) ...[ - Text( - description, - style: AppTypography.settingsDetailNote.copyWith( - color: ColorManager.secondaryText(context), - ), - ), - const SizedBox(height: 23), - ], - Padding( - padding: const EdgeInsets.only(top: 8, bottom: 16), - child: Column( - children: [ - _iconImage(_imageFor(selected), 100), - const SizedBox(height: 9), - Text('OneXray', style: AppTypography.iconPreviewBrand), - const SizedBox(height: 9), - Text( - useDockIconAssets - ? l10n.prototypeDockPreview - : l10n.prototypeHomeScreenPreview, - style: AppTypography.iconPreviewCaption.copyWith( - color: ColorManager.secondaryText(context), - ), - ), - ], - ), - ), - SizedBox(height: mobile ? 23 : 24), - ShadRadioGroup( - axis: Axis.horizontal, + Text(l10n.prototypeAppIcon, style: AppTypography.rowValue), + const SizedBox(height: 12), + ShadSelect( + key: ValueKey(selected), initialValue: selected, + selectedOptionBuilder: (_, icon) => + Text(appIconLabel(l10n, icon)), + options: [ + for (final icon in AppIcon.values) + ShadOption( + value: icon, + child: Text(appIconLabel(l10n, icon)), + ), + ], onChanged: (icon) { if (icon != null && icon != selected) onSelected(icon); }, - items: [ - LayoutBuilder( - builder: (context, constraints) { - final gap = mobile ? 10.0 : 14.0; - return Wrap( - spacing: gap, - runSpacing: gap, - children: [ - for (final icon in AppIcon.values) - SizedBox( - width: (constraints.maxWidth - gap * 2) / 3, - child: _AppIconOption( - icon: icon, - label: appIconLabel(l10n, icon), - image: _imageFor(icon), - selected: selected == icon, - onTap: () => onSelected(icon), - mobile: mobile, - ), - ), - ], - ); - }, - ), - ], + ), + const SizedBox(height: 28), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: _iconImage(_imageFor(selected), 96), + ), ), ], ), @@ -186,71 +147,3 @@ String appIconLabel(AppLocalizations l10n, AppIcon icon) => switch (icon) { AppIcon.purple => l10n.prototypeIconPurple, AppIcon.red => l10n.prototypeIconRed, }; - -class _AppIconOption extends StatelessWidget { - final AppIcon icon; - final String label; - final AssetGenImage image; - final bool selected; - final VoidCallback onTap; - final bool mobile; - - const _AppIconOption({ - required this.icon, - required this.label, - required this.image, - required this.selected, - required this.onTap, - required this.mobile, - }); - - @override - Widget build(BuildContext context) { - final primary = Theme.of(context).colorScheme.primary; - return Semantics( - button: true, - selected: selected, - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 120), - padding: EdgeInsets.symmetric( - horizontal: mobile ? 5 : 10, - vertical: mobile ? 14 : 18, - ), - decoration: BoxDecoration( - color: selected - ? ColorManager.selected(context) - : ColorManager.surface(context), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: selected ? primary : ColorManager.border(context), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIconChoiceView._iconImage(image, mobile ? 62 : 72), - const SizedBox(height: 12), - ShadRadio( - value: icon, - decoration: selected - ? ShadDecoration( - border: ShadBorder.all(color: primary, width: 1), - ) - : null, - radioPadding: EdgeInsets.zero, - padding: const EdgeInsetsDirectional.only(start: 7), - label: Text(label, style: AppTypography.iconChoice), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/settings/app_update/dialog.dart b/lib/pages/settings/app_update/dialog.dart index bbb5e3c56..dd70f9057 100644 --- a/lib/pages/settings/app_update/dialog.dart +++ b/lib/pages/settings/app_update/dialog.dart @@ -4,8 +4,8 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/app_update/controller.dart'; import 'package:onexray/pages/settings/app_update/params.dart'; -import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/service/settings/app_update/service.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -69,14 +69,14 @@ class AppUpdateDialogView extends StatelessWidget { child: ShadCard( width: double.infinity, padding: EdgeInsets.zero, - radius: const BorderRadius.all(Radius.circular(8)), + radius: const BorderRadius.all(Radius.circular(AppRadii.card)), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsetsDirectional.fromSTEB(20, 18, 20, 16), + padding: const EdgeInsetsDirectional.fromSTEB(18, 16, 18, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -90,18 +90,17 @@ class AppUpdateDialogView extends StatelessWidget { ), ), if (notes.isNotEmpty) ...[ - Divider(height: 1, color: ColorManager.border(context)), Flexible( child: ConstrainedBox( constraints: BoxConstraints(maxHeight: notesHeight), child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 18), + padding: const EdgeInsets.fromLTRB(18, 0, 18, 18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.prototypeReleaseNotes, - style: AppTypography.updateNotesHeading, + style: AppTypography.sectionTitle, ), const SizedBox(height: 10), // flutter_markdown_plus still uses SDK Material. @@ -111,8 +110,8 @@ class AppUpdateDialogView extends StatelessWidget { data: notes, selectable: true, styleSheet: MarkdownStyleSheet( - p: AppTypography.updateNotes, - listBullet: AppTypography.updateNotes, + p: AppTypography.supporting, + listBullet: AppTypography.supporting, listIndent: 20, listBulletPadding: const EdgeInsetsDirectional.only(end: 4) @@ -128,7 +127,6 @@ class AppUpdateDialogView extends StatelessWidget { ), ), ], - Divider(height: 1, color: ColorManager.border(context)), _UpdateActions( laterLabel: l10n.appUpdateLater, skipLabel: l10n.appUpdateSkipVersion, @@ -166,18 +164,8 @@ class _VersionGrid extends StatelessWidget { Widget _row(BuildContext context, String label, String version) { return Row( children: [ - Expanded(child: Text(label, style: AppTypography.updateVersionLabel)), - Container( - padding: const EdgeInsetsDirectional.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: ColorManager.palette(context).surfaceHover, - borderRadius: BorderRadius.circular(4), - ), - child: Text(version, style: AppTypography.updateVersion), - ), + Expanded(child: Text(label, style: AppTypography.rowTitle)), + Text(version, style: AppTypography.rowValue), ], ); } @@ -206,18 +194,17 @@ class _UpdateActions extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.all(14), - color: ColorManager.palette(context).muted, + padding: const EdgeInsets.fromLTRB(18, 0, 18, 18), child: LayoutBuilder( builder: (context, constraints) { final actions = [ ShadButton.outline( - height: 40, + height: 48, onPressed: onLater, child: Text(laterLabel), ), ShadButton.outline( - height: 40, + height: 48, enabled: action != AppUpdateDialogAction.skip, onPressed: action == AppUpdateDialogAction.skip ? null : onSkip, child: ButtonProgress( @@ -225,7 +212,11 @@ class _UpdateActions extends StatelessWidget { child: Text(skipLabel), ), ), - ShadButton(onPressed: onUpdate, child: Text(updateLabel)), + ShadButton( + height: 48, + onPressed: onUpdate, + child: Text(updateLabel), + ), ]; if (constraints.maxWidth < 460) { return Column( diff --git a/lib/pages/settings/backup/page.dart b/lib/pages/settings/backup/page.dart index 2adb450c1..0e776e10a 100644 --- a/lib/pages/settings/backup/page.dart +++ b/lib/pages/settings/backup/page.dart @@ -2,8 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:intl/intl.dart' show DateFormat; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/backup/controller.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; @@ -11,10 +9,11 @@ import 'package:onexray/pages/shared/widgets/menu_picker.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/settings/widgets.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/settings/backup/service.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; import 'package:onexray/pages/shared/widgets/app_switch.dart'; @@ -45,13 +44,13 @@ class BackupPage extends StatelessWidget { TargetPlatform.macOS, TargetPlatform.windows, }.contains(platform); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final date = DateFormat.yMd(Localizations.localeOf(context).toString()) .add_Hm(); final busy = state.busy; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, + description: l.backupPageDescription, title: Text(l.backupTitle), actions: [ PageHelpButton( @@ -72,25 +71,24 @@ class BackupPage extends StatelessWidget { bottomNavigationBar: supported ? PageActionBar( children: [ - OutlinedButton( + SettingsActionButton( + label: l.backupRestore, + variant: ShadButtonVariant.outline, + enabled: !busy && target != null, + busy: state.action == BackupPageAction.restoring, onPressed: busy || target == null ? null : () => controller.restore(context), - child: ButtonProgress( - busy: state.action == BackupPageAction.restoring, - child: Text(l.backupRestore), - ), ), - FilledButton( + SettingsActionButton( + label: l.backupNow, + enabled: !busy && target != null, + busy: + state.action == BackupPageAction.writing || + backup.operation == BackupOperation.writing, onPressed: busy || target == null ? null : () => controller.backup(context), - child: ButtonProgress( - busy: - state.action == BackupPageAction.writing || - backup.operation == BackupOperation.writing, - child: Text(l.backupNow), - ), ), ], ) @@ -102,12 +100,10 @@ class BackupPage extends StatelessWidget { ? Center(child: Text(l.prototypeTemporarilyUnavailable)) : SettingsPageScroll( child: Padding( - padding: EdgeInsets.all( - mobile ? AppSpacing.mobilePage : AppSpacing.page, - ), + padding: settingsContentPadding(context), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 24, + spacing: 20, children: [ SettingSection( title: l.backupLocation, @@ -115,7 +111,7 @@ class BackupPage extends StatelessWidget { padding: EdgeInsets.zero, children: [ Padding( - padding: const EdgeInsets.all(14), + padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -125,7 +121,7 @@ class BackupPage extends StatelessWidget { SelectableText( target.label, textDirection: TextDirection.ltr, - style: AppTypography.code, + style: AppTypography.supporting, ) else Text(l.backupNotConfigured), @@ -135,16 +131,25 @@ class BackupPage extends StatelessWidget { children: [ if (target == null) ...[ if (apple) - FilledButton( + SettingsActionButton( + label: l.backupUseICloud, + enabled: !busy, onPressed: busy ? null : () => controller.select( context, create: false, ), - child: Text(l.backupUseICloud), ), - OutlinedButton( + SettingsActionButton( + label: android || apple + ? l.backupChooseExisting + : l.backupChooseFolder, + variant: ShadButtonVariant.outline, + enabled: !busy, + busy: + state.action == + BackupPageAction.selecting, onPressed: busy ? null : () => controller.select( @@ -152,19 +157,16 @@ class BackupPage extends StatelessWidget { create: false, local: apple, ), - child: ButtonProgress( - busy: - state.action == - BackupPageAction.selecting, - child: Text( - android || apple - ? l.backupChooseExisting - : l.backupChooseFolder, - ), - ), ), if (android || apple) - OutlinedButton( + SettingsActionButton( + label: l.backupCreateFile, + variant: + ShadButtonVariant.outline, + enabled: !busy, + busy: + state.action == + BackupPageAction.creating, onPressed: busy ? null : () => controller.select( @@ -172,26 +174,20 @@ class BackupPage extends StatelessWidget { create: true, local: apple, ), - child: ButtonProgress( - busy: - state.action == - BackupPageAction.creating, - child: Text(l.backupCreateFile), - ), ), ] else ...[ - TextButton( + SettingsActionButton( + label: l.backupUnbind, + variant: ShadButtonVariant.ghost, + enabled: !busy, + busy: + state.action == + BackupPageAction.unbinding, onPressed: busy ? null : () => controller.unbind( context, ), - child: ButtonProgress( - busy: - state.action == - BackupPageAction.unbinding, - child: Text(l.backupUnbind), - ), ), if (android || (apple && @@ -213,42 +209,43 @@ class BackupPage extends StatelessWidget { create: create, local: apple, ), - triggerBuilder: (open) => OutlinedButton( - onPressed: busy ? null : open, - child: ButtonProgress( - busy: - state.action == - BackupPageAction - .selecting || - state.action == - BackupPageAction - .creating, - child: Text( - apple + triggerBuilder: (open) => + SettingsActionButton( + label: apple ? l.backupSwitchToLocal : l.backupChangeLocation, + variant: ShadButtonVariant + .outline, + enabled: !busy, + busy: + state.action == + BackupPageAction + .selecting || + state.action == + BackupPageAction + .creating, + onPressed: busy + ? null + : open, ), - ), - ), ) else - OutlinedButton( + SettingsActionButton( + label: apple + ? l.backupSwitchToCloud + : l.backupChangeLocation, + variant: + ShadButtonVariant.outline, + enabled: !busy, + busy: + state.action == + BackupPageAction.selecting, onPressed: busy ? null : () => controller.select( context, create: false, ), - child: ButtonProgress( - busy: - state.action == - BackupPageAction.selecting, - child: Text( - apple - ? l.backupSwitchToCloud - : l.backupChangeLocation, - ), - ), ), ], ], @@ -321,18 +318,10 @@ class BackupPage extends StatelessWidget { ), ], ), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: palette.warningSurface, - borderRadius: BorderRadius.circular( - AppRadii.compact, - ), - ), - child: Text( - l.backupSensitiveWarning, - style: AppTypography.settingsDetailNote, - ), + ShadAlert( + icon: const Icon(LucideIcons.circleHelp, size: 18), + description: Text(l.backupSensitiveWarning), + descriptionStyle: AppTypography.supporting, ), Text( backup.settings.lastSuccess == null @@ -342,7 +331,7 @@ class BackupPage extends StatelessWidget { backup.settings.lastSuccess!.toLocal(), ), ), - style: AppTypography.settingsDetailNote, + style: AppTypography.supporting, ), if (backup.error != null) SelectableText( @@ -351,7 +340,7 @@ class BackupPage extends StatelessWidget { backup.error, operation: l.backupFailed, ), - style: AppTypography.settingsDetailNote.copyWith( + style: AppTypography.supporting.copyWith( color: palette.destructive, ), ), diff --git a/lib/pages/settings/controller.dart b/lib/pages/settings/controller.dart index c496a98a8..28bf013a8 100644 --- a/lib/pages/settings/controller.dart +++ b/lib/pages/settings/controller.dart @@ -150,7 +150,9 @@ class SettingsController extends PageCubit { AppPageDestination destination, ) async { await context.pushScoped(destination); - if (isPageActive && destination == AppPageDestination.appIcon) { + if (isPageActive && + (destination == AppPageDestination.appIcon || + destination == AppPageDestination.desktopSettings)) { await _readPreferences(); } } diff --git a/lib/pages/settings/desktop/page.dart b/lib/pages/settings/desktop/page.dart index 790c7de03..6c71c5363 100644 --- a/lib/pages/settings/desktop/page.dart +++ b/lib/pages/settings/desktop/page.dart @@ -1,46 +1,90 @@ import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/core/desktop_startup/model.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/settings/controller.dart'; import 'package:onexray/pages/settings/desktop/controller.dart'; +import 'package:onexray/pages/settings/widgets.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class DesktopSettingsPage extends StatelessWidget { - const DesktopSettingsPage({super.key}); + const DesktopSettingsPage({super.key, this.showDesktopOptions}); + + final bool? showDesktopOptions; @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => DesktopSettingsController(), - child: BlocBuilder( - builder: (context, state) { - final controller = context.read(); + Widget build(BuildContext context) => BlocProvider( + create: (_) => SettingsController(), + child: BlocBuilder( + builder: (context, settings) { + final l = AppLocalizations.of(context)!; + final connect = _StartupToggle( + title: l.prototypeConnectAfterAppLaunch, + subtitle: l.prototypeConnectAfterAppLaunchHint, + value: settings.connectOnLaunch, + onChanged: settings.loading || settings.saving + ? null + : (value) => context + .read() + .setConnectOnLaunch(context, value), + ); + if (!(showDesktopOptions ?? AppPlatform.isDesktop)) { return SettingsPageScaffold( - title: AppLocalizations.of(context)!.settingsPageDesktop, - body: DesktopSettingsView( - state: state, - showMacOSOptions: AppPlatform.isMacOS, - onLaunchAtLoginChanged: (value) => - controller.updateLaunchAtLogin(context, value), - onStartHiddenChanged: controller.updateStartHidden, - onHideDockIconChanged: controller.updateHideDockIcon, - onOpenSystemSettings: () => - controller.openSystemSettings(context), + title: l.prototypeStartup, + description: l.settingsDevicePreferenceDescription, + body: SettingsPageScroll( + padding: EdgeInsets.zero, + child: Padding( + padding: settingsContentPadding(context), + child: SettingSection( + title: l.desktopSettingsPageSectionStartup, + padding: EdgeInsets.zero, + children: [connect], + ), + ), ), ); - }, - ), - ); - } + } + return BlocProvider( + create: (_) => DesktopSettingsController(), + child: + BlocBuilder( + builder: (context, state) { + final controller = context.read(); + return SettingsPageScaffold( + title: l.prototypeStartup, + description: l.settingsDevicePreferenceDescription, + body: DesktopSettingsView( + state: state, + connectOnLaunch: connect, + showMacOSOptions: AppPlatform.isMacOS, + onLaunchAtLoginChanged: (value) => + controller.updateLaunchAtLogin(context, value), + onStartHiddenChanged: (value) => + controller.updateStartHidden(value, context: context), + onHideDockIconChanged: (value) => controller + .updateHideDockIcon(value, context: context), + onOpenSystemSettings: () => + controller.openSystemSettings(context), + ), + ); + }, + ), + ); + }, + ), + ); } class DesktopSettingsView extends StatelessWidget { final DesktopSettingsPageState state; final bool showMacOSOptions; + final Widget? connectOnLaunch; final ValueChanged onLaunchAtLoginChanged; final ValueChanged onStartHiddenChanged; final ValueChanged onHideDockIconChanged; @@ -50,6 +94,7 @@ class DesktopSettingsView extends StatelessWidget { super.key, required this.state, required this.showMacOSOptions, + this.connectOnLaunch, required this.onLaunchAtLoginChanged, required this.onStartHiddenChanged, required this.onHideDockIconChanged, @@ -58,65 +103,107 @@ class DesktopSettingsView extends StatelessWidget { @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + final l = AppLocalizations.of(context)!; final launchStatus = state.launchAtLogin; - return SettingsPageScroll( - desktopMaxWidth: 720, - child: Column( - children: [ - SettingSection( - title: l10n.desktopSettingsPageSectionStartup, - children: [ - SwitchSettingRow( - title: l10n.settingsPageLaunchAtLogin, - subtitle: launchStatus.state == LaunchAtLoginState.unavailable - ? l10n.settingsPageLaunchAtLoginUnavailable - : null, - leading: const Icon(LucideIcons.logIn), - value: launchStatus.enabled, - onChanged: state.launchToggleEnabled - ? onLaunchAtLoginChanged - : null, + padding: EdgeInsets.zero, + child: Padding( + padding: settingsContentPadding(context), + child: SettingSection( + title: l.desktopSettingsPageSectionStartup, + padding: EdgeInsets.zero, + children: [ + ?connectOnLaunch, + _StartupToggle( + title: l.settingsPageLaunchAtLogin, + subtitle: launchStatus.state == LaunchAtLoginState.unavailable + ? l.settingsPageLaunchAtLoginUnavailable + : l.prototypeLaunchAtLoginHint, + value: launchStatus.enabled, + onChanged: state.launchToggleEnabled && !state.requiresApproval + ? onLaunchAtLoginChanged + : null, + ), + if (state.requiresApproval) ...[ + SettingRow( + title: l.settingsPageLaunchAtLoginRequiresApproval, + subtitle: l.settingsPageLaunchAtLoginApprovalDescription, ), - if (state.requiresApproval) - SettingRow( - title: l10n.settingsPageLaunchAtLoginRequiresApproval, - subtitle: l10n.settingsPageLaunchAtLoginApprovalDescription, - leading: const Icon(LucideIcons.settings2), - trailing: state.openingSystemSettings - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.externalLink), - onTap: state.openingSystemSettings - ? null - : onOpenSystemSettings, - ), - SwitchSettingRow( - title: l10n.settingsPageStartHidden, - leading: const Icon(LucideIcons.eyeOff), - value: state.startHidden, - onChanged: state.behaviorSettingsEnabled - ? onStartHiddenChanged - : null, + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.outline( + enabled: !state.changingLaunchAtLogin, + onPressed: state.changingLaunchAtLogin + ? null + : () => onLaunchAtLoginChanged(false), + child: ButtonProgress( + busy: state.changingLaunchAtLogin, + child: Text(l.prototypeCancelRequest), + ), + ), + ShadButton.outline( + enabled: !state.openingSystemSettings, + onPressed: state.openingSystemSettings + ? null + : onOpenSystemSettings, + child: ButtonProgress( + busy: state.openingSystemSettings, + child: Text(l.prototypeOpenSystemSettings), + ), + ), + ], ), ], - ), - if (showMacOSOptions) - SettingSection( - title: "macOS", - children: [ - SwitchSettingRow( - title: l10n.desktopSettingsPageHideDockIcon, - leading: const Icon(LucideIcons.monitorCog), - value: state.hideDockIcon, - onChanged: state.behaviorSettingsEnabled - ? onHideDockIconChanged - : null, - ), - ], + _StartupToggle( + title: l.settingsPageStartHidden, + subtitle: l.prototypeStartHiddenHint, + value: state.startHidden, + onChanged: state.behaviorSettingsEnabled + ? onStartHiddenChanged + : null, ), - ], + if (showMacOSOptions) + _StartupToggle( + title: l.desktopSettingsPageHideDockIcon, + subtitle: l.prototypeHideDockIconHint, + value: state.hideDockIcon, + onChanged: state.behaviorSettingsEnabled + ? onHideDockIconChanged + : null, + ), + ], + ), ), ); } } + +class _StartupToggle extends StatelessWidget { + const _StartupToggle({ + required this.title, + this.subtitle, + required this.value, + required this.onChanged, + }); + + final String title; + final String? subtitle; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => SettingRow( + title: title, + subtitle: subtitle, + enabled: onChanged != null, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + leading: AppSwitch( + value: value, + enabled: onChanged != null, + onChanged: onChanged, + ), + onTap: onChanged == null ? null : () => onChanged!(!value), + ); +} diff --git a/lib/pages/settings/language/page.dart b/lib/pages/settings/language/page.dart index d15f77862..5fc0b9cda 100644 --- a/lib/pages/settings/language/page.dart +++ b/lib/pages/settings/language/page.dart @@ -4,8 +4,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/language/controller.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; -import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; @@ -25,7 +25,9 @@ class LanguagePage extends StatelessWidget { final controller = context.read(); final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, + description: l10n.settingsDevicePreferenceDescription, title: Text(l10n.prototypeLanguage), leading: BackButton(onPressed: () => controller.cancel(context)), ), @@ -39,7 +41,7 @@ class LanguagePage extends StatelessWidget { ), ), bottomNavigationBar: PageActionBar( - maxWidth: AppLayout.routingEditorMaxWidth, + maxWidth: AppLayout.standardMaxWidth, children: [ ShadButton.outline( onPressed: () => controller.cancel(context), @@ -77,116 +79,49 @@ class LanguageChoiceView extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return SettingsPageScroll( - desktopMaxWidth: AppLayout.routingMaxWidth, - alignment: AlignmentDirectional.topStart, padding: EdgeInsets.zero, child: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 14 : AppSpacing.page, - mobile ? 17 : AppSpacing.desktopPageTop, - mobile ? 14 : AppSpacing.page, - mobile ? 26 : AppSpacing.desktopPageBottom + 26, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + padding: settingsContentPadding(context), + child: SettingSection( + title: l10n.settingsAppLanguage, + padding: EdgeInsets.zero, children: [ - ShadRadioGroup( - axis: Axis.horizontal, + Text(l10n.prototypeLanguage, style: AppTypography.rowValue), + const SizedBox(height: 12), + ShadSelect( + key: ValueKey(selected), initialValue: selected, - onChanged: onSelected, - items: [ - SettingSection( - title: "", - padding: EdgeInsets.zero, - dividerIndent: 0, - children: - const [ - LanguageCode.system, - LanguageCode.zh, - LanguageCode.zhHant, - LanguageCode.en, - LanguageCode.ru, - LanguageCode.fa, - ].map((language) { - return ColoredBox( - color: selected == language - ? ColorManager.palette(context).accent - : Colors.transparent, - child: SettingRow( - title: languageNativeLabel(l10n, language), - titleTextDirection: language == LanguageCode.system - ? null - : language == LanguageCode.fa - ? TextDirection.rtl - : TextDirection.ltr, - minHeight: 78, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 13, - ), - titleStyle: AppTypography.settingsChoiceTitle, - subtitleWidget: language == LanguageCode.system - ? null - : Padding( - padding: const EdgeInsets.only(top: 1), - child: Text( - _title(l10n, language), - style: AppTypography.settingsChoiceDetail - .copyWith( - color: ColorManager.secondaryText( - context, - ), - ), - ), - ), - leading: SizedBox( - width: 23, - child: Icon( - LucideIcons.languages, - size: 21, - color: ColorManager.palette(context).primary, - ), - ), - decorateLeading: false, - trailing: ShadRadio( - value: language, - decoration: selected == language - ? ShadDecoration( - border: ShadBorder.all( - color: ColorManager.palette(context) - .primary, - width: 1, - ), - ) - : null, - radioPadding: EdgeInsets.zero, - ), - onTap: () => onSelected(language), - ), - ); - }).toList(), - ), + selectedOptionBuilder: (_, language) => Text( + languageNativeLabel(l10n, language), + textDirection: language == LanguageCode.fa + ? TextDirection.rtl + : language == LanguageCode.system + ? null + : TextDirection.ltr, + ), + options: [ + for (final language in LanguageCode.values) + ShadOption( + value: language, + child: Text( + languageNativeLabel(l10n, language), + textDirection: language == LanguageCode.fa + ? TextDirection.rtl + : language == LanguageCode.system + ? null + : TextDirection.ltr, + ), + ), ], + onChanged: onSelected, ), + const SizedBox(height: 16), ], ), ), ); } - - String _title(AppLocalizations l10n, LanguageCode language) { - return switch (language) { - LanguageCode.system => l10n.prototypeFollowSystem, - LanguageCode.en => l10n.prototypeEnglish, - LanguageCode.ru => l10n.prototypeRussian, - LanguageCode.fa => l10n.prototypePersian, - LanguageCode.zh => l10n.prototypeSimplifiedChinese, - LanguageCode.zhHant => l10n.prototypeTraditionalChinese, - }; - } } String languageNativeLabel(AppLocalizations l10n, LanguageCode language) => diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index 4c7ecfd95..39ad1f9de 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -1,5 +1,5 @@ -import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -7,7 +7,6 @@ import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/settings/controller.dart'; import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/settings/app_icon/page.dart'; -import 'package:onexray/pages/settings/desktop/controller.dart'; import 'package:onexray/pages/settings/language/page.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/color.dart'; @@ -21,7 +20,6 @@ import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/service/shared/event_bus/state.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/service/settings/backup/service.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -34,7 +32,11 @@ class SettingsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final controller = context.read(); return Scaffold( - appBar: PageAppBar(title: Text(l10n.prototypeSettings)), + appBar: PageAppBar.forPage( + context, + title: Text(l10n.prototypeSettings), + description: l10n.settingsOverviewDescription, + ), body: SafeArea( child: BlocBuilder( builder: (context, preferences) { @@ -42,15 +44,12 @@ class SettingsPage extends StatelessWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); - final rowHeight = mobile ? 43.0 : 56.0; - final sectionGap = mobile ? 25.0 : 28.0; - final rowPadding = EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 10, - ); + final rowHeight = mobile ? 56.0 : 48.0; + const sectionGap = 20.0; + const rowPadding = EdgeInsets.all(12); final appearance = SettingSection( - title: l10n.prototypeAppearance, - icon: LucideIcons.palette, + title: l10n.settingsAppearanceAndLanguage, + icon: LucideIcons.sun, padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -59,128 +58,68 @@ class SettingsPage extends StatelessWidget { onSelected: (theme) => controller.setTheme(context, theme), ), - if (AppPlatform.isAndroid) - SettingRow( - title: l10n.trafficWidgetAdd, - minHeight: rowHeight, - contentPadding: rowPadding, - titleStyle: AppTypography.settingsRow, - trailing: state.addingWidget - ? const ButtonProgressIndicator(size: 20) - : null, - showChevron: true, - onTap: state.addingWidget - ? null - : () => controller.addTrafficWidget(context), - ), - if (controller.showAppIcon) - LayoutBuilder( - builder: (context, constraints) { - final label = appIconLabel(l10n, state.appIcon); - final valueStyle = - (mobile - ? AppTypography.settingsVersion - : AppTypography.desktopSettingsRowValue) - .copyWith(color: palette.mutedStrong); - final available = - constraints.maxWidth - - rowPadding.horizontal - - (26 + 11) - - (20 + 7) - - 11; - final stacked = - _settingsTextWidth( - context, - l10n.prototypeAppIcon, - AppTypography.settingsRow, - ) > - available * 5 / 9 || - _settingsTextWidth( - context, - label, - AppTypography.code.merge(valueStyle), - ) > - available * 4 / 9; - return SettingRow( - title: l10n.prototypeAppIcon, - titleMaxLines: stacked ? 4 : 2, - value: stacked ? null : label, - subtitleWidget: stacked - ? Text( - label, - style: AppTypography.code.merge(valueStyle), - ) - : null, - minHeight: mobile ? 52 : 56, - titleStyle: AppTypography.settingsRow, - valueStyle: valueStyle, - contentPadding: rowPadding, - decorateLeading: false, - showChevron: true, - leading: ClipRRect( - borderRadius: BorderRadius.circular( - AppRadii.compact, - ), - child: - (AppPlatform.isMacOS - ? state.appIcon.dockAssetImage - : state.appIcon.assetImage) - .image(width: 26, height: 26), - ), - onTap: () => controller.openSetting( - context, - AppPageDestination.appIcon, - ), - ); - }, - ), - ], - ); - final language = SettingSection( - title: l10n.prototypeLanguage, - icon: LucideIcons.languages, - padding: EdgeInsets.zero, - children: [ + const SizedBox(height: 12), SettingRow( - title: languageNativeLabel( + title: l10n.prototypeLanguage, + value: languageNativeLabel( l10n, preferences.languageCode, ), minHeight: rowHeight, contentPadding: rowPadding, - titleStyle: AppTypography.settingsRow, showChevron: true, onTap: () => controller.openSetting( context, AppPageDestination.language, ), ), + if (controller.showAppIcon) + SettingRow( + title: l10n.prototypeAppIcon, + subtitle: appIconLabel(l10n, state.appIcon), + minHeight: rowHeight, + contentPadding: rowPadding, + showChevron: true, + onTap: () => controller.openSetting( + context, + AppPageDestination.appIcon, + ), + ), ], ); final startup = SettingSection( - title: l10n.prototypeStartup, + title: l10n.settingsStartupAndSystem, icon: LucideIcons.power, padding: EdgeInsets.zero, dividerIndent: 0, children: [ - _StartupSettingRow( - title: l10n.prototypeConnectAfterAppLaunch, - value: state.connectOnLaunch, - onChanged: state.loading || state.saving - ? null - : (value) => - controller.setConnectOnLaunch(context, value), + SettingRow( + title: l10n.prototypeStartup, + value: state.connectOnLaunch + ? l10n.prototypeConnectAfterAppLaunch + : l10n.settingsManualConnection, + showChevron: true, + onTap: () => controller.openSetting( + context, + AppPageDestination.desktopSettings, + ), ), - if (AppPlatform.isDesktop) - BlocProvider( - create: (_) => DesktopSettingsController(), - child: const _DesktopStartupRows(), + if (AppPlatform.isAndroid) + SettingRow( + title: l10n.trafficWidgetAdd, + trailing: state.addingWidget + ? const ButtonProgressIndicator(size: 20) + : null, + showChevron: true, + onTap: state.addingWidget + ? null + : () => controller.addTrafficWidget(context), ), ], ); final data = SettingSection( title: l10n.prototypeData, - icon: LucideIcons.hardDrive, + icon: LucideIcons.database, padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -189,7 +128,7 @@ class SettingsPage extends StatelessWidget { title: l10n.backupTitle, minHeight: rowHeight, contentPadding: rowPadding, - titleStyle: AppTypography.settingsRow, + titleStyle: AppTypography.rowTitle, showChevron: true, onTap: () => controller.openSetting( context, @@ -206,9 +145,7 @@ class SettingsPage extends StatelessWidget { : null, minHeight: rowHeight, contentPadding: rowPadding, - titleStyle: AppTypography.settingsDanger.copyWith( - color: palette.destructive, - ), + titleStyle: AppTypography.rowTitle, onTap: state.clearingData ? null : () => controller.clearData( @@ -221,11 +158,7 @@ class SettingsPage extends StatelessWidget { enabled: !state.clearingData, minHeight: rowHeight, contentPadding: rowPadding, - titleStyle: - (mobile - ? AppTypography.settingsDanger - : AppTypography.desktopSettingsDanger) - .copyWith(color: palette.destructive), + titleStyle: AppTypography.rowTitle, trailing: state.clearingData && state.cleanupScope != 'resources' @@ -239,7 +172,6 @@ class SettingsPage extends StatelessWidget { ); final about = SettingSection( title: l10n.prototypeAbout, - icon: LucideIcons.info, padding: EdgeInsets.zero, dividerIndent: 0, children: [ @@ -251,7 +183,7 @@ class SettingsPage extends StatelessWidget { minHeight: rowHeight, contentPadding: rowPadding, title: l10n.prototypeAboutOneXray, - titleStyle: AppTypography.settingsRow, + titleStyle: AppTypography.rowTitle, titleTrailing: preferences.appUpdateInfo == null ? null : const SettingsUpdateDot(), @@ -262,26 +194,6 @@ class SettingsPage extends StatelessWidget { ), ), ), - Column( - children: [ - SettingsVersionRow( - label: l10n.prototypeAppVersion, - value: state.appVersion, - compact: true, - style: mobile - ? null - : AppTypography.desktopSettingsVersion, - ), - SettingsVersionRow( - label: 'Xray-core', - value: state.xrayVersion, - compact: true, - style: mobile - ? null - : AppTypography.desktopSettingsVersion, - ), - ], - ), ], ); return SettingsPageScroll( @@ -289,10 +201,10 @@ class SettingsPage extends StatelessWidget { padding: EdgeInsets.zero, child: Padding( padding: EdgeInsetsDirectional.fromSTEB( - mobile ? 12 : AppSpacing.page, - mobile ? 16 : 24, - mobile ? 12 : AppSpacing.page, - mobile ? 20 : 42, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 20, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 36, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -315,8 +227,6 @@ class SettingsPage extends StatelessWidget { children: [ appearance, SizedBox(height: sectionGap), - language, - SizedBox(height: sectionGap), startup, SizedBox(height: sectionGap), data, @@ -337,270 +247,13 @@ class SettingsPage extends StatelessWidget { ); } -double _settingsTextWidth(BuildContext context, String label, TextStyle style) { - final painter = TextPainter( - text: TextSpan( - text: label, - style: DefaultTextStyle.of(context).style.merge(style), - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.localeOf(context), - maxLines: 1, - )..layout(); - final width = painter.width; - painter.dispose(); - return width; -} - class _ThemeOptions extends StatelessWidget { final ThemeCode selected; final ValueChanged onSelected; const _ThemeOptions({required this.selected, required this.onSelected}); - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final style = mobile - ? AppTypography.settingsThemeOptionMobile - : AppTypography.settingsThemeOption; - final iconSize = mobile ? 16.0 : 18.0; - final gap = mobile ? 6.0 : 8.0; - final horizontalPadding = mobile ? 5.0 : 8.0; - final labels = { - ThemeCode.system: l10n.prototypeSystem, - ThemeCode.light: l10n.prototypeLight, - ThemeCode.dark: l10n.prototypeDark, - }; - var widestLabel = 0.0; - for (final label in labels.values) { - final width = _settingsTextWidth(context, label, style); - if (width > widestLabel) widestLabel = width; - } - final horizontalWidth = - (widestLabel + iconSize + gap + horizontalPadding * 2) * labels.length; - return Padding( - padding: const EdgeInsets.all(4), - child: LayoutBuilder( - builder: (context, constraints) { - final vertical = horizontalWidth > constraints.maxWidth; - Widget option(ThemeCode theme) => Semantics( - button: true, - selected: selected == theme, - child: Material( - color: selected == theme - ? palette.selectedSurface - : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.compact), - side: BorderSide( - color: selected == theme - ? palette.primary - : Colors.transparent, - ), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => onSelected(theme), - child: Container( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent( - mobile ? 45 : 47, - platform: Theme.of(context).platform, - ), - ), - padding: EdgeInsets.symmetric( - horizontal: horizontalPadding, - vertical: 6, - ), - decoration: BoxDecoration( - border: selected == theme || theme == ThemeCode.dark - ? null - : vertical - ? Border(bottom: BorderSide(color: palette.border)) - : BorderDirectional( - end: BorderSide(color: palette.border), - ), - ), - child: Row( - mainAxisAlignment: vertical - ? MainAxisAlignment.start - : MainAxisAlignment.center, - children: [ - Icon( - switch (theme) { - ThemeCode.system => LucideIcons.monitor, - ThemeCode.light => LucideIcons.sun, - ThemeCode.dark => LucideIcons.moon, - }, - size: iconSize, - color: selected == theme - ? palette.primary - : palette.foreground, - ), - SizedBox(width: gap), - Flexible( - child: Text( - labels[theme]!, - textAlign: vertical - ? TextAlign.start - : TextAlign.center, - style: style.copyWith( - color: selected == theme - ? palette.primary - : palette.foreground, - ), - ), - ), - ], - ), - ), - ), - ), - ); - if (vertical) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [for (final theme in ThemeCode.values) option(theme)], - ); - } - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final theme in ThemeCode.values) - Expanded(child: option(theme)), - ], - ), - ); - }, - ), - ); - } -} - -class _StartupSettingRow extends StatelessWidget { - final String title; - final bool value; - final ValueChanged? onChanged; - - const _StartupSettingRow({ - required this.title, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - return SettingRow( - title: title, - minHeight: mobile ? 48 : 56, - contentPadding: EdgeInsetsDirectional.symmetric( - horizontal: mobile ? 13 : 14, - vertical: mobile ? 7 : 9, - ), - titleStyle: mobile - ? AppTypography.settingsFieldTitle - : AppTypography.settingsRow, - enabled: onChanged != null, - onTap: onChanged == null ? null : () => onChanged!(!value), - trailing: AppSwitch( - value: value, - width: mobile ? 40 : 46, - height: mobile ? 23 : 27, - enabled: onChanged != null, - onChanged: onChanged, - ), - ); - } -} - -class _DesktopStartupRows extends StatelessWidget { - const _DesktopStartupRows(); @override Widget build(BuildContext context) => - BlocBuilder( - builder: (context, state) { - final controller = context.read(); - final l10n = AppLocalizations.of(context)!; - return Column( - children: [ - _StartupSettingRow( - title: l10n.prototypeLaunchAtLogin, - value: state.launchAtLogin.enabled, - onChanged: state.launchToggleEnabled && !state.requiresApproval - ? (value) => controller.updateLaunchAtLogin(context, value) - : null, - ), - const Divider(height: 1), - _StartupSettingRow( - title: l10n.prototypeStartHidden, - value: state.startHidden, - onChanged: state.behaviorSettingsEnabled - ? (value) => - controller.updateStartHidden(value, context: context) - : null, - ), - if (AppPlatform.isMacOS) ...[ - const Divider(height: 1), - _StartupSettingRow( - title: l10n.prototypeHideDockIcon, - value: state.hideDockIcon, - onChanged: state.behaviorSettingsEnabled - ? (value) => controller.updateHideDockIcon( - value, - context: context, - ) - : null, - ), - ], - if (state.requiresApproval) ...[ - const Divider(height: 1), - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.prototypeSystemApprovalRequired), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - TextButton( - onPressed: state.changingLaunchAtLogin - ? null - : () => controller.updateLaunchAtLogin( - context, - false, - ), - child: ButtonProgress( - busy: state.changingLaunchAtLogin, - child: Text(l10n.prototypeCancelRequest), - ), - ), - TextButton( - onPressed: state.openingSystemSettings - ? null - : () => controller.openSystemSettings(context), - child: ButtonProgress( - busy: state.openingSystemSettings, - child: Text(l10n.prototypeOpenSystemSettings), - ), - ), - ], - ), - ], - ), - ), - ], - ], - ); - }, - ); + SettingsThemeOptions(selected: selected, onSelected: onSelected); } diff --git a/lib/pages/settings/theme/page.dart b/lib/pages/settings/theme/page.dart index 69335d19e..d2300d0d3 100644 --- a/lib/pages/settings/theme/page.dart +++ b/lib/pages/settings/theme/page.dart @@ -5,6 +5,9 @@ import 'package:onexray/pages/settings/theme/controller.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/service/shared/event_bus/enum.dart'; +import 'package:onexray/pages/settings/widgets.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class ThemePage extends StatelessWidget { @@ -18,13 +21,29 @@ class ThemePage extends StatelessWidget { builder: (context, state) { final controller = context.read(); final l10n = AppLocalizations.of(context)!; - return SettingsPageScaffold( - title: l10n.themePageTitle, - onSave: () => controller.save(context), - saveLabel: l10n.buttonSave, - body: ThemeChoiceView( - selected: state.themeCode, - onSelected: controller.updateThemeCode, + return Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(l10n.themePageTitle), + description: l10n.settingsDevicePreferenceDescription, + ), + body: SafeArea( + child: ThemeChoiceView( + selected: state.themeCode, + onSelected: controller.updateThemeCode, + ), + ), + bottomNavigationBar: PageActionBar( + children: [ + ShadButton.outline( + onPressed: () => Navigator.maybePop(context), + child: Text(l10n.prototypeCancel), + ), + ShadButton( + onPressed: () => controller.save(context), + child: Text(l10n.buttonSave), + ), + ], ), ); }, @@ -47,22 +66,16 @@ class ThemeChoiceView extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return SettingsPageScroll( - desktopMaxWidth: 720, - child: SettingSection( - title: "", - children: ThemeCode.values.map((theme) { - final (title, icon) = switch (theme) { - ThemeCode.system => (l10n.themePageSystem, LucideIcons.monitorCog), - ThemeCode.light => (l10n.themePageLight, LucideIcons.sun), - ThemeCode.dark => (l10n.themePageDark, LucideIcons.moon), - }; - return SettingsChoiceRow( - title: title, - leading: Icon(icon), - selected: selected == theme, - onTap: () => onSelected(theme), - ); - }).toList(), + padding: EdgeInsets.zero, + child: Padding( + padding: settingsContentPadding(context), + child: SettingSection( + title: l10n.themePageTitle, + padding: EdgeInsets.zero, + children: [ + SettingsThemeOptions(selected: selected, onSelected: onSelected), + ], + ), ), ); } diff --git a/lib/pages/settings/widgets.dart b/lib/pages/settings/widgets.dart index ec62e3e74..50c8ea7dd 100644 --- a/lib/pages/settings/widgets.dart +++ b/lib/pages/settings/widgets.dart @@ -1,8 +1,13 @@ +import 'dart:math' as math; + import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/service/shared/event_bus/enum.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:onexray/pages/shared/widgets/button_progress.dart'; class SettingsUpdateDot extends StatelessWidget { const SettingsUpdateDot({super.key}); @@ -41,27 +46,146 @@ class SettingsVersionRow extends StatelessWidget { ), ); } - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final color = ColorManager.palette(context).mutedStrong; return SettingRow( title: label, - minHeight: mobile ? 42 : 53, - contentPadding: EdgeInsetsDirectional.symmetric( - horizontal: mobile ? 13 : 14, - vertical: 8, - ), - titleStyle: (style ?? AppTypography.settingsVersion).copyWith( - color: color, - ), + titleStyle: style ?? AppTypography.rowTitle, trailing: Text( value, textDirection: TextDirection.ltr, - style: (style ?? AppTypography.settingsVersion).copyWith( - color: color, - fontWeight: FontWeight.w600, - ), + style: AppTypography.rowValue, ), ); } } + +class SettingsThemeOptions extends StatelessWidget { + const SettingsThemeOptions({ + super.key, + required this.selected, + required this.onSelected, + }); + + final ThemeCode selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final labels = { + ThemeCode.system: l.prototypeSystem, + ThemeCode.light: l.prototypeLight, + ThemeCode.dark: l.prototypeDark, + }; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l.themePageTitle, style: AppTypography.rowValue), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final theme in ThemeCode.values) + Semantics( + selected: selected == theme, + child: selected == theme + ? ShadButton( + height: 48, + onPressed: () => onSelected(theme), + child: Text(labels[theme]!), + ) + : ShadButton.outline( + height: 48, + onPressed: () => onSelected(theme), + child: Text(labels[theme]!), + ), + ), + ], + ), + ], + ); + } +} + +EdgeInsets settingsContentPadding(BuildContext context) { + final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + return EdgeInsets.fromLTRB( + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 20, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 36, + ); +} + +/// Keeps short actions compact while letting localized labels wrap in tight rows. +class SettingsActionButton extends StatelessWidget { + const SettingsActionButton({ + super.key, + required this.label, + required this.onPressed, + this.variant = ShadButtonVariant.primary, + this.enabled = true, + this.busy = false, + }); + + final String label; + final VoidCallback? onPressed; + final ShadButtonVariant variant; + final bool enabled; + final bool busy; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final painter = TextPainter( + text: TextSpan( + text: label, + style: AppTypography.control.copyWith(fontFamily: AppFontFamily.sans), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + locale: Localizations.localeOf(context), + )..layout(); + final progressWidth = busy ? 24.0 : 0.0; + // Shad's outline border sits outside the button's content constraints. + final borderExtent = variant == ShadButtonVariant.outline ? 2.0 : 0.0; + final available = constraints.hasBoundedWidth + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + final outerWidth = math.min( + available, + painter.width.ceilToDouble() + 32 + progressWidth + borderExtent, + ); + final width = math.max(0.0, outerWidth - borderExtent); + painter.layout(maxWidth: math.max(1.0, width - 32 - progressWidth)); + final height = math.max( + AppLayout.touchExtent( + mobile ? 48 : 42, + platform: Theme.of(context).platform, + ) - + borderExtent, + painter.height.ceilToDouble() + 16, + ); + painter.dispose(); + return ShadButton.raw( + variant: variant, + enabled: enabled && onPressed != null, + onPressed: onPressed, + width: width, + height: height, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + expands: true, + child: ButtonProgress( + busy: busy, + child: Text( + label, + textAlign: TextAlign.center, + style: AppTypography.control, + ), + ), + ); + }, + ); +} diff --git a/lib/pages/shared/alert.dart b/lib/pages/shared/alert.dart index 0e05c07bb..503b31be2 100644 --- a/lib/pages/shared/alert.dart +++ b/lib/pages/shared/alert.dart @@ -4,7 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -32,22 +33,27 @@ class ContextAlert { backgroundColor: _dialogBackground(ctx), closeIcon: const SizedBox.shrink(), constraints: _dialogConstraints(ctx), - padding: const EdgeInsets.all(20), - gap: 20, - removeBorderRadiusWhenTiny: false, + padding: const EdgeInsets.all(24), + gap: 8, + removeBorderRadiusWhenTiny: true, titleTextAlign: TextAlign.start, descriptionTextAlign: TextAlign.start, scrollable: false, useSafeArea: false, actionsAxis: Axis.vertical, actions: [ - ShadButton.outline( - child: Text(AppLocalizations.of(ctx)!.buttonCancel), - onPressed: () => Navigator.of(ctx).pop(false), + _dialogAction( + ctx, + AppLocalizations.of(ctx)!.buttonCancel, + () => Navigator.of(ctx).pop(false), + outline: true, + expanded: true, ), - ShadButton( - child: Text(AppLocalizations.of(ctx)!.buttonOpenSettings), - onPressed: () => Navigator.of(ctx).pop(true), + _dialogAction( + ctx, + AppLocalizations.of(ctx)!.buttonOpenSettings, + () => Navigator.of(ctx).pop(true), + expanded: true, ), ], ), @@ -74,33 +80,100 @@ class ContextAlert { String? content, String? confirmLabel, }) async { + final l = AppLocalizations.of(context)!; + final confirmText = confirmLabel ?? l.buttonOK; final confirmed = await _showDialog( context: context, - variant: ShadDialogVariant.alert, - builder: (ctx) => ShadDialog.alert( - title: _dialogTitle(ctx, icon: LucideIcons.triangleAlert, title: title), - description: content == null ? null : _dialogDescription(ctx, content), - backgroundColor: _dialogBackground(ctx), - closeIcon: const SizedBox.shrink(), - constraints: _dialogConstraints(ctx), - padding: const EdgeInsets.all(20), - gap: 20, - removeBorderRadiusWhenTiny: false, - titleTextAlign: TextAlign.start, - descriptionTextAlign: TextAlign.start, - scrollable: false, - useSafeArea: false, - actionsAxis: Axis.vertical, - actions: [ - ShadButton.outline( - child: Text(AppLocalizations.of(ctx)!.buttonCancel), - onPressed: () => Navigator.of(ctx).pop(false), - ), - ShadButton( - child: Text(confirmLabel ?? AppLocalizations.of(ctx)!.buttonOK), - onPressed: () => Navigator.of(ctx).pop(true), - ), - ], + barrierColor: Colors.black.withValues(alpha: .45), + builder: (ctx) => LayoutBuilder( + builder: (ctx, viewport) { + final viewportWidth = math.min( + MediaQuery.sizeOf(ctx).width, + viewport.maxWidth, + ); + final width = math.min(520.0, math.max(0.0, viewportWidth - 32)); + final vertical = + viewportWidth < 560 || + _actionWidth(ctx, l.buttonCancel, outline: true) + + _actionWidth(ctx, confirmText) + + 10 > + width - 48; + return ShadDialog( + title: Semantics( + header: true, + child: Padding( + padding: EdgeInsetsDirectional.only( + end: + AppLayout.touchExtent( + 40, + platform: Theme.of(ctx).platform, + ) - + 16, + ), + child: Text(title, style: AppTypography.confirmationTitle), + ), + ), + description: content == null + ? null + : SelectionArea( + child: Text( + content, + style: AppTypography.confirmationBody.copyWith( + color: ShadTheme.of(ctx).colorScheme.mutedForeground, + ), + ), + ), + backgroundColor: ShadTheme.of(ctx).colorScheme.card, + radius: BorderRadius.circular( + MediaQuery.sizeOf(ctx).width < 640 ? 0 : AppRadii.card, + ), + border: Border.all(color: ShadTheme.of(ctx).colorScheme.border), + constraints: BoxConstraints( + maxWidth: width, + maxHeight: MediaQuery.sizeOf(ctx).height * .9, + ), + padding: const EdgeInsets.all(24), + gap: 8, + actionsGap: 10, + removeBorderRadiusWhenTiny: true, + expandActionsWhenTiny: false, + titleTextAlign: TextAlign.start, + descriptionTextAlign: TextAlign.start, + scrollable: true, + actionsAxis: vertical ? Axis.vertical : Axis.horizontal, + closeIcon: IconButton( + autofocus: true, + tooltip: l.prototypeCloseDialog, + onPressed: () => Navigator.of(ctx).pop(false), + style: IconButton.styleFrom( + minimumSize: Size.square( + AppLayout.touchExtent(40, platform: Theme.of(ctx).platform), + ), + maximumSize: Size.square( + AppLayout.touchExtent(40, platform: Theme.of(ctx).platform), + ), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.x, size: 20), + ), + actions: [ + _dialogAction( + ctx, + l.buttonCancel, + () => Navigator.of(ctx).pop(false), + outline: true, + expanded: vertical, + ), + _dialogAction( + ctx, + confirmText, + () => Navigator.of(ctx).pop(true), + expanded: vertical, + ), + ], + ); + }, ), ); return confirmed ?? false; @@ -143,12 +216,13 @@ class ContextAlert { required BuildContext context, required WidgetBuilder builder, ShadDialogVariant variant = ShadDialogVariant.primary, + Color? barrierColor, }) { return showShadDialog( context: context, builder: builder, variant: variant, - barrierColor: ColorManager.palette(context).overlay, + barrierColor: barrierColor ?? Colors.black.withValues(alpha: .8), barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, opaque: false, ); @@ -173,7 +247,15 @@ class ContextAlert { maxHeight: MediaQuery.sizeOf(context).height * 0.48, ), child: SingleChildScrollView( - child: SelectionArea(child: Text(content, textAlign: TextAlign.start)), + child: SelectionArea( + child: Text( + content, + textAlign: TextAlign.start, + style: AppTypography.dialogSubtitle.copyWith( + color: ShadTheme.of(context).colorScheme.mutedForeground, + ), + ), + ), ), ); } @@ -187,19 +269,69 @@ class ContextAlert { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: colors.accent, - borderRadius: BorderRadius.circular(8), + Icon(icon, size: 20, color: colors.primary), + const SizedBox(width: 12), + Expanded( + child: Semantics( + header: true, + child: Text( + title, + textAlign: TextAlign.start, + style: AppTypography.dialogTitle, + ), ), - child: Icon(icon, size: 20, color: colors.primary), ), - const SizedBox(width: 12), - Expanded(child: Text(title, textAlign: TextAlign.start)), ], ); } + + static double _actionWidth( + BuildContext context, + String label, { + bool outline = false, + }) { + final painter = TextPainter( + text: TextSpan( + text: label, + style: AppTypography.control.copyWith(fontFamily: AppFontFamily.sans), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final width = painter.width.ceilToDouble() + 32 + (outline ? 2 : 0); + painter.dispose(); + return width; + } + + static Widget _dialogAction( + BuildContext context, + String label, + VoidCallback onPressed, { + bool outline = false, + bool expanded = false, + }) => ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 48 + : 42, + platform: Theme.of(context).platform, + ), + ), + child: ShadButton.raw( + variant: outline ? ShadButtonVariant.outline : ShadButtonVariant.primary, + width: expanded + ? double.infinity + : _actionWidth(context, label, outline: outline) - (outline ? 2 : 0), + height: 0, + expands: true, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + onPressed: onPressed, + child: Text( + label, + textAlign: TextAlign.center, + style: AppTypography.control, + ), + ), + ); } diff --git a/lib/pages/shared/share/page.dart b/lib/pages/shared/share/page.dart index ceaa8a174..892ac0c3b 100644 --- a/lib/pages/shared/share/page.dart +++ b/lib/pages/shared/share/page.dart @@ -81,25 +81,29 @@ class SharePage extends StatelessWidget { ); } final border = OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadii.card), + borderRadius: BorderRadius.circular(AppRadii.control), borderSide: BorderSide(color: palette.border), ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 0), + child: Text(l.shareContentTitle, style: AppTypography.panelTitle), + ), Semantics( label: l.prototypeLinkFormat, child: Container( - margin: const EdgeInsets.fromLTRB(22, 16, 22, 0), + margin: const EdgeInsets.fromLTRB(18, 16, 18, 0), decoration: BoxDecoration( color: palette.card, border: Border.all(color: palette.border), borderRadius: BorderRadius.circular(AppRadii.control), ), clipBehavior: Clip.antiAlias, - child: SizedBox( - height: 41, + child: IntrinsicHeight( child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final format in ShareLinkFormat.values) if (state.supportsOriginal || @@ -118,7 +122,7 @@ class SharePage extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + padding: const EdgeInsets.fromLTRB(18, 16, 18, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -126,38 +130,34 @@ class SharePage extends StatelessWidget { params.type == ShareType.subscription ? l.prototypeSubscriptionLink : l.prototypeServerShareLink, - style: AppTypography.subscriptionField.copyWith( - color: palette.mutedStrong, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, ), ), const SizedBox(height: 8), - SizedBox( - height: 92, - child: TextFormField( - key: ValueKey(state.format), - initialValue: state.selectedLink, - readOnly: true, - textDirection: TextDirection.ltr, - textAlignVertical: TextAlignVertical.top, - expands: true, - minLines: null, - maxLines: null, - style: AppTypography.shareLink.copyWith( - color: palette.foreground, + TextFormField( + key: ValueKey(state.format), + initialValue: state.selectedLink, + readOnly: true, + textDirection: TextDirection.ltr, + textAlignVertical: TextAlignVertical.top, + minLines: 3, + maxLines: 6, + style: AppTypography.shareLink.copyWith( + color: palette.foreground, + ), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: palette.muted, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 11, ), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: palette.muted, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 11, - ), - border: border, - enabledBorder: border, - focusedBorder: border.copyWith( - borderSide: BorderSide(color: palette.primary), - ), + border: border, + enabledBorder: border, + focusedBorder: border.copyWith( + borderSide: BorderSide(color: palette.primary), ), ), ), @@ -176,7 +176,7 @@ class SharePage extends StatelessWidget { if (params.type == ShareType.subscription && state.format == ShareLinkFormat.onexray) Padding( - padding: const EdgeInsets.fromLTRB(22, 10, 22, 0), + padding: const EdgeInsets.fromLTRB(18, 10, 18, 0), child: Text( l.prototypeSubscriptionShareAgeHint, style: AppTypography.shareHint.copyWith( @@ -185,7 +185,7 @@ class SharePage extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.fromLTRB(22, 16, 22, 0), + padding: const EdgeInsets.fromLTRB(18, 16, 18, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -196,18 +196,31 @@ class SharePage extends StatelessWidget { onTap: state.selectedLink.isEmpty ? null : controller.toggleQr, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (state.qrExpanded && - state.qrCode == null && - state.qrError.isEmpty) - const ButtonProgressIndicator(size: 18) - else - const Icon(LucideIcons.qrCode, size: 18), - const SizedBox(width: 8), - Text(l.sharePageShowQRCode, style: AppTypography.shareQr), - ], + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 40, + platform: Theme.of(context).platform, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.qrExpanded && + state.qrCode == null && + state.qrError.isEmpty) + const ButtonProgressIndicator(size: 18) + else + const Icon(LucideIcons.qrCode, size: 18), + const SizedBox(width: 8), + Flexible( + child: Text( + l.sharePageShowQRCode, + style: AppTypography.shareQr, + ), + ), + ], + ), ), ), ), @@ -280,7 +293,13 @@ class SharePage extends StatelessWidget { onTap: onPressed, child: Container( alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 13), + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 40, + platform: Theme.of(context).platform, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10), decoration: BoxDecoration( color: selected ? palette.selectedSurface : palette.card, border: selected diff --git a/lib/pages/shared/widgets/adaptive_dialog.dart b/lib/pages/shared/widgets/adaptive_dialog.dart index 92ebb9d52..852588299 100644 --- a/lib/pages/shared/widgets/adaptive_dialog.dart +++ b/lib/pages/shared/widgets/adaptive_dialog.dart @@ -1,5 +1,4 @@ import 'dart:math' as math; -import 'dart:ui' as ui; import 'package:material_ui/material_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -7,6 +6,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme; /// A modal selection surface, separate from full-page child navigation. Future showChoiceDialog(BuildContext context, WidgetBuilder builder) { @@ -50,7 +50,7 @@ Future showAppDialog( // The previous wizard step still owns its barrier while its card is offstage. barrierColor: ModalRoute.of(context) is PopupRoute ? Colors.transparent - : ColorManager.palette(context).overlay, + : Colors.black.withValues(alpha: .8), pageBuilder: (context, animation, secondaryAnimation) => AppDialogFrame(desktopMaxWidth: desktopMaxWidth, child: builder(context)), ); @@ -81,26 +81,13 @@ class AppDialogFrame extends StatelessWidget { offstage: covered, child: Stack( children: [ - Positioned.fill( - child: IgnorePointer( - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: AppLayout.dialogBlur, - sigmaY: AppLayout.dialogBlur, - ), - child: const SizedBox.expand(), - ), - ), - ), SafeArea( - top: !mobile, - bottom: !mobile, child: Padding( padding: mobile ? EdgeInsets.only(bottom: keyboard) : EdgeInsets.fromLTRB(20, 20, 20, 20 + keyboard), child: Align( - alignment: mobile ? Alignment.bottomCenter : Alignment.center, + alignment: Alignment.center, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: mobile ? size.width : desktopMaxWidth, @@ -150,171 +137,208 @@ class AppDialog extends StatelessWidget { @override Widget build(BuildContext context) { final palette = ColorManager.palette(context); + final narrow = MediaQuery.sizeOf(context).width < 640; final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final radius = mobile - ? const BorderRadius.vertical( - top: Radius.circular(AppRadii.mobileDialog), - ) - : const BorderRadius.all(Radius.circular(AppRadii.dialog)); + final closeSize = AppLayout.touchExtent( + 40, + platform: Theme.of(context).platform, + ); + final textAlign = narrow ? TextAlign.center : TextAlign.start; + final header = Padding( + padding: EdgeInsetsDirectional.only(end: closeSize - 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (onBack != null) ...[ + TextButton( + onPressed: onBack, + style: TextButton.styleFrom( + minimumSize: Size.square( + AppLayout.touchExtent( + 40, + platform: Theme.of(context).platform, + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: AppTypography.control, + ), + child: Text(AppLocalizations.of(context)!.prototypeBack), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + title, + textAlign: textAlign, + style: AppTypography.dialogTitle.copyWith( + color: palette.foreground, + ), + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle!, + textAlign: textAlign, + style: AppTypography.dialogSubtitle.copyWith( + color: palette.mutedForeground, + ), + ), + ], + ], + ), + ), + ], + ), + ); return Material( - color: palette.card, + color: palette.background, shape: RoundedRectangleBorder( - borderRadius: radius, + borderRadius: BorderRadius.circular(narrow ? 0 : 8), side: BorderSide(color: palette.border), ), clipBehavior: Clip.antiAlias, - // Extend the surface beneath system gestures; inset only its content. - child: SafeArea( - top: false, - left: false, - right: false, - bottom: mobile, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - child: SingleChildScrollView( + child: LayoutBuilder( + builder: (context, constraints) { + final pinActions = + mobile && + constraints.maxHeight >= 380 && + MediaQuery.textScalerOf(context).scale(14) <= 28; + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(24), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - constraints: BoxConstraints( - minHeight: mobile - ? AppLayout.dialogMobileHeaderMinHeight - : AppLayout.dialogHeaderMinHeight, - ), - padding: mobile - ? const EdgeInsets.fromLTRB(16, 17, 16, 14) - : const EdgeInsets.fromLTRB(21, 20, 21, 17), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: palette.border), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (onBack != null) ...[ - TextButton( - onPressed: onBack, - style: TextButton.styleFrom( - minimumSize: Size.square( - AppLayout.touchExtent( - 34, - platform: Theme.of(context).platform, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: AppTypography.dialogBack, - ), - child: Text( - AppLocalizations.of(context)!.prototypeBack, - ), - ), - const SizedBox(width: 12), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + header, + const SizedBox(height: 8), + body, + if (!pinActions && actions.isNotEmpty) ...[ + const SizedBox(height: 8), + _actions(context), + ], ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: mobile - ? AppTypography.dialogTitle - : AppTypography.confirmationDesktopTitle, - ), - if (subtitle != null) ...[ - const SizedBox(height: 6), - Text( - subtitle!, - style: AppTypography.dialogSubtitle - .copyWith( - color: palette.mutedForeground, - ), - ), - ], - ], - ), - ), - const SizedBox(width: 12), - IconButton( - autofocus: true, - tooltip: AppLocalizations.of(context)! - .prototypeCloseDialog, - onPressed: - onClose ?? () => Navigator.of(context).pop(), - style: IconButton.styleFrom( - foregroundColor: palette.mutedStrong, - minimumSize: Size.square( - AppLayout.touchExtent( - AppLayout.dialogCloseSize, - platform: Theme.of(context).platform, - ), - ), - maximumSize: Size.square( - AppLayout.touchExtent( - AppLayout.dialogCloseSize, - platform: Theme.of(context).platform, - ), - ), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: const Icon(LucideIcons.x, size: 20), - ), - ], + ), ), ), - body, - if (!mobile && actions.isNotEmpty) - _actions(context, mobile: false), + if (pinActions && actions.isNotEmpty) ...[ + const SizedBox(height: 8), + _actions(context), + ], ], ), ), - ), - if (mobile && actions.isNotEmpty) _actions(context, mobile: true), - ], - ), + PositionedDirectional( + top: 8, + end: 8, + child: IconButton( + autofocus: true, + tooltip: AppLocalizations.of(context)!.prototypeCloseDialog, + onPressed: onClose ?? () => Navigator.of(context).pop(), + style: IconButton.styleFrom( + foregroundColor: palette.mutedForeground, + minimumSize: Size.square(closeSize), + maximumSize: Size.square(closeSize), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.x, size: 20), + ), + ), + ], + ); + }, ), ); } - Widget _actions(BuildContext context, {required bool mobile}) { - final palette = ColorManager.palette(context); - return Container( - constraints: const BoxConstraints(minHeight: 70), - padding: EdgeInsets.symmetric( - horizontal: mobile ? 16 : 20, - vertical: mobile ? 12 : 14, - ), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: LayoutBuilder( - builder: (context, constraints) => Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (var index = 0; index < actions.length; index++) ...[ - if (index > 0) const SizedBox(width: AppSpacing.actionGap), - if (mobile && expandLastAction && index == actions.length - 1) - Expanded(child: actions[index]) - else if (mobile && expandLastAction) - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth * 0.62, - ), - child: IntrinsicWidth(child: actions[index]), - ) - else - Flexible(child: actions[index]), - ], - ], + Widget _action( + BuildContext context, + Widget action, { + bool intrinsic = false, + }) { + final minimum = AppLayout.touchExtent( + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint ? 48 : 42, + platform: Theme.of(context).platform, + ); + Widget result = ConstrainedBox( + constraints: BoxConstraints(minHeight: minimum), + child: action, + ); + final shad = ShadTheme.maybeOf(context); + if (shad != null) { + result = ShadTheme( + data: shad.copyWith( + primaryButtonTheme: shad.primaryButtonTheme.copyWith( + height: 0, + expands: true, + ), + outlineButtonTheme: shad.outlineButtonTheme.copyWith( + height: 0, + expands: true, + ), + secondaryButtonTheme: shad.secondaryButtonTheme.copyWith( + height: 0, + expands: true, + ), + destructiveButtonTheme: shad.destructiveButtonTheme.copyWith( + height: 0, + expands: true, + ), + ghostButtonTheme: shad.ghostButtonTheme.copyWith( + height: 0, + expands: true, + ), + linkButtonTheme: shad.linkButtonTheme.copyWith( + height: 0, + expands: true, + ), ), + child: result, + ); + } + return intrinsic ? IntrinsicWidth(child: result) : result; + } + + Widget _actions(BuildContext context) { + if (MediaQuery.sizeOf(context).width < 640) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0) const SizedBox(height: 8), + _action(context, actions[index]), + ], + ], + ); + } + return LayoutBuilder( + builder: (context, constraints) => Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + for (final action in actions) + ConstrainedBox( + constraints: BoxConstraints(maxWidth: constraints.maxWidth), + child: _action(context, action, intrinsic: true), + ), + ], ), ); } diff --git a/lib/pages/shared/widgets/configuration_transfer.dart b/lib/pages/shared/widgets/configuration_transfer.dart index e1da09317..31a61fc70 100644 --- a/lib/pages/shared/widgets/configuration_transfer.dart +++ b/lib/pages/shared/widgets/configuration_transfer.dart @@ -12,6 +12,7 @@ import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/shared/share/action.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:onexray/service/advanced/xray/geodata/model.dart'; @@ -301,7 +302,18 @@ class ConfigurationTransferTools extends StatelessWidget { OutlinedButtonTheme( data: OutlinedButtonThemeData( style: Theme.of(context).outlinedButtonTheme.style?.copyWith( - minimumSize: const WidgetStatePropertyAll(Size(0, 36)), + minimumSize: WidgetStatePropertyAll( + Size( + 0, + AppLayout.touchExtent( + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? 48 + : 42, + platform: Theme.of(context).platform, + ), + ), + ), padding: const WidgetStatePropertyAll( EdgeInsets.symmetric(horizontal: 10), ), @@ -311,8 +323,8 @@ class ConfigurationTransferTools extends StatelessWidget { ), ), child: Wrap( - spacing: 8, - runSpacing: 8, + spacing: 10, + runSpacing: 10, children: [ OutlinedButton.icon( onPressed: busy diff --git a/lib/pages/shared/widgets/dns_text_field.dart b/lib/pages/shared/widgets/dns_text_field.dart index b17aa4bcf..d4c2f09ef 100644 --- a/lib/pages/shared/widgets/dns_text_field.dart +++ b/lib/pages/shared/widgets/dns_text_field.dart @@ -1,5 +1,6 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/theme/color.dart'; /// Keeps editing resources local while the owning page Cubit owns the value. class DnsTextField extends StatefulWidget { @@ -46,25 +47,40 @@ class _DnsTextFieldState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8, children: [ - TextField( - controller: controller, - focusNode: widget.focusNode, - onChanged: widget.onChanged, - enabled: widget.enabled, - textDirection: TextDirection.ltr, - textAlign: TextAlign.left, - keyboardType: TextInputType.url, - autocorrect: false, - enableSuggestions: false, - style: AppTypography.settingsInput, - decoration: InputDecoration( - labelText: widget.label, - errorText: widget.errorText, - errorMaxLines: 3, + Text( + widget.label, + style: AppTypography.fieldLabel.copyWith( + color: widget.enabled + ? ColorManager.primaryText(context) + : Theme.of(context).disabledColor, + ), + ), + Semantics( + label: widget.label, + child: TextField( + controller: controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + enabled: widget.enabled, + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + keyboardType: TextInputType.url, + autocorrect: false, + enableSuggestions: false, + style: AppTypography.settingsInput, + decoration: InputDecoration( + errorText: widget.errorText, + errorMaxLines: 3, + ), ), ), if (widget.hint != null) - Text(widget.hint!, style: Theme.of(context).textTheme.bodySmall), + Text( + widget.hint!, + style: AppTypography.supporting.copyWith( + color: ColorManager.secondaryText(context), + ), + ), ], ); } diff --git a/lib/pages/shared/widgets/json_editor.dart b/lib/pages/shared/widgets/json_editor.dart index 12e1dd581..4ea936471 100644 --- a/lib/pages/shared/widgets/json_editor.dart +++ b/lib/pages/shared/widgets/json_editor.dart @@ -178,7 +178,7 @@ class _AppJsonEditorState extends State Widget _buildEditor(BuildContext context, JsonEditorState state) { final palette = ColorManager.palette(context); - final style = widget.textStyle ?? AppTypography.code; + final style = widget.textStyle ?? AppTypography.jsonEditor; final codeTheme = Map.from( Theme.of(context).brightness == Brightness.dark ? atomOneDarkTheme @@ -245,15 +245,22 @@ class _AppJsonEditorState extends State autofocus: false, autocompleteSymbols: true, wordWrap: false, - padding: const EdgeInsets.all(14), - border: Border.all(color: palette.border), - borderRadius: BorderRadius.circular(AppRadii.card), + padding: EdgeInsets.symmetric( + horizontal: 13, + vertical: + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint + ? 14 + : 12, + ), + border: Border.all(color: palette.input), + borderRadius: BorderRadius.circular(AppRadii.control), clipBehavior: Clip.antiAlias, style: CodeEditorStyle( fontFamily: AppFontFamily.mono, fontSize: style.fontSize, + fontHeight: style.height, textColor: palette.foreground, - backgroundColor: palette.muted, + backgroundColor: palette.card, selectionColor: palette.selection, cursorColor: palette.primary, cursorLineColor: palette.primary.withValues(alpha: .05), diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index bb8dc5b0e..d934a03f7 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -1,9 +1,19 @@ +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:macos_window_utils/widgets/macos_toolbar_passthrough.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/main/desktop_window.dart'; import 'package:go_router/go_router.dart'; import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/pages/shared/alert.dart'; +import 'package:onexray/service/shared/failure.dart'; +import 'package:onexray/service/shared/event_bus/enum.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -16,16 +26,128 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { this.leading, this.actions, this.bottom, + }) : description = null, + headingAction = null, + _headingHeight = null; + + const PageAppBar._({ + super.key, + required this.title, + this.description, + this.headingAction, + this.leading, + this.actions, + this.bottom, + required this._headingHeight, }); + factory PageAppBar.forPage( + BuildContext context, { + Key? key, + required Widget title, + String? description, + Widget? headingAction, + Widget? leading, + List? actions, + PreferredSizeWidget? bottom, + }) { + final viewport = MediaQuery.sizeOf(context); + final mobile = viewport.width <= AppLayout.mobileBreakpoint; + final hasShell = GoRouter.maybeOf(context) != null; + final sidebar = !mobile && hasShell + ? (viewport.width <= AppLayout.compactDesktopBreakpoint + ? AppLayout.compactSidebarWidth + : AppLayout.desktopSidebarWidth) + + 8 + : 0.0; + final width = (viewport.width - sidebar - (mobile ? 32 : 56)).clamp( + 1.0, + double.infinity, + ); + final effectiveDescription = + description ?? _defaultDescription(context, title); + final style = mobile + ? AppTypography.mobilePageTitle + : AppTypography.pageTitle; + double textHeight(String text, TextStyle textStyle) { + final painter = + TextPainter( + text: TextSpan( + text: text, + style: textStyle.copyWith(fontFamily: AppFontFamily.sans), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout( + maxWidth: headingAction != null && width >= 540 + ? (width - 20) / 2 + : width, + ); + final height = painter.height; + painter.dispose(); + return height; + } + + final titleHeight = title is Text && title.data != null + ? textHeight(title.data!, style.merge(title.style)) + : MediaQuery.textScalerOf(context).scale(style.fontSize!) * 1.45; + final blockHeight = + titleHeight + + (effectiveDescription == null || effectiveDescription.isEmpty + ? 0 + : 6 + textHeight(effectiveDescription, AppTypography.rowValue)); + final scaledExtra = + math.max(0, MediaQuery.textScalerOf(context).scale(14) - 14) * 1.45; + final actionHeight = AppLayout.touchExtent( + (mobile ? 48 : 42) + scaledExtra, + platform: Theme.of(context).platform, + ); + final naturalHeadingHeight = + (mobile ? 20.0 : 24.0) + + 8 + + (headingAction == null + ? blockHeight + : width < 540 + ? blockHeight + 16 + actionHeight + : math.max(blockHeight, actionHeight)); + final media = MediaQuery.of(context); + final headingBudget = math.max( + 44.0, + viewport.height - + media.padding.vertical - + media.viewInsets.bottom - + 56 - + 180 - + (bottom?.preferredSize.height ?? 0), + ); + final headingHeight = math.min(naturalHeadingHeight, headingBudget); + return PageAppBar._( + key: key, + title: title, + description: effectiveDescription, + headingAction: headingAction, + leading: leading, + actions: actions, + bottom: bottom, + headingHeight: headingHeight, + ); + } + final Widget title; + final String? description; + final Widget? headingAction; + final double? _headingHeight; final Widget? leading; final List? actions; final PreferredSizeWidget? bottom; // Retain AppBar's theme-aware preferred size, including its optional tabs. @override - Size get preferredSize => AppBar(bottom: bottom).preferredSize; + Size get preferredSize => _headingHeight == null + ? AppBar(bottom: bottom).preferredSize + : Size.fromHeight( + 56 + _headingHeight + (bottom?.preferredSize.height ?? 0), + ); @override Widget build(BuildContext context) { @@ -48,6 +170,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { } } final effectiveActions = [...?actions]; + Widget? mobileSettingsAction; final router = GoRouter.maybeOf(context); if (router != null && route != null && @@ -67,31 +190,286 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { AppPrimaryDestination.values.any( (p) => p != AppPrimaryDestination.settings && p.rootPath == path, )) { - effectiveActions.add( - IconButton( - key: const ValueKey('mobile-settings-action'), - tooltip: AppLocalizations.of(context)!.prototypeSettings, - icon: const Icon(LucideIcons.settings2), - onPressed: () => router.push( - '$path/${AppPageDestination.settings.segment}', - ), - ), + mobileSettingsAction = IconButton( + key: const ValueKey('mobile-settings-action'), + tooltip: AppLocalizations.of(context)!.prototypeSettings, + icon: const Icon(LucideIcons.settings2), + onPressed: () => + router.push('$path/${AppPageDestination.settings.segment}'), ); } } + if (_headingHeight != null) { + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final palette = ColorManager.palette(context); + final routeLeading = + effectiveLeading ?? + ((Scaffold.maybeOf(context)?.hasDrawer ?? false) + ? const DrawerButton() + : (route?.impliesAppBarDismissal ?? false) + ? route!.fullscreenDialog + ? const CloseButton() + : const BackButton() + : null); + final bus = context.read(); + final toolbarActions = [ + ...effectiveActions, + if (bus != null) _PageThemeToggle(bus: bus), + ?mobileSettingsAction, + ]; + Widget passThrough(Widget child) => + forwardClicks ? _AnimatedToolbarPassthrough(child: child) : child; + return AnnotatedRegion( + value: + Theme.of(context).appBarTheme.systemOverlayStyle ?? + SystemUiOverlayStyle.dark, + child: Material( + color: palette.header, + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + height: 56, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: mobile ? 16 : 24), + child: Row( + children: [ + if (routeLeading != null) passThrough(routeLeading), + Expanded(child: _toolbarIdentity(context, mobile)), + for (final action in toolbarActions) + passThrough(action), + ], + ), + ), + ), + SizedBox( + height: _headingHeight, + child: SingleChildScrollView( + key: const ValueKey('page-heading'), + primary: false, + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + mobile ? 20 : 24, + mobile ? 16 : 28, + 8, + ), + child: _pageHeading(context, mobile), + ), + ), + ?bottom, + ], + ), + ), + ), + ); + } return AppBar( title: title, leading: forwardClicks && effectiveLeading != null ? _AnimatedToolbarPassthrough(child: effectiveLeading) : effectiveLeading, actions: forwardClicks - ? effectiveActions - .map((child) => _AnimatedToolbarPassthrough(child: child)) - .toList() - : effectiveActions, + ? [ + ...effectiveActions, + ?mobileSettingsAction, + ].map((child) => _AnimatedToolbarPassthrough(child: child)).toList() + : [...effectiveActions, ?mobileSettingsAction], bottom: bottom, ); } + + Widget _pageHeading(BuildContext context, bool mobile) { + final palette = ColorManager.palette(context); + final identity = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + header: true, + child: DefaultTextStyle( + style: + (mobile + ? AppTypography.mobilePageTitle + : AppTypography.pageTitle) + .copyWith( + color: palette.foreground, + fontFamily: AppFontFamily.sans, + ), + child: title, + ), + ), + if (description != null && description!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + description!, + style: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), + ), + ], + ], + ); + if (headingAction == null) return identity; + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 540) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [identity, const SizedBox(height: 16), headingAction!], + ); + } + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: identity), + const SizedBox(width: 20), + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: (constraints.maxWidth - 20) / 2, + ), + child: headingAction!, + ), + ], + ); + }, + ); + } + + static String? _defaultDescription(BuildContext context, Widget title) { + if (title is! Text) return null; + final l = AppLocalizations.of(context); + if (l == null) return null; + return switch (title.data) { + final value when value == l.prototypeSettings => + l.settingsOverviewDescription, + final value when value == l.prototypeVpnTunnel => l.pageTunnelDescription, + final value when value == l.prototypeXrayRuntimeDiagnostics => + l.pageXrayDescription, + final value when value == l.navigationRouting => l.pageRoutingDescription, + _ => null, + }; + } + + Widget _toolbarIdentity(BuildContext context, bool mobile) { + final palette = ColorManager.palette(context); + if (mobile) { + return Row( + children: [ + Image.asset( + 'assets/logo.png', + width: 26, + height: 26, + excludeFromSemantics: true, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'OneXray', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.headerBrand.copyWith( + color: palette.foreground, + ), + ), + ), + ], + ); + } + final l = AppLocalizations.of(context)!; + final path = GoRouter.maybeOf(context) + ?.routerDelegate + .currentConfiguration + .uri + .path; + final destination = path == null + ? null + : AppPrimaryDestination.fromPath(path); + final (icon, label) = switch (destination) { + AppPrimaryDestination.connect => (LucideIcons.power, l.prototypeConnect), + AppPrimaryDestination.servers => (LucideIcons.server, l.prototypeServers), + AppPrimaryDestination.routing => (LucideIcons.route, l.navigationRouting), + AppPrimaryDestination.tunnel => ( + LucideIcons.shieldCheck, + l.prototypeVpnTunnel, + ), + AppPrimaryDestination.xray => (LucideIcons.activity, 'Xray'), + AppPrimaryDestination.settings => ( + LucideIcons.settings2, + l.prototypeSettings, + ), + null => (LucideIcons.panelTop, 'OneXray'), + }; + return Row( + children: [ + Icon(icon, size: 16, color: palette.mutedForeground), + const SizedBox(width: 10), + Text( + l.navigationWorkspace, + style: AppTypography.breadcrumb.copyWith( + color: palette.mutedForeground, + ), + ), + const SizedBox(width: 12), + Text( + '/', + style: AppTypography.breadcrumb.copyWith( + color: palette.mutedForeground, + ), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.breadcrumbCurrent.copyWith( + color: palette.foreground, + ), + ), + ), + ], + ); + } +} + +class _PageThemeToggle extends StatefulWidget { + const _PageThemeToggle({required this.bus}); + final AppEventBus bus; + @override + State<_PageThemeToggle> createState() => _PageThemeToggleState(); +} + +class _PageThemeToggleState extends State<_PageThemeToggle> { + bool _saving = false; + @override + Widget build(BuildContext context) { + final dark = Theme.of(context).brightness == Brightness.dark; + final l = AppLocalizations.of(context)!; + return IconButton( + tooltip: dark ? l.headerSwitchToLight : l.headerSwitchToDark, + icon: Icon(dark ? LucideIcons.sun : LucideIcons.moon, size: 18), + onPressed: _saving + ? null + : () async { + setState(() => _saving = true); + try { + await widget.bus.updateThemeCode( + dark ? ThemeCode.light : ThemeCode.dark, + ); + } catch (error) { + if (context.mounted) { + ContextAlert.showToast(context, appFailureMessage(l, error)); + } + } finally { + if (mounted) setState(() => _saving = false); + } + }, + ); + } } /// Route transitions transform the page without laying out its buttons again. diff --git a/lib/pages/shared/widgets/settings_page.dart b/lib/pages/shared/widgets/settings_page.dart index 8cf3fe50a..321d4e0d2 100644 --- a/lib/pages/shared/widgets/settings_page.dart +++ b/lib/pages/shared/widgets/settings_page.dart @@ -1,5 +1,4 @@ import 'dart:math' as math; -import 'dart:ui' as ui; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -14,6 +13,7 @@ import 'package:shadcn_ui/shadcn_ui.dart'; class SettingsPageScaffold extends StatelessWidget { final String title; + final String? description; final Widget body; final VoidCallback? onSave; final bool saveLoading; @@ -23,6 +23,7 @@ class SettingsPageScaffold extends StatelessWidget { const SettingsPageScaffold({ super.key, required this.title, + this.description, required this.body, this.onSave, this.saveLoading = false, @@ -33,8 +34,10 @@ class SettingsPageScaffold extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: PageAppBar( + appBar: PageAppBar.forPage( + context, title: Text(title), + description: description, actions: [ ...actions, if (onSave != null || saveLoading) _saveButton(context), @@ -181,7 +184,7 @@ class AppConfirmationDialog extends StatelessWidget { context: context, barrierDismissible: barrierDismissible, useSafeArea: false, - barrierColor: AppPalette.restoreOverlay, + barrierColor: Colors.black.withValues(alpha: .45), builder: (_) => this, ) ?? false; @@ -191,6 +194,10 @@ class AppConfirmationDialog extends StatelessWidget { final palette = ColorManager.palette(context); final size = MediaQuery.sizeOf(context); final mobile = size.width <= AppLayout.mobileBreakpoint; + final closeSize = AppLayout.touchExtent( + 40, + platform: Theme.of(context).platform, + ); final buttonStyle = ButtonStyle( minimumSize: WidgetStatePropertyAll( Size( @@ -209,236 +216,184 @@ class AppConfirmationDialog extends StatelessWidget { textStyle: WidgetStatePropertyAll(AppTypography.control), tapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.standard, - shape: const WidgetStatePropertyAll( - RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(AppRadii.control)), - ), - ), ); final confirm = FilledButton( onPressed: () => Navigator.pop(context, true), - style: destructive - ? buttonStyle.merge(AppTheme.destructiveButton(context)) - : buttonStyle, + style: + (destructive + ? buttonStyle.merge(AppTheme.destructiveButton(context)) + : buttonStyle) + .copyWith( + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(AppRadii.cta), + ), + ), + ), + ), child: Text(confirmLabel, textAlign: TextAlign.center), ); - return Stack( - children: [ - Positioned.fill( - child: IgnorePointer( - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 4, sigmaY: 4), - child: const SizedBox.expand(), - ), + final cancel = OutlinedButton( + onPressed: () => Navigator.pop(context, false), + style: buttonStyle.copyWith( + side: WidgetStatePropertyAll(BorderSide(color: palette.border)), + shape: const WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(AppRadii.control)), ), ), - SafeArea( - child: Dialog( - insetPadding: EdgeInsets.symmetric( - horizontal: mobile ? 19 : 20, - vertical: 24, - ), - backgroundColor: palette.card, - surfaceTintColor: Colors.transparent, - elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: mobile - ? const BorderRadius.vertical( - top: Radius.circular(AppRadii.mobileDialog), - ) - : const BorderRadius.all(Radius.circular(AppRadii.dialog)), - side: BorderSide(color: palette.border), + ), + child: Text(cancelLabel, textAlign: TextAlign.center), + ); + return SafeArea( + child: Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + backgroundColor: palette.card, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + size.width < 640 ? 0 : AppRadii.card, + ), + side: BorderSide(color: palette.border), + ), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: math.min(520.0, math.max(0.0, size.width - 32)), + maxHeight: math.min( + AppLayout.dialogMaxHeight, + size.height * + (mobile + ? AppLayout.dialogMobileHeightFactor + : AppLayout.dialogDesktopHeightFactor), ), - clipBehavior: Clip.antiAlias, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: AppLayout.dialogWidth, - maxHeight: math.min( - AppLayout.dialogMaxHeight, - size.height * - (mobile - ? AppLayout.dialogMobileHeightFactor - : AppLayout.dialogDesktopHeightFactor), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final pinActions = + constraints.maxHeight >= 380 && + MediaQuery.textScalerOf(context).scale(14) <= 28; + final actions = LayoutBuilder( + builder: (context, constraints) { + double naturalWidth(String label) { + final painter = TextPainter( + text: TextSpan( + text: label, + style: AppTypography.control.copyWith( + fontFamily: AppFontFamily.sans, + ), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final width = + painter.width + AppSpacing.buttonHorizontal * 2; + painter.dispose(); + return width; + } + + final vertical = + size.width < 560 || + naturalWidth(cancelLabel) + + naturalWidth(confirmLabel) + + 10 > + constraints.maxWidth; + if (vertical) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [cancel, const SizedBox(height: 10), confirm], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible(child: cancel), + const SizedBox(width: 10), + Flexible(child: confirm), + ], + ); + }, + ); + return Stack( children: [ - Flexible( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - constraints: BoxConstraints( - minHeight: mobile - ? AppLayout.dialogMobileHeaderMinHeight - : AppLayout.dialogHeaderMinHeight, - ), - padding: mobile - ? const EdgeInsets.fromLTRB(16, 17, 16, 14) - : const EdgeInsets.fromLTRB(21, 20, 21, 17), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: palette.border), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Text( - title, - style: - (mobile - ? AppTypography.dialogTitle - : AppTypography - .confirmationDesktopTitle) - .copyWith( - color: palette.foreground, - ), + Padding( + padding: EdgeInsetsDirectional.only( + end: closeSize - 16, ), - ), - const SizedBox(width: 12), - IconButton( - autofocus: true, - tooltip: AppLocalizations.of(context)! - .prototypeCloseDialog, - onPressed: () => - Navigator.pop(context, false), - style: IconButton.styleFrom( - foregroundColor: palette.mutedStrong, - minimumSize: Size.square( - AppLayout.touchExtent( - AppLayout.dialogCloseSize, - platform: Theme.of(context).platform, - ), - ), - maximumSize: Size.square( - AppLayout.touchExtent( - AppLayout.dialogCloseSize, - platform: Theme.of(context).platform, - ), + child: Semantics( + header: true, + child: Text( + title, + style: AppTypography.confirmationTitle + .copyWith(color: palette.foreground), ), - padding: EdgeInsets.zero, - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, ), - icon: const Icon(LucideIcons.x, size: 20), ), - ], - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 15, - children: [ - if (subject != null) + const SizedBox(height: 8), + if (subject != null) ...[ Text( subject!, style: AppTypography.confirmationSubject .copyWith(color: palette.foreground), ), + const SizedBox(height: 8), + ], Text( content, style: AppTypography.confirmationBody - .copyWith(color: palette.foreground), + .copyWith(color: palette.mutedForeground), ), + if (!pinActions) ...[ + const SizedBox(height: 8), + actions, + ], ], ), ), - ], - ), + ), + if (pinActions) const SizedBox(height: 8), + if (pinActions) actions, + ], ), ), - Container( - constraints: const BoxConstraints(minHeight: 70), - padding: EdgeInsets.symmetric( - horizontal: mobile ? 16 : 20, - vertical: mobile ? 12 : 14, - ), - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: LayoutBuilder( - builder: (context, constraints) { - final labelStyle = DefaultTextStyle.of(context).style - .merge(AppTypography.control); - double naturalWidth(String label) { - final painter = TextPainter( - text: TextSpan( - text: label, - style: MediaQuery.boldTextOf(context) - ? labelStyle.copyWith( - fontWeight: FontWeight.bold, - ) - : labelStyle, - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.localeOf(context), - )..layout(); - final width = - painter.width + AppSpacing.buttonHorizontal * 2; - painter.dispose(); - return width; - } - - final cancelWidth = naturalWidth(cancelLabel); - final confirmWidth = naturalWidth(confirmLabel); - final cancel = OutlinedButton( - onPressed: () => Navigator.pop(context, false), - style: buttonStyle.copyWith( - side: WidgetStatePropertyAll( - BorderSide(color: palette.borderStrong), - ), - ), - child: Text(cancelLabel, textAlign: TextAlign.center), - ); - if (cancelWidth + confirmWidth + AppSpacing.actionGap > - constraints.maxWidth) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - cancel, - const SizedBox(height: AppSpacing.actionGap), - confirm, - ], - ); - } - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: math.max( - constraints.maxWidth * .62, - cancelWidth, - ), - ), - child: IntrinsicWidth(child: cancel), - ), - const SizedBox(width: AppSpacing.actionGap), - if (mobile && expandConfirm) - Expanded(child: confirm) - else - Flexible(child: confirm), - ], - ); - }, + PositionedDirectional( + top: 8, + end: 8, + child: IconButton( + autofocus: true, + tooltip: AppLocalizations.of(context)! + .prototypeCloseDialog, + onPressed: () => Navigator.pop(context, false), + style: IconButton.styleFrom( + foregroundColor: palette.mutedForeground, + minimumSize: Size.square(closeSize), + maximumSize: Size.square(closeSize), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: const Icon(LucideIcons.x, size: 20), ), ), ], - ), - ), + ); + }, ), ), - ], + ), ); } } diff --git a/lib/pages/theme/font.dart b/lib/pages/theme/font.dart index 9e094273e..f23f4fc11 100644 --- a/lib/pages/theme/font.dart +++ b/lib/pages/theme/font.dart @@ -25,9 +25,15 @@ abstract final class AppTypography { ); static final pageTitle = _style(26, 600); + static final mobilePageTitle = _style(24, 600); + static final headerBrand = _style(16, 700); + static final breadcrumb = _style(12); + static final breadcrumbCurrent = _style(12, 500); static final panelTitle = _style(16, 600); static final sectionTitle = _style(16, 600); static final rowTitle = _style(14, 500); + static final fieldLabel = _style(14, 600); + static final jsonEditor = _style(14); static final rowValue = _style(14); static final supporting = _style(13); static final metadata = _style(13); @@ -67,68 +73,68 @@ abstract final class AppTypography { static final settingsSelect = _style(14, 400); static final settingsInput = _style(14, 400); static final routingCardTitle = _style(16, 600); - static final routingCardDescription = _style(12, 400); - static final routingRowTitle = _style(13, 700); - static final routingRowTitleMobile = _style(12, 700); - static final routingRowDescription = _style(12, 400); - static final routingRowValue = _style(12, 600); + static final routingCardDescription = _style(13, 400); + static final routingRowTitle = _style(14, 500); + static final routingRowTitleMobile = _style(14, 500); + static final routingRowDescription = _style(13, 400); + static final routingRowValue = _style(13, 400); static final routingCount = _style(12, 700); - static final routingPreviewTitle = _style(13, 700); - static final routingPreviewBody = _style(12, 400); - static final routingPreviewHint = _style(12, 400); - static final routingPreviewMeta = _style(12, 400); - static final routingSelectionTitle = _style(12, 700); - static final routingSelectionDescription = _style(12, 400); + static final routingPreviewTitle = _style(14, 500); + static final routingPreviewBody = _style(13, 400); + static final routingPreviewHint = _style(13, 400); + static final routingPreviewMeta = _style(13, 400); + static final routingSelectionTitle = _style(14, 500); + static final routingSelectionDescription = _style(13, 400); static final routingSelectionGroup = _style(12, 700); - static final routingSelectionInput = _style(12, 400); + static final routingSelectionInput = _style(13, 400); static final routingSelectionCount = _style(12, 600); static final routingSelectionNote = _style(12, 400); static final routingRegionCode = _style(12, 700); - static final routeIdentityLabel = _style(12, 600); - static final routeCount = _style(12, 400); - static final ruleTitleMobile = _style(12, 700); - static final ruleTitleDesktop = _style(13, 700); - static final ruleSummaryDesktop = _style(12, 400); - static final ruleSummary = _style(12, 400); + static final routeIdentityLabel = _style(14, 500); + static final routeCount = _style(13, 400); + static final ruleTitleMobile = _style(14, 500); + static final ruleTitleDesktop = _style(14, 500); + static final ruleSummaryDesktop = _style(13, 400); + static final ruleSummary = _style(13, 400); static final ruleAction = _style(12, 700); static final ruleNumber = _style(12, 400); - static final conditionTitle = _style(12, 700); - static final conditionSummary = _style(12, 400); - static final routingInput = _style(16, 400); + static final conditionTitle = _style(14, 500); + static final conditionSummary = _style(13, 400); + static final routingInput = _style(14, 400); static final conditionRelation = _style(12, 400); - static final actionOption = _style(12, 400); - static final selectedActionOption = _style(12, 700); - static final actionHelp = _style(12, 400); - static final ruleAdd = _style(12, 600); - static final configurationTool = _style(12, 600); - static final rawField = _style(14, 600); - static final rawNote = _style(12, 400); - static final runtimeCodePill = _style(12, 600); - static final runtimeCodeAction = _style(12, 600); - static final runtimeCodeMobile = _style(12, 400); - static final runtimeCodeDesktop = _style(12, 400); - static final runtimeCodeNote = _style(12, 400); - static final runtimeCodeDesktopNote = _style(12, 400); + static final actionOption = _style(14, 400); + static final selectedActionOption = _style(14, 600); + static final actionHelp = _style(13, 400); + static final ruleAdd = _style(14, 600); + static final configurationTool = _style(14, 600); + static final rawField = _style(14, 500); + static final rawNote = _style(13, 400); + static final runtimeCodePill = _style(13, 500); + static final runtimeCodeAction = _style(14, 600); + static final runtimeCodeMobile = _style(13, 400); + static final runtimeCodeDesktop = _style(13, 400); + static final runtimeCodeNote = _style(13, 400); + static final runtimeCodeDesktopNote = _style(13, 400); static final routingConditionInput = _style(16, 400); - static final subscriptionField = _style(12, 600); - static final subscriptionInfo = _style(12, 400); - static final subscriptionAgeTitle = _style(13, 700); - static final subscriptionAgeOptional = _style(12, 500); - static final subscriptionAgeWarning = _style(12, 400); - static final subscriptionAgeAction = _style(12, 600); + static final subscriptionField = _style(14, 500); + static final subscriptionInfo = _style(13, 400); + static final subscriptionAgeTitle = _style(14, 500); + static final subscriptionAgeOptional = _style(13, 400); + static final subscriptionAgeWarning = _style(13, 400); + static final subscriptionAgeAction = _style(14, 600); static final subscriptionClear = _style(13, 600); - static final importMethod = _style(16, 400); - static final importField = _style(13, 600); - static final importHint = _style(12, 500); - static final importJson = _style(12, 400); - static final importJsonHint = _style(12, 500); - static final importSummary = _style(16, 700); - static final importSummaryMeta = _style(12, 400); - static final importStat = _style(12, 400); + static final importMethod = _style(14, 500); + static final importField = _style(14, 500); + static final importHint = _style(13, 400); + static final importJson = _style(14, 400); + static final importJsonHint = _style(13, 400); + static final importSummary = _style(16, 600); + static final importSummaryMeta = _style(13, 400); + static final importStat = _style(13, 400); static final dialogBack = _style(13, 400); - static final shareLink = _style(12, 400); - static final shareQr = _style(13, 400); - static final shareHint = _style(12, 400); + static final shareLink = _style(13, 400); + static final shareQr = _style(14, 500); + static final shareHint = _style(13, 400); static final aboutBrandTitle = _style(19, 700); static final aboutBrandDescription = _style(12, 400); static final settingsChoiceTitle = _style(14, 600); @@ -136,15 +142,15 @@ abstract final class AppTypography { static final iconPreviewBrand = _style(16, 700); static final iconChoice = _style(13, 400); static final iconPreviewCaption = _style(12, 400); - static final platformDetailTitle = _style(19, 700); - static final platformDetailBody = _style(16, 400); - static final platformChoiceTitle = _style(13, 700); - static final platformChoiceHint = _style(12, 400); + static final platformDetailTitle = _style(16, 600); + static final platformDetailBody = _style(14, 400); + static final platformChoiceTitle = _style(14, 500); + static final platformChoiceHint = _style(13, 400); static final windowsPolicyHint = _style(12, 400); - static final windowsNetworkTitle = _style(15, 700); - static final windowsNetworkMeta = _style(12, 400); - static final windowsNetworkInput = _style(13, 400); - static final windowsNetworkNote = _style(12, 400); + static final windowsNetworkTitle = _style(16, 600); + static final windowsNetworkMeta = _style(13, 400); + static final windowsNetworkInput = _style(14, 400); + static final windowsNetworkNote = _style(13, 400); static final setupBrand = _style(19, 700); static final setupTitle = _style(27, 700); static final setupDesktopTitle = _style(40, 700); @@ -176,34 +182,34 @@ abstract final class AppTypography { static final setupSearch = _style(15, 400); static final setupError = _style(13, 400); static final setupPrivacyLink = _style(14, 600); - static final appleSettingTitle = _style(12, 700); - static final appleSettingTitleDesktop = _style(13, 700); - static final appleSettingHint = _style(12, 400); - static final appleSettingHintDesktop = _style(12, 400); - static final appleAutoTitle = _style(14, 700); - static final appleAutoTitleDesktop = _style(15, 700); - static final appleRuleTitle = _style(12, 700); - static final appleRuleTitleDesktop = _style(12, 700); - static final appleRuleHint = _style(12, 400); - static final appleRuleHintDesktop = _style(12, 400); - static final appleWifiToken = _style(12, 600); - static final appleWifiTokenDesktop = _style(12, 600); - static final appleNetworkAction = _style(12, 600); - static final appleNetworkActionDesktop = _style(12, 600); - static final appleFallbackPill = _style(12, 600); - static final appleFallbackPillDesktop = _style(12, 600); - static final appleWifiEdit = _style(12, 700); - static final appleWifiEditDesktop = _style(12, 700); - static final appleWifiHeading = _style(15, 700); - static final appleWifiHeadingDesktop = _style(18, 700); - static final appleWifiDescription = _style(12, 400); + static final appleSettingTitle = _style(14, 500); + static final appleSettingTitleDesktop = _style(14, 500); + static final appleSettingHint = _style(13, 400); + static final appleSettingHintDesktop = _style(13, 400); + static final appleAutoTitle = _style(16, 600); + static final appleAutoTitleDesktop = _style(16, 600); + static final appleRuleTitle = _style(14, 500); + static final appleRuleTitleDesktop = _style(14, 500); + static final appleRuleHint = _style(13, 400); + static final appleRuleHintDesktop = _style(13, 400); + static final appleWifiToken = _style(13, 500); + static final appleWifiTokenDesktop = _style(13, 500); + static final appleNetworkAction = _style(14, 600); + static final appleNetworkActionDesktop = _style(14, 600); + static final appleFallbackPill = _style(13, 500); + static final appleFallbackPillDesktop = _style(13, 500); + static final appleWifiEdit = _style(14, 600); + static final appleWifiEditDesktop = _style(14, 600); + static final appleWifiHeading = _style(16, 600); + static final appleWifiHeadingDesktop = _style(16, 600); + static final appleWifiDescription = _style(13, 400); static final appleWifiDescriptionDesktop = _style(13, 400); - static final appleWifiInput = _style(12, 400); - static final appleWifiInputDesktop = _style(16, 400); - static final appleWifiAdd = _style(12, 700); - static final appleWifiAddDesktop = _style(13, 700); - static final appleWifiMatchNote = _style(12, 400); - static final appleWifiMatchNoteDesktop = _style(12, 400); + static final appleWifiInput = _style(14, 400); + static final appleWifiInputDesktop = _style(14, 400); + static final appleWifiAdd = _style(14, 600); + static final appleWifiAddDesktop = _style(14, 600); + static final appleWifiMatchNote = _style(13, 400); + static final appleWifiMatchNoteDesktop = _style(13, 400); static final updateTitle = _style(18, 600); static final updateVersionLabel = _style(13, 400); static final updateVersion = _style(12, 400); @@ -211,66 +217,66 @@ abstract final class AppTypography { static final updateNotes = _style(13, 400); static final settingsDanger = _style(14, 600); - static final geodataIntro = _style(12, 400); - static final geodataDesktopIntro = _style(12, 400); - static final geodataDesktopTitle = _style(14, 700); - static final geodataTableHeading = _style(12, 400); - static final geodataTableBody = _style(12, 400); - static final geodataDesktopAction = _style(12, 700); - static final geodataTitle = _style(13, 700); - static final geodataMeta = _style(12, 500); - static final geodataValue = _style(12, 400); - static final geodataAction = _style(12, 600); - static final geodataPrimaryAction = _style(12, 600); - static final geodataField = _style(12, 600); - static final geodataEmpty = _style(12, 400); - static final geodataCategory = _style(13, 700); - static final geodataReference = _style(12, 400); - static final geodataSourceUrl = _style(12, 400); - static final geodataReadonly = _style(12, 600); - static final geodataBody = _style(16, 400); - static final geodataSourceLabel = _style(12, 400); + static final geodataIntro = _style(13, 400); + static final geodataDesktopIntro = _style(13, 400); + static final geodataDesktopTitle = _style(16, 600); + static final geodataTableHeading = _style(13, 400); + static final geodataTableBody = _style(13, 400); + static final geodataDesktopAction = _style(14, 600); + static final geodataTitle = _style(16, 600); + static final geodataMeta = _style(13, 400); + static final geodataValue = _style(13, 400); + static final geodataAction = _style(14, 600); + static final geodataPrimaryAction = _style(14, 600); + static final geodataField = _style(14, 400); + static final geodataEmpty = _style(13, 400); + static final geodataCategory = _style(14, 500); + static final geodataReference = _style(13, 400); + static final geodataSourceUrl = _style(13, 400); + static final geodataReadonly = _style(13, 500); + static final geodataBody = _style(14, 400); + static final geodataSourceLabel = _style(13, 400); - static final androidTitle = _style(19, 700); - static final androidBody = _style(16, 400); - static final androidModeTitle = _style(13, 700); + static final androidTitle = _style(16, 600); + static final androidBody = _style(14, 400); + static final androidModeTitle = _style(14, 500); static final androidModeHint = _style(12, 400); - static final androidRowTitle = _style(12, 700); - static final androidRowHint = _style(12, 400); - static final androidPackage = _style(12, 400); - static final androidCount = _style(12, 700); - static final androidBadge = _style(12, 700); - static final androidEmpty = _style(12, 400); + static final androidRowTitle = _style(14, 500); + static final androidRowHint = _style(13, 400); + static final androidPackage = _style(13, 400); + static final androidCount = _style(13, 500); + static final androidBadge = _style(13, 500); + static final androidEmpty = _style(13, 400); - static final runtimeLabel = _style(12, 400); - static final runtimeDesktopLabel = _style(12, 400); - static final runtimeValue = _style(14, 600); - static final runtimeDesktopValue = _style(14, 600); + static final runtimeLabel = _style(13, 400); + static final runtimeDesktopLabel = _style(13, 400); + static final runtimeValue = _style(14, 500); + static final runtimeDesktopValue = _style(14, 500); static final runtimeNavigationHint = _style(12, 400); - static final runtimePushHint = _style(12, 400); - static final runtimeDesktopPushHint = _style(12, 400); - static final runtimeSelector = _style(12, 400); + static final runtimePushHint = _style(13, 400); + static final runtimeDesktopPushHint = _style(13, 400); + static final runtimeSelector = _style(14, 400); static final runtimeDesktopSelector = _style(14, 400); static final serverBody = _style(16, 400); static final serverTitle = _style(14, 600); // HTML small rounds up from 5/6 of the prototype's 16px body text. - static final serverDetail = _style(14, 400); - static final serverGroupDetail = _style(14, 400); + static final serverDetail = _style(13, 400); + static final serverGroupDetail = _style(13, 400); static final serverSectionTitle = _style(12, 700); static final serverRegionCode = _style(12, 700); static final serverSelectionHealth = _style(12, 600); static final serverSelectionLabel = _style(12, 400); - static final serverSelectionTitle = _style(14, 700); - static final serverSelectionDetail = _style(12, 400); + static final serverSelectionTitle = _style(14, 500); + static final serverSelectionDetail = _style(13, 400); static final serverUseLabel = _style(12, 600); static final serverUseCount = _style(14, 600); static final serverGroupCode = _style(13, 700); - static final serverGroupTitle = _style(18, 700); - static final serverGroupSummary = _style(12, 400); + static final serverGroupTitle = _style(16, 600); + static final serverGroupSummary = _style(13, 400); static final serverNodeTitle = _style(14, 500); static final serverProtocol = _style(12, 600); - static final serverGroupAction = _style(13, 600); + static final serverGroupAction = _style(14, 600); static final serverGroupUseCount = _style(12, 700); static final serverConnectedBadge = _style(12, 700); @@ -313,21 +319,22 @@ abstract final class AppTypography { static final connectDesktopRawEmptyTitle = _style(14, 700); static final connectDesktopRawEmptyDetail = _style(12, 400); - static final dialogTitle = _style(18, 700); + static final dialogTitle = _style(16, 600); static final dialogSubtitle = _style(13, 400); static final dialogBody = _style(14, 400); static final dialogCallout = _style(13, 400); - static final dialogOptionTitle = _style(14, 700); - static final dialogOptionDescription = _style(12, 400); - static final dialogOptionEdit = _style(12, 700); + static final dialogOptionTitle = _style(14, 500); + static final dialogOptionDescription = _style(13, 400); + static final dialogOptionEdit = _style(14, 600); static final dialogGroupTitle = _style(13, 700); static final dialogGroupMeta = _style(12, 400); - static final dialogAddAction = _style(12, 600); - static final confirmationDesktopTitle = _style(20, 700); - static final confirmationSubject = _style(14, 700); + static final dialogAddAction = _style(14, 600); + static final confirmationTitle = _style(20, 600); + static final confirmationDesktopTitle = _style(20, 600); + static final confirmationSubject = _style(14, 600); static final confirmationBody = _style(14, 400); - static final serverMenuTitle = _style(13, 700); - static final serverMenuHint = _style(12, 400); + static final serverMenuTitle = _style(14, 500); + static final serverMenuHint = _style(13, 400); static final shad = ShadTextTheme.custom( canMerge: false, diff --git a/test/pages/advanced/tunnel/apple/page_test.dart b/test/pages/advanced/tunnel/apple/page_test.dart index b92d9e094..0d79fb0c4 100644 --- a/test/pages/advanced/tunnel/apple/page_test.dart +++ b/test/pages/advanced/tunnel/apple/page_test.dart @@ -224,15 +224,15 @@ void main() { ), ), ); - expect(find.text('System VPN policy'), findsNothing); - expect(find.byType(ShadSwitch), findsNWidgets(5)); + expect(find.text('System VPN policy'), findsOneWidget); + expect(find.byType(ShadSwitch), findsNWidgets(4)); final warning = find.textContaining( 'Incorrect settings may block network access.', ); expect(warning, findsOneWidget); await _tap(tester, _toggle('captureAllTraffic')); expect(warning, findsOneWidget); - expect(find.byType(ShadSwitch), findsNWidgets(9)); + expect(find.byType(ShadSwitch), findsNWidgets(8)); expect( tester.widget(_toggle('allowLocalNetwork')).value, isTrue, @@ -455,7 +455,7 @@ void main() { expect(find.text(l.prototypeWifiExactMatchNotice), findsOneWidget); expect( tester.widget(find.byType(TextField).first).style!.fontSize, - 12, + 14, ); expect( Directionality.of(tester.element(find.byType(AppleWifiView))), diff --git a/test/pages/advanced/tunnel/connection_status_test.dart b/test/pages/advanced/tunnel/connection_status_test.dart index 0aa56e2d2..39c06f313 100644 --- a/test/pages/advanced/tunnel/connection_status_test.dart +++ b/test/pages/advanced/tunnel/connection_status_test.dart @@ -66,6 +66,16 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(VpnTunnelPane)), )!; + expect( + find.byKey(const ValueKey('tunnel-system-capture')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('tunnel-connection-parameters')), + findsOneWidget, + ); + expect(find.byType(TextField), findsNothing); + expect(find.byType(PolicyActions), findsNothing); final row = find.byWidgetPredicate( (widget) => widget is PolicyValueRow && widget.title == l.prototypeSystemVpn, diff --git a/test/pages/advanced/tunnel/dns_fields_test.dart b/test/pages/advanced/tunnel/dns_fields_test.dart index fc3f8fb1b..cfa3a1a7b 100644 --- a/test/pages/advanced/tunnel/dns_fields_test.dart +++ b/test/pages/advanced/tunnel/dns_fields_test.dart @@ -5,7 +5,7 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; -import 'package:onexray/pages/advanced/tunnel/page.dart'; +import 'package:onexray/pages/advanced/tunnel/details.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; @@ -42,10 +42,29 @@ Future _mount( addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget( - _app(VpnTunnelPane(createController: () => controller)), + _app( + Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-dns'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TunnelDnsPage( + draft: controller.draft!.copy(), + createController: () => controller, + ), + ), + ), + child: const Text('Open DNS'), + ), + ), + ), + ), ); await tester.pumpAndSettle(); - return AppLocalizations.of(tester.element(find.byType(VpnTunnelPane)))!; + await tester.tap(find.byKey(const Key('open-dns'))); + await tester.pumpAndSettle(); + return AppLocalizations.of(tester.element(find.byType(TunnelDnsPage)))!; } void main() { @@ -242,13 +261,68 @@ void main() { await tester.pumpWidget(const SizedBox()); }); + testWidgets('cancel discards an edited DNS draft without saving', ( + tester, + ) async { + final original = service.configuration.policy.dnsIpv4Address; + final l = await _mount(tester, controller); + await tester.enterText(_field('ipv4'), '1.1.1.1'); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(find.byType(TunnelDnsPage), findsNothing); + expect(service.configuration.policy.dnsIpv4Address, original); + expect(service.saves, 0); + expect(tester.takeException(), isNull); + }); + + testWidgets('DNS save keeps the draft when reconnect is declined', ( + tester, + ) async { + service.requestConfirmation = true; + coordinator.state.value = const ConnectionView( + phase: ConnectionPhase.connected, + ); + final l = await _mount(tester, controller); + final original = service.configuration.policy.dnsIpv4Address; + await tester.enterText(_field('ipv4'), '1.1.1.1'); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(FilledButton, l.prototypeSaveAndReconnect), + ); + // The form remains busy while confirmation is open; its spinner never settles. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(ShadDialog), findsOneWidget); + await tester.tap(find.widgetWithText(ShadButton, l.buttonCancel)); + await tester.pumpAndSettle(); + expect(find.byType(TunnelDnsPage), findsOneWidget); + expect(controller.value['dnsIpv4Address'], '1.1.1.1'); + expect(service.configuration.policy.dnsIpv4Address, original); + expect(service.saves, 0); + await tester.tap( + find.widgetWithText(FilledButton, l.prototypeSaveAndReconnect), + ); + // The form remains busy while confirmation is open; its spinner never settles. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap( + find.widgetWithText(ShadButton, l.prototypeSaveAndReconnect), + ); + await tester.pumpAndSettle(); + expect(find.byType(TunnelDnsPage), findsNothing); + expect(service.configuration.policy.dnsIpv4Address, '1.1.1.1'); + expect(service.saves, 1); + expect(tester.takeException(), isNull); + }); + testWidgets('closing the page cancels a pending DNS focus reveal', ( tester, ) async { controller.update('dnsIpv4Address', 'invalid-v4'); await _mount(tester, controller); await controller.save( - tester.element(find.byType(VpnTunnelPane)), + tester.element(find.byType(TunnelDnsPage)), pop: false, ); await tester.pumpWidget(const SizedBox()); @@ -265,6 +339,7 @@ class _DnsService extends PolicyEditorService { ConnectionConfiguration configuration = ConnectionConfiguration(); Object? failure; int saves = 0; + bool requestConfirmation = false; @override Future load() async => PolicyEditorDraft(configuration); @@ -276,6 +351,7 @@ class _DnsService extends PolicyEditorService { }) async { final prepared = prepare(draft); if (failure case final error?) throw error; + if (requestConfirmation && !await confirm(false)) return false; configuration = ConnectionConfiguration( connection: draft.original.connection, policy: prepared.policy, diff --git a/test/pages/advanced/tunnel/root_page_test.dart b/test/pages/advanced/tunnel/root_page_test.dart new file mode 100644 index 000000000..55852c580 --- /dev/null +++ b/test/pages/advanced/tunnel/root_page_test.dart @@ -0,0 +1,365 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/ffi/windows/mode.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/tunnel/controller.dart'; +import 'package:onexray/pages/advanced/tunnel/details.dart'; +import 'package:onexray/pages/advanced/tunnel/page.dart'; +import 'package:onexray/pages/advanced/tunnel/widgets.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/advanced/platform_policy.dart'; +import 'package:onexray/service/advanced/policy_editor.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +Widget _app(Widget child, double width) => MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: width <= 720), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: width <= 720), + child: ShadToaster(child: child!), + ), + home: child, +); + +void _viewport(WidgetTester tester, Size size) { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); +} + +Future _tap(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +_UiService _service({ConnectionPlatform platform = ConnectionPlatform.ios}) { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final coordinator = ConnectionCoordinator( + database: db, + statusEvents: const Stream.empty(), + ); + addTearDown(() async { + coordinator.dispose(); + await db.close(); + }); + return _UiService(coordinator, platform); +} + +void main() { + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'tunnel summaries open isolated drafts and refresh only after save at $size', + (tester) async { + _viewport(tester, size); + final service = _service(); + final root = PolicyEditorController( + draft: PolicyEditorDraft(service.configuration), + service: service, + ); + addTearDown(root.close); + PolicyEditorController? child; + final opened = []; + await tester.pumpWidget( + _app( + Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(AppLocalizations.of(context)!.prototypeVpnTunnel), + ), + body: VpnTunnelPane( + createController: () => root, + openTunnel: (context, destination, draft) { + opened.add(destination); + expect( + identical(draft.policy, root.draft!.policy), + isFalse, + ); + child = PolicyEditorController( + draft: draft, + service: service, + ); + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => switch (destination) { + TunnelDestination.dns => TunnelDnsPage( + draft: draft, + createController: () => child!, + ), + TunnelDestination.network => TunnelNetworkPage( + draft: draft, + createController: () => child!, + ), + _ => throw StateError( + 'Unexpected destination $destination', + ), + }, + ), + ); + }, + ), + ), + ), + size.width, + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(VpnTunnelPane)), + )!; + expect(find.byType(SettingSection), findsNWidgets(2)); + expect(find.byType(TextField), findsNothing); + expect(find.byType(PolicyActions), findsNothing); + expect(find.text('iOS'), findsOneWidget); + for (final key in [ + 'tunnel-system-capture', + 'tunnel-connection-parameters', + ]) { + final rect = tester.getRect(find.byKey(ValueKey(key))); + expect(rect.left, greaterThanOrEqualTo(size.width <= 720 ? 16 : 28)); + expect( + rect.right, + lessThanOrEqualTo(size.width - (size.width <= 720 ? 16 : 28)), + ); + } + final dns = find.byKey(const ValueKey('tunnel-open-dns')); + await _tap(tester, dns); + expect(find.byType(TunnelDnsPage), findsOneWidget); + final ipv4 = find.descendant( + of: find.byKey(const ValueKey('tunnel-dns-ipv4')), + matching: find.byType(TextField), + ); + await tester.enterText(ipv4, '2.2.2.2'); + await tester.pumpAndSettle(); + expect(child!.value['dnsIpv4Address'], '2.2.2.2'); + expect(root.value['dnsIpv4Address'], '1.1.1.1'); + await _tap( + tester, + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ); + expect(root.value['dnsIpv4Address'], '1.1.1.1'); + expect(service.saves, 0); + expect(service.loads, 0); + await _tap(tester, dns); + await tester.enterText(ipv4, '2.2.2.2'); + await tester.pumpAndSettle(); + await _tap(tester, find.widgetWithText(FilledButton, l.prototypeSave)); + expect(find.byType(TunnelDnsPage), findsNothing); + expect(root.value['dnsIpv4Address'], '2.2.2.2'); + expect(service.saves, 1); + expect(service.loads, 1); + expect(find.textContaining('2.2.2.2'), findsOneWidget); + expect(find.byType(PolicyActions), findsNothing); + + final network = find.byKey(const ValueKey('tunnel-open-network')); + await _tap(tester, network); + final toggle = find.descendant( + of: find.byKey(const ValueKey('tunnel-ipv6-enabled')), + matching: find.byType(ShadSwitch), + ); + await _tap(tester, toggle); + expect(child!.value['ipv6Enabled'], isFalse); + expect(root.value['ipv6Enabled'], isTrue); + await _tap( + tester, + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ); + expect(root.value['ipv6Enabled'], isTrue); + await _tap(tester, network); + await _tap(tester, toggle); + await _tap(tester, find.widgetWithText(FilledButton, l.prototypeSave)); + expect(root.value['ipv6Enabled'], isFalse); + expect(service.saves, 2); + expect(service.loads, 2); + expect(find.text(l.tunnelIpv6Disabled), findsNWidgets(2)); + expect(opened, [ + TunnelDestination.dns, + TunnelDestination.dns, + TunnelDestination.network, + TunnelDestination.network, + ]); + expect(find.byType(PolicyActions), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); + } + + testWidgets( + 'restore keeps its complete scope as a draft until Save and Cancel reloads', + (tester) async { + _viewport(tester, const Size(390, 844)); + final service = _service(); + final policy = service.configuration.policy.toJson(); + policy['apple']['alwaysOn'] = true; + service.configuration = ConnectionConfiguration( + policy: PlatformPolicy.fromJson(policy), + ); + final root = PolicyEditorController( + draft: PolicyEditorDraft(service.configuration), + service: service, + ); + addTearDown(root.close); + await tester.pumpWidget( + _app(VpnTunnelPane(createController: () => root), 390), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(VpnTunnelPane)), + )!; + final restore = find.byKey(const ValueKey('tunnel-restore-defaults')); + expect(find.byType(PolicyActions), findsNothing); + await _tap(tester, restore); + expect(root.group('apple')['alwaysOn'], isFalse); + expect(root.value['dnsIpv4Address'], '8.8.8.8'); + expect( + service.configuration.policy.toJson()['apple']['alwaysOn'], + isTrue, + ); + expect(service.configuration.policy.dnsIpv4Address, '1.1.1.1'); + expect(service.saves, 0); + expect(find.byType(PolicyActions), findsOneWidget); + expect(find.text(l.settingsDefaultsRestored), findsOneWidget); + // The transient bottom toast occupies the footer until it times out. + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(find.text(l.settingsDefaultsRestored), findsNothing); + await _tap( + tester, + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ); + expect(root.group('apple')['alwaysOn'], isTrue); + expect(root.value['dnsIpv4Address'], '1.1.1.1'); + expect(find.byType(PolicyActions), findsNothing); + await _tap(tester, restore); + expect(find.text(l.settingsDefaultsRestored), findsOneWidget); + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(find.text(l.settingsDefaultsRestored), findsNothing); + await _tap(tester, find.widgetWithText(FilledButton, l.prototypeSave)); + expect(service.saves, 1); + expect(service.configuration.policy.dnsIpv4Address, '8.8.8.8'); + expect( + service.configuration.policy.toJson()['apple']['alwaysOn'], + isFalse, + ); + expect(find.byType(PolicyActions), findsNothing); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); + + testWidgets( + 'Windows network conflict keeps the existing validation and IPv6 DNS visible', + (tester) async { + _viewport(tester, const Size(390, 844)); + final service = _service(platform: ConnectionPlatform.windows); + final policy = service.configuration.policy.toJson(); + policy['windows']['excludedCidrs'] = ['fd00::/64']; + final draft = PolicyEditorDraft( + ConnectionConfiguration(policy: PlatformPolicy.fromJson(policy)), + ); + final controller = PolicyEditorController(draft: draft, service: service); + addTearDown(controller.close); + await tester.pumpWidget( + _app( + TunnelNetworkPage(draft: draft, createController: () => controller), + 390, + ), + ); + await tester.pumpAndSettle(); + await _tap(tester, find.byType(ShadSwitch)); + final l = AppLocalizations.of( + tester.element(find.byType(TunnelNetworkPage)), + )!; + expect(controller.ipv6Conflict, isTrue); + expect(find.text(l.prototypeIpv6BypassConflict), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(FilledButton, l.prototypeSave), + ) + .onPressed, + isNull, + ); + final dns = PolicyEditorController( + draft: controller.draft!.copy(), + service: service, + ); + addTearDown(dns.close); + await tester.pumpWidget( + _app( + TunnelDnsPage(draft: dns.draft!, createController: () => dns), + 390, + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('tunnel-dns-ipv6')), findsOneWidget); + expect(find.byKey(const ValueKey('tunnel-dns-over-tls')), findsNothing); + expect( + find.byKey(const ValueKey('tunnel-dns-server-name')), + findsNothing, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); +} + +class _UiService extends PolicyEditorService { + _UiService(ConnectionCoordinator coordinator, ConnectionPlatform platform) + : super( + coordinator: coordinator, + platform: platform, + windowsMode: WindowsMode.msix, + ) { + final policy = PlatformPolicy.defaults().toJson(); + policy['dnsIpv4Address'] = '1.1.1.1'; + policy['xrayOutboundInterfaceName'] = 'Ethernet'; + configuration = ConnectionConfiguration( + policy: PlatformPolicy.fromJson(policy), + ); + } + late ConnectionConfiguration configuration; + int saves = 0; + int loads = 0; + @override + Future load() async { + loads++; + return PolicyEditorDraft(configuration); + } + + @override + Future save({ + required PolicyEditorDraft draft, + required Future Function(bool disconnect) confirm, + }) async { + final prepared = prepare(draft); + configuration = ConnectionConfiguration( + connection: draft.original.connection, + policy: prepared.policy, + ); + saves++; + return true; + } +} diff --git a/test/pages/advanced/tunnel/windows_interface_test.dart b/test/pages/advanced/tunnel/windows_interface_test.dart index c3beaa59d..49cb2a2f2 100644 --- a/test/pages/advanced/tunnel/windows_interface_test.dart +++ b/test/pages/advanced/tunnel/windows_interface_test.dart @@ -81,7 +81,22 @@ void main() { FilledButton save() => tester.widget( find.widgetWithText(FilledButton, l.prototypeSave), ); - expect(find.byType(SettingSection), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is SettingSection && + widget.title == l.prototypeSystemVpnPolicy, + ), + findsOneWidget, + ); + expect( + find.byWidgetPredicate( + (widget) => + widget is SettingSection && + widget.title == l.prototypeBypassNetworks, + ), + findsOneWidget, + ); expect(find.text('0 / 64'), findsOneWidget); expect(find.text(l.prototypeNoBypassNetworks), findsOneWidget); expect(save().onPressed, isNull); @@ -96,9 +111,21 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l.prototypeNoBypassNetworks), findsNothing); expect(find.text('0 / 64'), findsOneWidget); + final cidrRow = tester.getRect( + find.byKey(const ValueKey('windows-cidr-row:0')), + ); + expect(cidrRow.height, greaterThanOrEqualTo(50)); + expect( + cidrRow.contains(tester.getCenter(find.byType(TextField))), + isTrue, + ); expect( - tester.getSize(find.byKey(const ValueKey('windows-cidr-row:0'))).height, - 50, + cidrRow.contains( + tester.getCenter( + find.byTooltip(l.prototypeRemoveBypassNetworkNumber(1)), + ), + ), + isTrue, ); final field = tester.widget(find.byType(TextField)); expect(field.textDirection, TextDirection.ltr); diff --git a/test/pages/advanced/xray/geodata/add_page_test.dart b/test/pages/advanced/xray/geodata/add_page_test.dart new file mode 100644 index 000000000..d32039323 --- /dev/null +++ b/test/pages/advanced/xray/geodata/add_page_test.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/advanced/xray/geodata/add.dart'; +import 'package:onexray/pages/advanced/xray/geodata/controller.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/advanced/xray/geodata/model.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +Finder _input(String name) => find.descendant( + of: find.byKey(ValueKey('geodata-source-$name')), + matching: find.byType(EditableText), +); + +Future _mount( + WidgetTester tester, + GeoDataController controller, { + ValueChanged? returned, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 640); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: ShadToaster(child: child!), + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-geodata-add'), + onPressed: () async { + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + GeoDataAddPage(createController: () => controller), + ), + ); + returned?.call(saved); + }, + child: const Text('Open source form'), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-geodata-add'))); + await tester.pumpAndSettle(); + return AppLocalizations.of(tester.element(find.byType(GeoDataAddPage)))!; +} + +Future _fill(WidgetTester tester, String url) async { + await tester.enterText(_input('name'), 'custom-routing'); + await tester.ensureVisible(_input('url')); + await tester.pumpAndSettle(); + await tester.enterText(_input('url'), url); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + final bus = AppEventBus(); + addTearDown(bus.close); + }); + testWidgets( + 'source form keeps the real HTTPS validation and cancellation is not a save', + (tester) async { + final controller = GeoDataController(); + addTearDown(controller.close); + var returned = false; + final l = await _mount( + tester, + controller, + returned: (value) => returned = value == true, + ); + await _fill(tester, 'http://example.test/custom.dat'); + await tester.tap(find.byKey(const ValueKey('geodata-source-add'))); + await tester.pumpAndSettle(); + expect(controller.state.formError, isNotNull); + expect(find.text(controller.state.formError!), findsOneWidget); + expect(controller.name.text, 'custom-routing'); + expect(controller.url.text, 'http://example.test/custom.dat'); + expect(controller.state.formBusy, isFalse); + expect(find.byType(GeoDataAddPage), findsOneWidget); + await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(find.byType(GeoDataAddPage), findsNothing); + expect(returned, isFalse); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'source form waits for add, retains a failure and returns true only after success', + (tester) async { + final controller = _ControlledAdd(); + addTearDown(controller.close); + bool? returned; + final l = await _mount( + tester, + controller, + returned: (value) => returned = value, + ); + await _fill(tester, 'https://example.test/custom.dat'); + final add = find.byKey(const ValueKey('geodata-source-add')); + await tester.tap(add); + await tester.pump(); + expect(controller.submitted!.fileName, 'custom-routing'); + expect(controller.submitted!.url, 'https://example.test/custom.dat'); + expect(tester.widget(add).onPressed, isNull); + expect( + tester + .widget( + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ) + .onPressed, + isNull, + ); + await tester.tap(find.byType(BackButton)); + await tester.pump(); + expect(find.byType(GeoDataAddPage), findsOneWidget); + controller.result.complete(false); + await tester.pumpAndSettle(); + expect(find.text('source-download-failed'), findsOneWidget); + expect(controller.name.text, 'custom-routing'); + expect(controller.url.text, 'https://example.test/custom.dat'); + expect(find.byType(GeoDataAddPage), findsOneWidget); + controller.result = Completer(); + await tester.tap(add); + await tester.pump(); + controller.result.complete(true); + await tester.pumpAndSettle(); + expect(find.byType(GeoDataAddPage), findsNothing); + expect(returned, isTrue); + expect(tester.takeException(), isNull); + }, + ); +} + +/// Only the completion boundary is controlled here; the first test exercises +/// the production controller's HTTPS validation before any database access. +class _ControlledAdd extends GeoDataController { + Completer result = Completer(); + GeoDataInput? submitted; + @override + Future add(BuildContext context) async { + if (state.formBusy) return; + submitted = GeoDataInput( + fileName: name.text, + type: state.type, + url: url.text, + ); + emit(state.copyWith(formBusy: true, clearFormError: true)); + final saved = await result.future; + if (saved) { + name.clear(); + url.clear(); + } + emit( + state.copyWith( + formBusy: false, + adding: !saved, + formError: saved ? null : 'source-download-failed', + clearFormError: saved, + ), + ); + } +} diff --git a/test/pages/advanced/xray/geodata/rows_test.dart b/test/pages/advanced/xray/geodata/rows_test.dart index 3b6fa41c7..f10783518 100644 --- a/test/pages/advanced/xray/geodata/rows_test.dart +++ b/test/pages/advanced/xray/geodata/rows_test.dart @@ -124,6 +124,7 @@ void main() { for (final (locale, width) in const [ (Locale('en'), 1160.0), + (Locale('en'), 1032.0), (Locale('zh'), 390.0), (Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), 390.0), (Locale('ru'), 1160.0), @@ -250,24 +251,12 @@ void main() { ), file.builtIn ? findsNothing : findsOneWidget, ); - if (width > 720) { - expect( - find.descendant( - of: group, - matching: find.text(l.prototypeAction), - ), - file.builtIn ? findsNothing : findsOneWidget, - ); - expect( - find.descendant( - of: group, - matching: find.text(l.prototypeFileName), - ), - findsOneWidget, - ); - } await tester.tap(find.text(file.fileName)); } + expect( + tester.getRect(find.text(custom.fileName)).top, + greaterThan(tester.getRect(find.text(builtIn.fileName)).bottom), + ); await tester.tap(find.text(l.prototypeUpdate)); await tester.tap(find.byTooltip(l.prototypeDeleteCustomDataset)); expect(actions, [ diff --git a/test/pages/advanced/xray/log_test.dart b/test/pages/advanced/xray/log_test.dart index fdf32f661..8d7534da4 100644 --- a/test/pages/advanced/xray/log_test.dart +++ b/test/pages/advanced/xray/log_test.dart @@ -6,9 +6,11 @@ import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; +import 'package:onexray/pages/advanced/xray/log/settings.dart'; import 'package:onexray/pages/advanced/xray/page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; @@ -26,7 +28,7 @@ void main() { (false, true), ]) { testWidgets( - 'Xray logs visibility: systemExtension=$systemExtension, mobile=$mobile', + 'Xray logs use an isolated page: systemExtension=$systemExtension, mobile=$mobile', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = mobile @@ -36,27 +38,31 @@ void main() { addTearDown(tester.view.resetPhysicalSize); final db = AppDatabase.forTesting(NativeDatabase.memory()); final coordinator = ConnectionCoordinator(database: db); - final controller = _Controller( + final root = _Controller( coordinator: coordinator, systemExtension: systemExtension, ); + Widget app(Widget child) => MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: mobile), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: mobile), + child: ShadToaster(child: child!), + ), + home: child, + ); await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: mobile), - locale: const Locale('en'), - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - builder: (_, child) => ShadTheme( - data: AppTheme.shad(Brightness.light, mobile: mobile), - child: ShadToaster(child: child!), - ), - home: XrayRuntimePage( - createController: () => controller, + app( + XrayRuntimePage( + createController: () => root, onGeodata: (_) {}, onUpdates: (_) {}, onSpeedTest: (_) {}, onLog: (_, _) {}, onConfig: (_, _) {}, + onLogSettings: (_) async => null, ), ), ); @@ -64,9 +70,14 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(XrayRuntimePage)), )!; - final visibility = systemExtension ? findsNothing : findsOneWidget; + expect(find.byType(PageActionBar), findsNothing); + expect( + find.byWidgetPredicate( + (w) => w is SettingRow && w.title == l.prototypeLogs, + ), + systemExtension ? findsNothing : findsOneWidget, + ); for (final text in [ - l.prototypeLogs, l.prototypeRecordXrayLogs, l.prototypeErrorLogLevel, l.prototypeRecordDnsQueries, @@ -76,9 +87,8 @@ void main() { l.prototypeRestoreDefaults, l.prototypeSave, ]) { - expect(find.text(text), visibility); + expect(find.text(text), findsNothing); } - expect(find.byType(PageActionBar), visibility); for (final text in [ l.prototypeRuntimeStatus, l.prototypeRoutingData, @@ -88,23 +98,87 @@ void main() { ]) { expect(find.text(text), findsOneWidget); } - if (!systemExtension) { - final original = controller.base; - expect(controller.dirty, isFalse); - await tester.tap(find.text(l.prototypeSave)); - await tester.pump(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + late _Controller detail; + var viewerCalls = 0; + await tester.pumpWidget( + app( + Builder( + builder: (context) => Scaffold( + body: TextButton( + key: const Key('open-log-settings'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => XrayLogSettingsPage( + createController: () => detail = _Controller( + coordinator: coordinator, + systemExtension: systemExtension, + ), + onLog: (_, _) => viewerCalls++, + ), + ), + ), + child: const Text('Open logs'), + ), + ), + ), + ), + ); + await tester.tap(find.byKey(const Key('open-log-settings'))); + await tester.pumpAndSettle(); + final visibility = systemExtension ? findsNothing : findsOneWidget; + for (final text in [ + l.prototypeRecordXrayLogs, + l.prototypeErrorLogLevel, + l.prototypeRecordDnsQueries, + l.prototypeHideLogIpAddresses, + l.prototypeAccessLog, + l.prototypeErrorLog, + l.prototypeRestoreDefaults, + l.prototypeSave, + ]) { + expect(find.text(text), visibility); + } + expect(find.byType(PageActionBar), visibility); + if (systemExtension) { + expect(find.text(l.logsUnavailableSystemExtension), findsOneWidget); + expect(viewerCalls, 0); + } else { + expect(detail.dirty, isFalse); + await tester.tap(find.widgetWithText(FilledButton, l.prototypeSave)); + await tester.pumpAndSettle(); expect(find.text(l.prototypeSettingsSaved), findsOneWidget); - expect(controller.saving, isFalse); + expect(find.byType(XrayLogSettingsPage), findsNothing); + expect(detail.saving, isFalse); await tester.pump(const Duration(seconds: 5)); await tester.pumpAndSettle(); - - await tester.tap(find.text(l.prototypeRestoreDefaults)); - await tester.pump(); + await tester.tap(find.byKey(const Key('open-log-settings'))); + await tester.pumpAndSettle(); + final original = detail.base; + final restore = find.text(l.prototypeRestoreDefaults); + await tester.ensureVisible(restore); + await tester.pumpAndSettle(); + await tester.tap(restore); + await tester.pumpAndSettle(); expect(find.text(l.settingsDefaultsRestored), findsOneWidget); expect(find.text(l.prototypeSettingsSaved), findsNothing); - expect(controller.logsEnabled, isFalse); - expect(controller.base, same(original)); - expect(controller.dirty, isTrue); + expect(detail.logsEnabled, isFalse); + expect(detail.base, same(original)); + expect(detail.dirty, isTrue); + // Wait for the existing bottom toast before using the footer. + await tester.pump(const Duration(seconds: 3)); + await tester.pumpAndSettle(); + expect(find.text(l.settingsDefaultsRestored), findsNothing); + await tester.tap( + find.widgetWithText(OutlinedButton, l.prototypeCancel), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('open-log-settings'))); + await tester.pumpAndSettle(); + expect(detail.logsEnabled, isTrue); + expect(detail.dirty, isFalse); } expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -118,9 +192,7 @@ void main() { class _Controller extends XrayRuntimeController { _Controller({required super.coordinator, required this.systemExtension}); - final bool systemExtension; - @override Future load({bool showLoading = true}) async { final configuration = ConnectionConfiguration( diff --git a/test/pages/advanced/xray/runtime_viewer_test.dart b/test/pages/advanced/xray/runtime_viewer_test.dart index 6fc540f65..2cc63aeaf 100644 --- a/test/pages/advanced/xray/runtime_viewer_test.dart +++ b/test/pages/advanced/xray/runtime_viewer_test.dart @@ -14,6 +14,7 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; const _panel = ValueKey('runtime-code-panel'); const _logLines = ValueKey('runtime-log-lines'); @@ -86,11 +87,7 @@ void main() { ); expect( export.top, - closeTo( - tester.getRect(find.byType(AppBar)).bottom + - AppSpacing.desktopPageTop, - .01, - ), + closeTo(tester.getRect(find.byType(PageAppBar)).bottom + 20, .01), ); expect(export.bottom + 12, closeTo(panel.top, .01)); expect(panel.height, greaterThanOrEqualTo(420)); @@ -131,7 +128,7 @@ void main() { final export = find.widgetWithText(OutlinedButton, l.prototypeExport); expect( tester.getSize(export).height, - greaterThanOrEqualTo(AppLayout.touchExtent(36)), + greaterThanOrEqualTo(AppLayout.touchExtent(48)), ); expect(tester.getRect(export).left, AppSpacing.mobilePage); expect(panel.width, 427 - AppSpacing.mobilePage * 2); @@ -147,10 +144,11 @@ void main() { ); final line = tester.widget(find.text('first line')); expect(line.textDirection, TextDirection.ltr); - expect(line.style!.fontSize, 12); + expect(line.style!.fontSize, 13); expect(line.style!.height, 1.45); expect( - tester.widget(find.byType(AppBar)).actions ?? const [], + tester.widget(find.byType(PageAppBar)).actions ?? + const [], isEmpty, ); expect(tester.takeException(), isNull); diff --git a/test/pages/connect/controller_test.dart b/test/pages/connect/controller_test.dart index e6aa28999..76ce6381c 100644 --- a/test/pages/connect/controller_test.dart +++ b/test/pages/connect/controller_test.dart @@ -814,9 +814,14 @@ void main() { find.widgetWithText(ChoiceChip, l.serverSelectionAutomaticScope), ); await tester.pumpAndSettle(); - await tester.tap( - find.widgetWithText(ListTile, l.prototypeManualAdditions), + final manualScope = find.widgetWithText( + ListTile, + l.prototypeManualAdditions, ); + await tester.ensureVisible(manualScope); + await tester.pumpAndSettle(); + expect(manualScope.hitTestable(), findsOneWidget); + await tester.tap(manualScope); await tester.pumpAndSettle(); await tester.tap(find.text('Apply and reconnect')); await tester.pumpAndSettle(); @@ -863,9 +868,14 @@ void main() { find.widgetWithText(ChoiceChip, l.serverSelectionAutomaticScope), ); await tester.pumpAndSettle(); - await tester.tap( - find.widgetWithText(ListTile, l.prototypeManualAdditions), + final manualScope = find.widgetWithText( + ListTile, + l.prototypeManualAdditions, ); + await tester.ensureVisible(manualScope); + await tester.pumpAndSettle(); + expect(manualScope.hitTestable(), findsOneWidget); + await tester.tap(manualScope); await tester.pumpAndSettle(); expect(coordinator.applyCount, 0); await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeCancel)); diff --git a/test/pages/connect/dialogs_test.dart b/test/pages/connect/dialogs_test.dart index b205b8555..b0341ae5a 100644 --- a/test/pages/connect/dialogs_test.dart +++ b/test/pages/connect/dialogs_test.dart @@ -87,7 +87,7 @@ void main() { }); for (final brightness in Brightness.values) { - testWidgets('bottom dialog paints its safe area in $brightness', ( + testWidgets('centered dialog respects the safe area in $brightness', ( tester, ) async { const size = Size(390, 844); @@ -138,15 +138,22 @@ void main() { matching: find.byType(Material), ) .first; - expect(tester.getRect(surface).bottom, size.height); + final bounds = tester.getRect(surface); + final usableHeight = size.height - bottomInset; + expect(bounds.width, size.width); + expect(bounds.center.dx, closeTo(size.width / 2, .01)); + expect(bounds.center.dy, closeTo(usableHeight / 2, .01)); + expect(bounds.top, greaterThanOrEqualTo(0)); + expect(bounds.bottom, lessThanOrEqualTo(usableHeight)); expect( tester.widget(surface).color, - AppColorTokens.fallback(brightness).palette.card, - ); - expect( - tester.getRect(find.byType(FilledButton)).bottom, - lessThanOrEqualTo(size.height - bottomInset), + AppColorTokens.fallback(brightness).palette.background, ); + final done = find.widgetWithText(FilledButton, 'Done'); + final actionBounds = tester.getRect(done); + expect(actionBounds.top, greaterThanOrEqualTo(bounds.top)); + expect(actionBounds.bottom, lessThanOrEqualTo(bounds.bottom - 24)); + expect(done.hitTestable(), findsOneWidget); expect(tester.takeException(), isNull); await tester.tap(find.text('Done')); await tester.pumpAndSettle(); diff --git a/test/pages/connect/routing/custom_ui_test.dart b/test/pages/connect/routing/custom_ui_test.dart index 00ac500f0..a87f2f962 100644 --- a/test/pages/connect/routing/custom_ui_test.dart +++ b/test/pages/connect/routing/custom_ui_test.dart @@ -30,14 +30,19 @@ Widget _app( Widget child, { Locale locale = const Locale('en'), bool mobile = true, + double textScale = 1, }) => MaterialApp( theme: AppTheme.material(Brightness.light, mobile: mobile), locale: locale, localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => ShadTheme( - data: AppTheme.shad(Brightness.light, mobile: mobile), - child: child!, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: mobile), + child: child!, + ), ), home: child, ); @@ -181,60 +186,68 @@ void main() { Locale('ru'), Locale('fa'), ]) { - testWidgets( - 'four-condition form fits phone and preserves input direction ($locale)', - (tester) async { - _phone(tester); - final controller = CustomRoutingRuleController( - loadIndex: () async => - const RoutingGeodataIndex(domainFiles: {}, ipFiles: {}), - ); - addTearDown(controller.close); - await tester.pumpWidget( - _app( - Scaffold( - body: SingleChildScrollView( - padding: const EdgeInsets.all(14), - child: CustomRoutingRuleForm(controller: controller), + for (final textScale in [1.0, 1.5]) { + testWidgets( + 'four-condition form fits phone and preserves input direction ($locale / $textScale)', + (tester) async { + _phone(tester); + final controller = CustomRoutingRuleController( + loadIndex: () async => + const RoutingGeodataIndex(domainFiles: {}, ipFiles: {}), + ); + addTearDown(controller.close); + await tester.pumpWidget( + _app( + Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(14), + child: CustomRoutingRuleForm(controller: controller), + ), ), + locale: locale, + textScale: textScale, ), - locale: locale, - ), - ); - await tester.pumpAndSettle(); - final l = AppLocalizations.of( - tester.element(find.byType(CustomRoutingRuleForm)), - )!; - for (final title in [ - l.prototypeWebsitesDomains, - l.prototypeIpAddressesRanges, - l.prototypeTargetPort, - ]) { - await tester.ensureVisible(find.text(title)); - await tester.tap(find.text(title)); - await tester.pumpAndSettle(); - } - for (final value in [ - controller.domains.single.text, - controller.ips.single.text, - controller.port, - ]) { - final editable = find.descendant( - of: _input(value), - matching: find.byType(EditableText), ); - expect( - Directionality.of(tester.element(editable)), - TextDirection.ltr, - ); - expect(tester.widget(editable).style.fontSize, 16); - } - controller.setAction(RoutingRuleAction.block); - await tester.pumpAndSettle(); - expect(find.text(l.prototypeVpnRuleHint), findsNothing); - expect(tester.takeException(), isNull); - }, - ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(CustomRoutingRuleForm)), + )!; + for (final title in [ + l.prototypeWebsitesDomains, + l.prototypeIpAddressesRanges, + l.prototypeTargetPort, + ]) { + await tester.ensureVisible(find.text(title)); + await tester.tap(find.text(title)); + await tester.pumpAndSettle(); + } + for (final value in [ + controller.domains.single.text, + controller.ips.single.text, + controller.port, + ]) { + final editable = find.descendant( + of: _input(value), + matching: find.byType(EditableText), + ); + expect( + Directionality.of(tester.element(editable)), + TextDirection.ltr, + ); + final field = tester.widget(editable); + expect(field.style.fontSize, 14); + final effectiveScaler = + field.textScaler ?? + MediaQuery.textScalerOf(tester.element(editable)); + expect(effectiveScaler.scale(14), closeTo(14 * textScale, .01)); + } + controller.setAction(RoutingRuleAction.block); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeVpnRuleHint), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } } testWidgets( diff --git a/test/pages/connect/routing/root_page_test.dart b/test/pages/connect/routing/root_page_test.dart index f236b9c8b..07b89ef69 100644 --- a/test/pages/connect/routing/root_page_test.dart +++ b/test/pages/connect/routing/root_page_test.dart @@ -100,8 +100,11 @@ void main() { await tester.pumpAndSettle().timeout(_waitTimeout); } - ShadButton useButton(WidgetTester tester, String key) => - tester.widget(find.byKey(ValueKey(key))); + bool useEnabled(WidgetTester tester, String key) { + final widget = tester.widget(find.byKey(ValueKey(key))); + // Selected routes have a status badge instead of a disabled action. + return widget is ShadButton && widget.enabled; + } testWidgets( 'broken and readable routes coexist while Smart and All remain usable', @@ -121,12 +124,9 @@ void main() { expect(controller!.configurationFor(originalGood), isNotNull); expect(find.text('Good route'), findsOneWidget); expect(find.text('Broken route'), findsOneWidget); - expect( - useButton(tester, 'routing-use-profile-$brokenId').enabled, - isFalse, - ); - expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isTrue); - expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-profile-$brokenId'), isFalse); + expect(useEnabled(tester, 'routing-use-profile-$goodId'), isTrue); + expect(useEnabled(tester, 'routing-use-smart'), isTrue); final l = AppLocalizations.of(tester.element(find.byType(RoutingPage)))!; final broken = find.byKey(ValueKey('routing-profile-$brokenId')); expect( @@ -159,7 +159,7 @@ void main() { ))!.connection.trafficMode, TrafficMode.smart, ); - expect(useButton(tester, 'routing-use-all').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-all'), isTrue); await tester.runAsync(() async { await tester.tap(find.byKey(const ValueKey('routing-use-all'))); await waitFor( @@ -200,14 +200,14 @@ void main() { await tester.pumpAndSettle().timeout(_waitTimeout); expect(controller!.state.failed, isFalse); expect(controller!.state.profiles, hasLength(2)); - expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isFalse); - expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-profile-$goodId'), isFalse); + expect(useEnabled(tester, 'routing-use-smart'), isTrue); await tester.runAsync(() async { await db.routingProfileDao.updateRow(originalGood); await waitFor((s) => !s.profileErrors.containsKey(goodId)); }); await tester.pumpAndSettle().timeout(_waitTimeout); - expect(useButton(tester, 'routing-use-profile-$goodId').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-profile-$goodId'), isTrue); expect(controller!.state.profileErrors.keys, [brokenId]); expect(tester.takeException(), isNull); }, @@ -238,7 +238,7 @@ void main() { }); await tester.pumpAndSettle().timeout(_waitTimeout); expect(find.text('Good route'), findsOneWidget); - expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-smart'), isTrue); expect(tester.takeException(), isNull); }); @@ -310,7 +310,7 @@ void main() { }); await pumpPage(tester); final context = tester.element(find.byType(RoutingPage)); - expect(useButton(tester, 'routing-use-smart').enabled, isTrue); + expect(useEnabled(tester, 'routing-use-smart'), isTrue); await tester.runAsync(() async { try { await controller! diff --git a/test/pages/connect/routing/smart/exit_picker_test.dart b/test/pages/connect/routing/smart/exit_picker_test.dart index 2547a049f..c2b547b95 100644 --- a/test/pages/connect/routing/smart/exit_picker_test.dart +++ b/test/pages/connect/routing/smart/exit_picker_test.dart @@ -16,6 +16,7 @@ import 'package:onexray/pages/connect/routing/smart/exit_picker.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/outbound/map.dart'; @@ -204,8 +205,8 @@ void main() { expect(find.text(l.prototypeAddServers), findsNothing); expect(find.text(l.prototypeManageSources), findsNothing); expect(find.text(l.prototypeFavorites), findsNothing); - expect(find.text('VLESS | XHTTP | TLS'), findsNWidgets(4)); - expect(find.text('outbound'), findsNWidgets(4)); + expect(find.textContaining('VLESS | XHTTP | TLS'), findsNWidgets(4)); + expect(find.textContaining('outbound'), findsNWidgets(4)); expect(find.text(l.countryRegionName('SG')), findsNothing); await tester.tap(find.text('Server 3')); await tester.pumpAndSettle(); @@ -248,7 +249,7 @@ void main() { expect(controller.selectedId, 4); expect(results, isEmpty); expect( - find.text( + find.textContaining( delay == PingDelayConstants.unknown ? l.prototypeNotTested : l.serverLatencyFailed, @@ -273,7 +274,7 @@ void main() { expect(tester.getCenter(card).dx, 580); expect( tester.getTopLeft(card).dy, - kToolbarHeight + AppSpacing.desktopPageTop, + tester.getBottomLeft(find.byType(PageAppBar)).dy + 16, ); expect( tester.widget(find.byType(PageActionBar)).maxWidth, diff --git a/test/pages/connect/target_picker_test.dart b/test/pages/connect/target_picker_test.dart index 037e77261..01cb178ca 100644 --- a/test/pages/connect/target_picker_test.dart +++ b/test/pages/connect/target_picker_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/constants.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -9,6 +10,7 @@ import 'package:onexray/l10n/localizations/app_localizations_en.dart'; import 'package:onexray/pages/connect/target_picker.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:onexray/service/settings/language/locale.dart'; @@ -86,6 +88,7 @@ void main() { localizationsDelegates: AppLocalePolicy.localizationsDelegates, builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( + size: size, viewInsets: viewInsets, textScaler: TextScaler.linear(textScale), ), @@ -188,6 +191,72 @@ void main() { await tester.pumpAndSettle(); } + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets('target picker uses prototype selection roles at $size', ( + tester, + ) async { + await open( + tester, + ConnectionSettings(selection: const ServerSelection.source(7)), + size: size, + ); + final active = tile('Duplicate'); + final inactive = tile(l.prototypeAutomaticSelection); + await reveal(tester, active); + final activeTile = tester.widget(active); + expect((activeTile.title! as Text).style!.fontSize, 14); + expect((activeTile.title! as Text).style!.fontWeight, FontWeight.w600); + expect((activeTile.subtitle! as Text).style!.fontSize, 13); + expect((activeTile.trailing! as Icon).icon, LucideIcons.circleCheck); + expect((activeTile.trailing! as Icon).size, 18); + await reveal(tester, inactive); + final inactiveTile = tester.widget(inactive); + expect((inactiveTile.title! as Text).style!.fontWeight, FontWeight.w500); + final surface = tester.widget( + find.ancestor(of: inactive, matching: find.byType(Material)).first, + ); + expect(surface.color, Colors.transparent); + expect((surface.shape! as RoundedRectangleBorder).side, BorderSide.none); + final pickerSurface = tester.widget( + find + .descendant( + of: find.byType(ConnectionTargetPicker), + matching: find.byType(Material), + ) + .first, + ); + expect( + pickerSurface.color, + ColorManager.palette(tester.element(inactive)).background, + ); + expect( + pickerSurface.borderRadius, + BorderRadius.circular(size.width < 640 ? 0 : 8), + ); + for (final chip in tester.widgetList( + find.byType(ChoiceChip), + )) { + expect(chip.labelStyle!.fontSize, 14); + expect(chip.labelStyle!.fontWeight, FontWeight.w600); + } + final firstTab = find.widgetWithText( + ChoiceChip, + l.serverSelectionAutomaticScope, + ); + await reveal(tester, firstTab); + expect(tester.getSize(firstTab).height, greaterThanOrEqualTo(48)); + await tester.tap(find.widgetWithText(ShadButton, l.prototypeCancel)); + await tester.pumpAndSettle(); + expect(choice, isNull); + expect(completions, 1); + expect(tester.takeException(), isNull); + }); + } + testWidgets( 'source ID collisions keep automatic scope selected by kind and ID', (tester) async { diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index 8d4f21e98..869d2a689 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -121,7 +121,14 @@ void main() { ); await tester.tap(title); expect(choices, 0); - expect(find.text('App routing preferences are kept.'), findsOneWidget); + final explanation = find.textContaining( + 'App routing preferences are kept.', + ); + expect(explanation, findsOneWidget); + expect( + tester.renderObject(explanation).didExceedMaxLines, + isFalse, + ); expect(tester.takeException(), isNull); } }, @@ -269,7 +276,7 @@ void main() { ConnectionPhase.disconnecting => l.prototypePleaseWait, ConnectionPhase.failed => l.prototypeTryAgain, }; - expect(find.text(title), findsOneWidget); + expect(find.text(title), findsNWidgets(2)); expect( tester .widget(find.widgetWithText(FilledButton, action)) @@ -462,7 +469,7 @@ void main() { ); expect(find.text(l.prototypeConnectionFailed), findsNothing); expect(find.text(l.prototypeCheckNetwork), findsNothing); - expect(find.text(l.prototypeConnected), findsOneWidget); + expect(find.text(l.prototypeConnected), findsNWidgets(2)); expect(tester.takeException(), isNull); }, ); diff --git a/test/pages/launch/setup_view_test.dart b/test/pages/launch/setup_view_test.dart index ad569d799..054b397bb 100644 --- a/test/pages/launch/setup_view_test.dart +++ b/test/pages/launch/setup_view_test.dart @@ -3,7 +3,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/launch/setup/page.dart'; -import 'package:onexray/pages/launch/setup/widgets.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/launch/setup.dart'; @@ -59,14 +58,21 @@ void main() { ); await tester.pumpAndSettle(); final l = AppLocalizations.of(tester.element(find.byType(SetupView)))!; - for (final element in find.byType(SetupPoint).evaluate()) { - final bounds = tester.getRect( - find.byElementPredicate((item) => item == element), + final card = find.byType(ShadCard); + expect(card, findsOneWidget); + for (final text in [ + l.prototypeWelcome, + l.prototypeNoDataCollection, + l.prototypeBringOwnServers, + l.prototypeAgreeAndContinue, + ]) { + expect( + find.descendant(of: card, matching: find.text(text)), + findsOneWidget, ); - expect(bounds.left, greaterThanOrEqualTo(24)); - expect(bounds.right, lessThanOrEqualTo(366)); - expect(bounds.center.dx, closeTo(195, .1)); } + expect(tester.getRect(card).left, greaterThanOrEqualTo(24)); + expect(tester.getRect(card).right, lessThanOrEqualTo(366)); await tester.ensureVisible(find.text(l.prototypePrivacyPolicy)); await tester.tap(find.text(l.prototypePrivacyPolicy)); await tester.tap(find.text(l.prototypeAgreeAndContinue)); @@ -103,15 +109,21 @@ void main() { expect(find.text(l.prototypeSetUpVpn), findsNothing); expect(find.text(l.prototypeAddServers), findsNothing); expect(find.text(l.prototypeVpnPermission), findsNothing); - if (mobile) expect(find.text('2 / 2'), findsOneWidget); - - final footerBefore = tester.getRect(find.byType(SetupFooter)); + expect(find.byType(ShadCard), findsOneWidget); + expect( + find.descendant( + of: find.byType(ShadCard), + matching: find.text(l.prototypeGoToHome), + ), + findsOneWidget, + ); await tester.drag( find.byType(SingleChildScrollView), const Offset(0, -300), ); await tester.pumpAndSettle(); - expect(tester.getRect(find.byType(SetupFooter)), footerBefore); + await tester.ensureVisible(find.text(l.prototypeGoToHome)); + await tester.pumpAndSettle(); await tester.tap(find.text(l.prototypeGoToHome)); expect(actions, [SetupAction.finish]); expect(tester.takeException(), isNull); diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index 58f26147a..7405284b5 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -8,11 +8,255 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/main/desktop_window.dart'; import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/advanced/xray/geodata/model.dart'; import 'package:onexray/service/settings/language/locale.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; void main() { + for (final (width, height, scale, locale) in const [ + (390.0, 844.0, 1.0, Locale('zh')), + (1032.0, 1376.0, 1.0, Locale('zh')), + (1160.0, 720.0, 1.0, Locale('zh')), + (390.0, 844.0, 2.0, Locale('ru')), + (390.0, 844.0, 2.0, Locale('fa')), + ]) { + testWidgets( + 'page header preserves title, description and actions at $width/$scale/$locale', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, height); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var actions = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: width <= 720), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: Directionality( + textDirection: locale.languageCode == 'fa' + ? TextDirection.rtl + : TextDirection.ltr, + child: child!, + ), + ), + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('Настройки подключения'), + description: + 'Параметры соединения и данные этого устройства.', + actions: [ + IconButton( + key: const Key('page-action'), + icon: const Icon(Icons.add), + onPressed: () => actions++, + ), + ], + ), + body: const SizedBox( + key: Key('page-body'), + width: double.infinity, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final title = find.text('Настройки подключения'); + final description = find.text( + 'Параметры соединения и данные этого устройства.', + ); + expect(title, findsOneWidget); + expect(description, findsOneWidget); + expect( + tester.getRect(description).top, + greaterThanOrEqualTo(tester.getRect(title).bottom), + ); + expect( + tester.getRect(find.byKey(const Key('page-body'))).top, + greaterThanOrEqualTo(tester.getRect(description).bottom), + ); + expect( + tester.getRect(title).left, + greaterThanOrEqualTo(width <= 720 ? 16 : 28), + ); + expect( + tester.getRect(title).right, + lessThanOrEqualTo(width - (width <= 720 ? 16 : 28)), + ); + expect( + find.ancestor( + of: title, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.header == true, + ), + ), + findsOneWidget, + ); + await tester.tap(find.byKey(const Key('page-action'))); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }, + ); + } + + for (final height in [844.0, 360.0]) { + testWidgets( + 'long legal resource heading keeps content and footer reachable at 390x$height/2x', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(390, height); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fileName = '${List.filled(176, 'a').join()}.dat'; + expect(fileName.length, 180); + expect(GeoDataInput.canonicalFileName(fileName), fileName); + final controller = TextEditingController(); + addTearDown(controller.dispose); + var headingActions = 0; + var bodyActions = 0; + var saves = 0; + var backs = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: child!, + ), + ), + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(fileName), + description: 'Resource source and update settings.', + leading: BackButton(onPressed: () => backs++), + headingAction: TextButton( + key: const Key('resource-heading-action'), + onPressed: () => headingActions++, + child: const Text('Update'), + ), + ), + body: ListView( + key: const Key('resource-body'), + padding: const EdgeInsets.all(16), + children: [ + TextField( + key: const Key('resource-source'), + controller: controller, + decoration: const InputDecoration(labelText: 'Source'), + ), + const SizedBox(height: 160), + TextButton( + key: const Key('resource-body-action'), + onPressed: () => bodyActions++, + child: const Text('Details'), + ), + ], + ), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton( + key: const Key('resource-save'), + onPressed: () => saves++, + child: const Text('Save'), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final heading = find.byKey(const ValueKey('page-heading')); + final title = find.descendant( + of: heading, + matching: find.text(fileName), + ); + expect(title, findsOneWidget); + expect(tester.widget(title).maxLines, isNull); + expect( + tester.widget(title).overflow, + isNot(TextOverflow.ellipsis), + ); + final headingScrollable = find.descendant( + of: heading, + matching: find.byType(Scrollable), + ); + expect( + tester + .state(headingScrollable) + .position + .maxScrollExtent, + greaterThan(0), + ); + final body = find.byKey(const Key('resource-body')); + expect(tester.getSize(body).height, greaterThanOrEqualTo(80)); + final footer = tester.getRect(find.byType(PageActionBar)); + expect(footer.top, greaterThanOrEqualTo(tester.getRect(body).bottom)); + expect(footer.bottom, lessThanOrEqualTo(height)); + + await tester.scrollUntilVisible( + find.byKey(const Key('resource-heading-action')), + 120, + scrollable: headingScrollable, + maxScrolls: 60, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('resource-heading-action'))); + await tester.pumpAndSettle(); + expect(headingActions, 1); + + final source = find.byKey(const Key('resource-source')); + await tester.ensureVisible(source); + await tester.pumpAndSettle(); + await tester.enterText(source, 'https://example.test/resource.dat'); + await tester.pumpAndSettle(); + final bodyScrollable = find.descendant( + of: body, + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + widget.axisDirection == AxisDirection.down, + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('resource-body-action')), + 100, + scrollable: bodyScrollable, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('resource-body-action'))); + await tester.tap(find.byKey(const Key('resource-save'))); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(bodyActions, 1); + expect(saves, 1); + expect(backs, 1); + expect(controller.text, 'https://example.test/resource.dat'); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('imperative routes under GoRouter keep page bars responsive', ( tester, ) async { @@ -266,6 +510,9 @@ void main() { await tester.pumpWidget( MaterialApp( navigatorKey: navigator, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, theme: AppTheme.light.copyWith(platform: TargetPlatform.macOS), builder: (context, child) => MacOSWindowInsets( titlebarHeight: 28, @@ -274,16 +521,19 @@ void main() { child: child!, ), ), - home: Scaffold( - appBar: PageAppBar( - title: const Text('Root'), - actions: [ - IconButton( - key: rootAction, - onPressed: () {}, - icon: const Icon(Icons.add), - ), - ], + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('Root'), + actions: [ + IconButton( + key: rootAction, + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ], + ), ), ), ), @@ -291,8 +541,9 @@ void main() { await tester.pump(const Duration(milliseconds: 20)); navigator.currentState!.push( MaterialPageRoute( - builder: (_) => Scaffold( - appBar: PageAppBar( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, title: const Text('Detail'), actions: [ IconButton( diff --git a/test/pages/servers/manual_groups_test.dart b/test/pages/servers/manual_groups_test.dart index acf6eef99..61fb83c8a 100644 --- a/test/pages/servers/manual_groups_test.dart +++ b/test/pages/servers/manual_groups_test.dart @@ -410,7 +410,11 @@ void main() { final l = AppLocalizations.of(tester.element(find.byType(ServersPage)))!; final add = find.byKey(const ValueKey('servers-add')); expect(add.hitTestable(), findsOneWidget); - expect(tester.widget(add).tooltip, l.prototypeAddServer); + expect(tester.widget(add).onPressed, isNotNull); + expect( + find.descendant(of: add, matching: find.text(l.prototypeAddServer)), + findsOneWidget, + ); expect(find.byTooltip(l.manualGroupNew), findsNothing); expect( tester.getSize(find.text(l.prototypeServers)).width, diff --git a/test/pages/servers/menus_test.dart b/test/pages/servers/menus_test.dart index 54257b2a8..208e5701c 100644 --- a/test/pages/servers/menus_test.dart +++ b/test/pages/servers/menus_test.dart @@ -104,7 +104,7 @@ void main() { Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'), Locale('fa'), ]) { - testWidgets('$locale menu fits and paints the bottom safe area', ( + testWidgets('$locale menu is centered within the safe area', ( tester, ) async { await _pumpMenu(tester, locale: locale); @@ -119,15 +119,27 @@ void main() { final bounds = tester.getRect(surface); expect(MediaQuery.sizeOf(tester.element(menu)), _size); expect(bounds.width, _size.width); - expect(bounds.bottom, _size.height); - expect(tester.widget(surface).color, AppPalette.light.card); + final usableHeight = _size.height - _bottomInset; + expect(bounds.center.dx, closeTo(_size.width / 2, .01)); + expect(bounds.center.dy, closeTo(usableHeight / 2, .01)); + expect(bounds.top, greaterThanOrEqualTo(0)); + expect(bounds.bottom, lessThanOrEqualTo(usableHeight)); + expect( + tester.widget(surface).color, + AppPalette.light.background, + ); final lastRow = find .ancestor( of: find.text(l.prototypeDelete), matching: find.byType(InkWell), ) .first; - expect(tester.getRect(lastRow).bottom, _size.height - _bottomInset); + expect(tester.getRect(lastRow).bottom, closeTo(bounds.bottom - 24, .01)); + expect(lastRow.hitTestable(), findsOneWidget); + expect( + find.byTooltip(l.prototypeCloseDialog).hitTestable(), + findsOneWidget, + ); expect( Directionality.of(tester.element(menu)), locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, @@ -140,10 +152,7 @@ void main() { expect(textBounds.left, greaterThanOrEqualTo(bounds.left)); expect(textBounds.right, lessThanOrEqualTo(bounds.right)); expect(textBounds.top, greaterThanOrEqualTo(bounds.top)); - expect( - textBounds.bottom, - lessThanOrEqualTo(_size.height - _bottomInset), - ); + expect(textBounds.bottom, lessThanOrEqualTo(bounds.bottom)); } expect(tester.takeException(), isNull); }); diff --git a/test/pages/settings/appearance_startup_update_test.dart b/test/pages/settings/appearance_startup_update_test.dart index 3e4c8932a..cbd097e98 100644 --- a/test/pages/settings/appearance_startup_update_test.dart +++ b/test/pages/settings/appearance_startup_update_test.dart @@ -16,6 +16,7 @@ import 'package:onexray/pages/settings/app_update/controller.dart'; import 'package:onexray/pages/settings/desktop/controller.dart'; import 'package:onexray/pages/settings/desktop/page.dart'; import 'package:onexray/pages/settings/page.dart'; +import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/settings/theme/page.dart'; import 'package:onexray/pages/theme/theme.dart'; @@ -120,12 +121,157 @@ void main() { expect(tester.takeException(), isNull); }); - for (final (locale, width, scale, vertical) in const [ - (Locale('en'), 390.0, 1.0, true), - (Locale('en'), 390.0, 2.0, true), - (Locale('ru'), 390.0, 2.0, true), - (Locale('en'), 834.0, 2.0, false), - (Locale('fa'), 390.0, 2.0, true), + testWidgets('startup approval actions remain reachable and independent', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1160, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + bool? login; + var opened = 0; + await tester.pumpWidget( + app( + DesktopSettingsView( + state: const DesktopSettingsPageState( + launchAtLogin: LaunchAtLoginStatus( + LaunchAtLoginState.requiresApproval, + ), + loading: false, + ), + showMacOSOptions: true, + onLaunchAtLoginChanged: (value) => login = value, + onStartHiddenChanged: (_) {}, + onHideDockIconChanged: (_) {}, + onOpenSystemSettings: () => opened++, + ), + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(DesktopSettingsView)), + )!; + expect( + tester.widget(find.byType(ShadSwitch).first).enabled, + isFalse, + ); + await tester.tap(find.text(l.prototypeCancelRequest)); + await tester.pump(); + await tester.tap(find.text(l.prototypeOpenSystemSettings)); + await tester.pump(); + expect(login, isFalse); + expect(opened, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'mobile startup page persists App launch and refreshes its summary', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + final previousPreferences = SharedPreferencesAsyncPlatform.instance; + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + addTearDown( + () => SharedPreferencesAsyncPlatform.instance = previousPreferences, + ); + await PreferencesKey().saveConnectOnAppLaunch(false); + const iconChannel = BasicMessageChannel( + 'dev.flutter.pigeon.onexray.BridgeHostApi.getCurrentAppIcon', + BridgeHostApi.pigeonChannelCodec, + ); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockDecodedMessageHandler( + iconChannel, + (_) async => ['IconBlue'], + ); + addTearDown( + () => messenger.setMockDecodedMessageHandler(iconChannel, null), + ); + final bus = AppEventBus(); + addTearDown(bus.close); + final router = GoRouter( + initialLocation: '/settings', + routes: [ + GoRoute( + path: '/settings', + builder: (_, _) => const SettingsPage(), + routes: [ + GoRoute( + path: 'desktop-settings', + builder: (_, _) => + const DesktopSettingsPage(showDesktopOptions: false), + ), + ], + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + BlocProvider.value( + value: bus, + child: MaterialApp.router( + routerConfig: router, + theme: AppTheme.material(Brightness.light, mobile: true), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: ShadToaster(child: child!), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'settings root before startup', + ); + final l = AppLocalizations.of(tester.element(find.byType(SettingsPage)))!; + Finder startup() => find.byWidgetPredicate( + (widget) => widget is SettingRow && widget.title == l.prototypeStartup, + ); + await tester.ensureVisible(startup()); + await tester.pumpAndSettle(); + await tester.tap(startup()); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'independent startup page', + ); + expect(find.byType(DesktopSettingsPage), findsOneWidget); + expect(find.byType(ShadSwitch), findsOneWidget); + await tester.tap(find.byType(ShadSwitch)); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'startup after preference saved', + ); + expect(await PreferencesKey().readConnectOnAppLaunch(), isTrue); + router.pop(); + await tester.pumpAndSettle(); + expect( + tester.widget(startup()).value, + l.prototypeConnectAfterAppLaunch, + ); + expect(find.byType(ShadSwitch), findsNothing); + expect( + tester.takeException(), + isNull, + reason: 'settings root after startup return', + ); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + + for (final (locale, width, scale) in const [ + (Locale('en'), 390.0, 1.0), + (Locale('en'), 390.0, 2.0), + (Locale('ru'), 390.0, 2.0), + (Locale('en'), 1032.0, 2.0), + (Locale('fa'), 390.0, 2.0), ]) { testWidgets( 'settings appearance adapts to labels at $locale / $width / $scale', @@ -196,15 +342,23 @@ void main() { final system = find.text(l.prototypeSystem); final light = find.text(l.prototypeLight); final dark = find.text(l.prototypeDark); - final systemRect = tester.getRect(system); - final lightRect = tester.getRect(light); - final darkRect = tester.getRect(dark); - if (vertical) { - expect(lightRect.top, greaterThanOrEqualTo(systemRect.bottom)); - expect(darkRect.top, greaterThanOrEqualTo(lightRect.bottom)); - } else { - expect(lightRect.center.dy, systemRect.center.dy); - expect(darkRect.center.dy, systemRect.center.dy); + final options = find.byType(SettingsThemeOptions); + expect( + find.descendant(of: options, matching: find.byType(Wrap)), + findsOneWidget, + ); + final rects = [system, light, dark].map(tester.getRect).toList(); + for (var i = 0; i < rects.length; i++) { + for (var j = i + 1; j < rects.length; j++) { + expect(rects[i].overlaps(rects[j]), isFalse); + } + } + for (final label in [system, light, dark]) { + final button = find.ancestor( + of: label, + matching: find.byType(ShadButton), + ); + expect(tester.getSize(button).height, greaterThanOrEqualTo(48)); } final selected = find.ancestor( of: dark, @@ -234,14 +388,9 @@ void main() { widget is SettingRow && widget.title == l.prototypeAppIcon, ), ); - if (scale == 1) { - expect(iconRow.value, isNotNull); - expect(iconRow.subtitleWidget, isNull); - } else { - expect(iconRow.value, isNull); - expect(iconRow.subtitleWidget, isA()); - expect((iconRow.subtitleWidget! as Text).maxLines, isNull); - } + expect(iconRow.value, isNull); + expect(iconRow.subtitle, isNotEmpty); + expect(iconRow.subtitleWidget, isNull); } expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); @@ -253,7 +402,7 @@ void main() { ); } - testWidgets('theme choice uses the shared selected indicator', ( + testWidgets('theme choice uses the prototype selected button', ( tester, ) async { await tester.binding.setSurfaceSize(const Size(390, 700)); @@ -269,7 +418,21 @@ void main() { )!; expect(find.text(l.themePageSystem), findsOneWidget); expect(find.text(l.themePageDark), findsOneWidget); - expect(find.byIcon(LucideIcons.check), findsOneWidget); + final selected = find.ancestor( + of: find.text(l.prototypeDark), + matching: find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.selected == true, + ), + ); + expect(selected, findsOneWidget); + expect( + tester + .widget( + find.descendant(of: selected, matching: find.byType(ShadButton)), + ) + .variant, + ShadButtonVariant.primary, + ); expect(tester.takeException(), isNull); }); diff --git a/test/pages/settings/backup_test.dart b/test/pages/settings/backup_test.dart index d01f5f355..65ec02bce 100644 --- a/test/pages/settings/backup_test.dart +++ b/test/pages/settings/backup_test.dart @@ -109,6 +109,16 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(PageActionBar)), )!; + Future tapVisible(Finder target) async { + // Binding changes can reflow long labels and retain a scroll offset. + await tester.ensureVisible(target); + // Reveal uses a zero-duration jump. Flush its new geometry without + // waiting for the busy spinner behind a pending confirmation. + await tester.pump(); + expect(target.hitTestable(), findsOneWidget); + await tester.tap(target.hitTestable()); + } + expect(find.text(l.backupChooseExisting), findsOneWidget); expect(find.text(l.backupCreateFile), findsOneWidget); for (final (label, request, menu) in [ @@ -127,12 +137,11 @@ void main() { ); storage.bytes = null; if (menu) { - await tester.tap(find.text(l.backupSwitchToLocal)); + await tapVisible(find.text(l.backupSwitchToLocal)); await tester.pumpAndSettle(); } - await tester.ensureVisible(find.text(label)); final expectedWrites = storage.writes + 1; - await tester.tap(find.text(label)); + await tapVisible(find.text(label)); await pumpUntil( tester, () => @@ -157,18 +166,18 @@ void main() { final actions = find.descendant( of: find.byType(SettingSection).first, matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton, + (widget) => widget is ShadButton, ), ); expect(actions, findsNWidgets(2)); } - await tester.tap(find.text(l.backupUnbind)); + await tapVisible(find.text(l.backupUnbind)); await pumpUntil( tester, () => find.byType(AppConfirmationDialog).evaluate().isNotEmpty, ); await tester.pump(const Duration(milliseconds: 400)); - await tester.tap( + await tapVisible( find.descendant( of: find.byType(AppConfirmationDialog), matching: find.widgetWithText(FilledButton, l.backupUnbind), @@ -220,7 +229,13 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(PageActionBar)), )!; - expect(find.text(l.backupTitle), findsOneWidget); + expect( + find.descendant( + of: find.byKey(const ValueKey('page-heading')), + matching: find.text(l.backupTitle), + ), + findsOneWidget, + ); expect(find.text(l.backupAutomatic), findsOneWidget); expect(storage.writes, 0); final sections = find.byType(SettingSection); diff --git a/test/pages/settings/cleanup_test.dart b/test/pages/settings/cleanup_test.dart index de43c28ba..96c9a635e 100644 --- a/test/pages/settings/cleanup_test.dart +++ b/test/pages/settings/cleanup_test.dart @@ -95,9 +95,8 @@ void main() { (widget) => widget is SettingRow && widget.title == title, ); final sections = [ - l.prototypeAppearance, - l.prototypeLanguage, - l.prototypeStartup, + l.settingsAppearanceAndLanguage, + l.settingsStartupAndSystem, l.prototypeData, l.prototypeAbout, ].map((title) => tester.getRect(section(title))).toList(); diff --git a/test/pages/settings/details_test.dart b/test/pages/settings/details_test.dart index f236a984e..66e4d7543 100644 --- a/test/pages/settings/details_test.dart +++ b/test/pages/settings/details_test.dart @@ -18,7 +18,7 @@ void main() { localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, builder: (context, child) => ShadTheme( - data: ShadThemeData(colorScheme: const ShadBlueColorScheme.light()), + data: AppTheme.shad(Brightness.light, mobile: true), child: child ?? const SizedBox.shrink(), ), home: Scaffold(body: child), @@ -43,69 +43,73 @@ void main() { Locale('ru'), Locale('fa'), ]) { - testWidgets( - 'settings language and icon choices fit ${locale.toLanguageTag()}', - (tester) async { - tester.view.physicalSize = const Size(390, 844); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - LanguageCode? language; - await tester.pumpWidget( - app( - locale, - LanguageChoiceView( - selected: LanguageCode.zh, - onSelected: (value) => language = value, + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'settings language and icon choices fit ${locale.toLanguageTag()} at $size', + (tester) async { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + LanguageCode? language; + await tester.pumpWidget( + app( + locale, + LanguageChoiceView( + selected: LanguageCode.zh, + onSelected: (value) => language = value, + ), ), - ), - ); - await tester.pumpAndSettle(); - for (final label in ['简体中文', '繁體中文', 'English', 'Русский', 'فارسی']) { - expect(find.text(label), findsWidgets); - } - await tester.tap(find.text('فارسی').first); - expect(language, LanguageCode.fa); - expect(tester.takeException(), isNull); + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(ShadSelect)); + await tester.pumpAndSettle(); + for (final label in ['简体中文', '繁體中文', 'English', 'Русский', 'فارسی']) { + expect(find.text(label), findsWidgets); + } + await tester.tap(find.text('فارسی').last); + await tester.pumpAndSettle(); + expect(language, LanguageCode.fa); + expect(tester.takeException(), isNull); - AppIcon? icon; - await tester.pumpWidget( - app( - locale, - Builder( - builder: (context) => AppIconChoiceView( - selected: AppIcon.primary, - useDockIconAssets: false, - description: AppLocalizations.of(context)! - .prototypeHomeScreenIconHint, - onSelected: (value) => icon = value, + AppIcon? icon; + await tester.pumpWidget( + app( + locale, + Builder( + builder: (context) => AppIconChoiceView( + selected: AppIcon.primary, + useDockIconAssets: false, + description: AppLocalizations.of(context)! + .prototypeHomeScreenIconHint, + onSelected: (value) => icon = value, + ), ), ), - ), - ); - await tester.pumpAndSettle(); - final l10n = AppLocalizations.of( - tester.element(find.byType(AppIconChoiceView)), - )!; - expect(find.text(l10n.prototypeHomeScreenPreview), findsOneWidget); - for (final option in AppIcon.values) { - expect(find.text(appIconLabel(l10n, option)), findsOneWidget); - } - final icons = find.byType(Image); - expect(tester.getSize(icons.first), const Size(100, 100)); - expect(tester.getSize(icons.at(1)), const Size(62, 62)); - expect( - tester.getTopLeft(icons.at(1)).dy, - tester.getTopLeft(icons.at(3)).dy, - ); - expect( - tester.getTopLeft(icons.at(4)).dy, - greaterThan(tester.getTopLeft(icons.at(1)).dy), - ); - await tester.tap(find.text(l10n.prototypeIconBlack)); - expect(icon, AppIcon.black); - expect(tester.takeException(), isNull); - }, - ); + ); + await tester.pumpAndSettle(); + final l10n = AppLocalizations.of( + tester.element(find.byType(AppIconChoiceView)), + )!; + expect(find.text(l10n.settingsHomeScreenIcon), findsOneWidget); + final icons = find.byType(Image); + expect(icons, findsOneWidget); + expect(tester.getSize(icons.first), const Size(96, 96)); + await tester.tap(find.byType(ShadSelect)); + await tester.pumpAndSettle(); + for (final option in AppIcon.values) { + expect(find.text(appIconLabel(l10n, option)), findsWidgets); + } + await tester.tap(find.text(l10n.prototypeIconBlack).last); + await tester.pumpAndSettle(); + expect(icon, AppIcon.black); + expect(tester.takeException(), isNull); + }, + ); + } } } diff --git a/test/pages/shared/alert_test.dart b/test/pages/shared/alert_test.dart index db77d5652..0bd7a8b9d 100644 --- a/test/pages/shared/alert_test.dart +++ b/test/pages/shared/alert_test.dart @@ -5,6 +5,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/color.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { @@ -32,6 +33,68 @@ void main() { expect(find.byType(ShadDialog), findsNothing); }); + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'generic confirmation uses its own roles and results at $size', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + bool? result; + var confirmed = 0; + await tester.pumpWidget( + _AlertTestApp( + onPressed: (context) async { + result = await ContextAlert.showConfirmDialog( + context, + title: 'Confirm action', + content: 'Review the change.', + confirmLabel: 'Continue', + ); + if (result!) confirmed++; + }, + ), + ); + for (final close in ['Cancel', 'Close dialog', 'Continue']) { + await tester.tap(find.text('Show')); + await tester.pumpAndSettle(); + final dialog = tester.widget(find.byType(ShadDialog)); + expect( + dialog.radius, + BorderRadius.circular(size.width < 640 ? 0 : 18), + ); + final title = tester.widget(find.text('Confirm action')); + final description = tester.widget( + find.text('Review the change.'), + ); + expect(title.style!.fontSize, 20); + expect(title.style!.fontWeight, FontWeight.w600); + expect(description.style!.fontSize, 14); + expect( + description.style!.color, + ColorManager.palette(tester.element(find.byType(ShadDialog))) + .mutedForeground, + ); + if (close == 'Close dialog') { + await tester.tap(find.byTooltip(close)); + } else { + await tester.tap(find.text(close)); + } + await tester.pumpAndSettle(); + expect(result, close == 'Continue'); + expect(find.byType(ShadDialog), findsNothing); + } + expect(confirmed, 1); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('showToast uses the shared Shad toaster', (tester) async { await tester.pumpWidget( _AlertTestApp( diff --git a/test/pages/shared/widgets/adaptive_dialog_test.dart b/test/pages/shared/widgets/adaptive_dialog_test.dart index 9e326cdbb..44b81729b 100644 --- a/test/pages/shared/widgets/adaptive_dialog_test.dart +++ b/test/pages/shared/widgets/adaptive_dialog_test.dart @@ -1,12 +1,15 @@ import 'dart:math' as math; import 'package:material_ui/material_ui.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadButton; const _size = Size(390, 844); const _safeBottom = 34.0; @@ -15,8 +18,11 @@ Future _openDialog( WidgetTester tester, WidgetBuilder dialog, { required ValueNotifier keyboard, + Size size = _size, + double safeBottom = _safeBottom, + double textScale = 1, }) async { - await tester.binding.setSurfaceSize(_size); + await tester.binding.setSurfaceSize(size); addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( MaterialApp( @@ -28,10 +34,11 @@ Future _openDialog( valueListenable: keyboard, builder: (context, inset, _) => MediaQuery( data: MediaQueryData( - size: _size, - padding: EdgeInsets.only(bottom: math.max(0, _safeBottom - inset)), - viewPadding: const EdgeInsets.only(bottom: _safeBottom), + size: size, + padding: EdgeInsets.only(bottom: math.max(0, safeBottom - inset)), + viewPadding: EdgeInsets.only(bottom: safeBottom), viewInsets: EdgeInsets.only(bottom: inset), + textScaler: TextScaler.linear(textScale), ), child: child!, ), @@ -51,6 +58,118 @@ Future _openDialog( } void main() { + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets('ordinary dialog follows the prototype surface at $size', ( + tester, + ) async { + final keyboard = ValueNotifier(0.0); + addTearDown(keyboard.dispose); + await _openDialog( + tester, + (context) => AppDialog( + title: 'Dialog title', + subtitle: 'Supporting description', + body: const SizedBox(height: 80), + actions: [ + FilledButton( + onPressed: () => Navigator.pop(context), + child: const Text('Done'), + ), + ], + ), + keyboard: keyboard, + size: size, + safeBottom: 0, + ); + final surface = tester.widget( + find + .descendant( + of: find.byType(AppDialog), + matching: find.byType(Material), + ) + .first, + ); + final palette = ColorManager.palette( + tester.element(find.byType(AppDialog)), + ); + expect(surface.color, palette.background); + expect( + (surface.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(size.width < 640 ? 0 : 8), + ); + final title = tester.widget(find.text('Dialog title')); + final description = tester.widget( + find.text('Supporting description'), + ); + expect(title.style!.fontSize, 16); + expect(title.style!.fontWeight, FontWeight.w600); + expect(description.style!.fontSize, 13); + expect(description.style!.color, palette.mutedForeground); + final surfaceRect = tester.getRect( + find + .descendant( + of: find.byType(AppDialog), + matching: find.byType(Material), + ) + .first, + ); + expect(surfaceRect.center.dy, closeTo(size.height / 2, .01)); + expect(tester.takeException(), isNull); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(AppDialog), findsNothing); + }); + } + + testWidgets('short dialog scrolls large action labels without shrinking', ( + tester, + ) async { + final keyboard = ValueNotifier(0.0); + addTearDown(keyboard.dispose); + var calls = 0; + const label = 'Apply the saved configuration and reconnect'; + await _openDialog( + tester, + (context) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: AppDialog( + title: 'Changes', + body: const SizedBox(height: 1000), + actions: [ + ShadButton( + onPressed: () { + calls++; + Navigator.pop(context); + }, + child: const Text(label), + ), + ], + ), + ), + keyboard: keyboard, + size: const Size(390, 360), + safeBottom: 0, + textScale: 2, + ); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text(label)); + await tester.pumpAndSettle(); + expect(find.text(label).hitTestable(), findsOneWidget); + expect( + DefaultTextStyle.of(tester.element(find.text(label))).style.fontSize, + 14, + ); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.byType(AppDialog), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('wizard hides the covered card and restores its live draft', ( tester, ) async { @@ -125,7 +244,7 @@ void main() { }); testWidgets( - 'keyboard moves actions above its edge and preserves the bottom surface', + 'keyboard keeps centered modal and actions inside the usable area', (tester) async { final keyboard = ValueNotifier(0.0); addTearDown(keyboard.dispose); @@ -155,7 +274,11 @@ void main() { .first; expect( tester.getRect(surface).bottom, - closeTo(_size.height - inset, 0.01), + lessThanOrEqualTo(_size.height - math.max(inset, _safeBottom)), + ); + expect( + tester.getRect(surface).center.dy, + closeTo((_size.height - math.max(inset, _safeBottom)) / 2, .01), ); if (inset == 0) { expect( diff --git a/test/pages/shared/widgets/app_confirmation_dialog_test.dart b/test/pages/shared/widgets/app_confirmation_dialog_test.dart index 6bf52f178..30d6a1797 100644 --- a/test/pages/shared/widgets/app_confirmation_dialog_test.dart +++ b/test/pages/shared/widgets/app_confirmation_dialog_test.dart @@ -5,6 +5,7 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; const _open = Key('open-confirmation'); @@ -136,10 +137,12 @@ void main() { ); final cancel = find.byType(OutlinedButton); final confirm = find.byType(FilledButton); + await tester.ensureVisible(cancel); + await tester.pumpAndSettle(); final cancelBounds = tester.getRect(cancel); final confirmBounds = tester.getRect(confirm); final surface = tester.getRect(_surface); - expect(cancelBounds.width, closeTo(surface.width - 32, .5)); + expect(cancelBounds.width, closeTo(surface.width - 48, .5)); expect(confirmBounds.width, closeTo(cancelBounds.width, .5)); expect(confirmBounds.left, closeTo(cancelBounds.left, .5)); expect( @@ -157,17 +160,26 @@ void main() { ); final paragraph = tester.renderObject(richText); expect(paragraph.didExceedMaxLines, isFalse); - for (final box in paragraph.getBoxesForSelection( - TextSelection( - baseOffset: 0, - extentOffset: paragraph.text.toPlainText().length, - ), - )) { - expect(box.left, greaterThanOrEqualTo(-.5)); - expect(box.right, lessThanOrEqualTo(paragraph.size.width + .5)); - expect(box.bottom, lessThanOrEqualTo(paragraph.size.height + .5)); + // SkParagraph includes hanging spaces in a full-range selection box. + // Check every visible word instead of treating that whitespace as ink. + for (final word in RegExp( + r'\S+', + ).allMatches(paragraph.text.toPlainText())) { + for (final box in paragraph.getBoxesForSelection( + TextSelection(baseOffset: word.start, extentOffset: word.end), + )) { + expect(box.left, greaterThanOrEqualTo(-.5)); + expect(box.right, lessThanOrEqualTo(paragraph.size.width + .5)); + expect(box.bottom, lessThanOrEqualTo(paragraph.size.height + .5)); + } } - expect(tester.getRect(button).bottom, lessThanOrEqualTo(667)); + await tester.ensureVisible(button); + await tester.pumpAndSettle(); + expect(tester.getRect(button).top, greaterThanOrEqualTo(surface.top)); + expect( + tester.getRect(button).bottom, + lessThanOrEqualTo(surface.bottom), + ); } final body = find.byType(SingleChildScrollView); final scroll = tester.state( @@ -177,14 +189,16 @@ void main() { await tester.drag(body, const Offset(0, -180)); await tester.pumpAndSettle(); expect(scroll.position.pixels, greaterThan(0)); - expect(tester.getRect(cancel), cancelBounds); - expect(tester.getRect(confirm), confirmBounds); + await tester.ensureVisible(cancel); + await tester.pumpAndSettle(); await tester.tap(cancel); await tester.pumpAndSettle(); expect(result, isFalse); expect(find.byType(AppConfirmationDialog), findsNothing); await tester.tap(find.byKey(_open)); await tester.pumpAndSettle(); + await tester.ensureVisible(find.byType(FilledButton)); + await tester.pumpAndSettle(); await tester.tap(find.byType(FilledButton)); await tester.pumpAndSettle(); expect(result, isTrue); @@ -209,23 +223,37 @@ void main() { final surface = tester.getRect(_surface); final cancel = tester.getRect(find.byType(OutlinedButton)); final confirm = tester.getRect(find.byType(FilledButton)); - expect(surface.width, closeTo(mobile ? width - 38 : 540, 0.01)); + expect(surface.width, closeTo(mobile ? width - 32 : 520, 0.01)); expect(surface.center.dx, closeTo(width / 2, 0.01)); - expect(confirm.right, closeTo(surface.right - (mobile ? 16 : 20), 1)); - expect(confirm.left - cancel.right, closeTo(10, 0.01)); - expect(cancel.height, AppLayout.touchExtent(mobile ? 42 : 40)); - expect(confirm.height, AppLayout.touchExtent(mobile ? 42 : 40)); + expect(confirm.right, closeTo(surface.right - 24, 1)); + if (width < 560) { + expect(cancel.width, closeTo(surface.width - 48, .01)); + expect(confirm.width, closeTo(cancel.width, .01)); + expect(confirm.top - cancel.bottom, closeTo(10, .01)); + } else { + expect(confirm.left - cancel.right, closeTo(10, .01)); + } + expect( + cancel.height, + greaterThanOrEqualTo(AppLayout.touchExtent(mobile ? 48 : 42)), + ); + expect( + confirm.height, + greaterThanOrEqualTo(AppLayout.touchExtent(mobile ? 48 : 42)), + ); final label = tester.getRect( find.descendant( of: find.byType(FilledButton), matching: find.byType(Text), ), ); - expect( - confirm.width, - closeTo(label.width + AppSpacing.buttonHorizontal * 2, 0.01), - ); - expect(cancel.left, greaterThan(surface.left + (mobile ? 16 : 20))); + if (width >= 560) { + expect( + confirm.width, + closeTo(label.width + AppSpacing.buttonHorizontal * 2, 0.01), + ); + expect(cancel.left, greaterThan(surface.left + 24)); + } expect(tester.takeException(), isNull); }); } @@ -257,6 +285,33 @@ void main() { find.byType(AppConfirmationDialog), ); final surface = tester.getRect(_surface); + final palette = ColorManager.palette( + tester.element(find.byType(AppConfirmationDialog)), + ); + final title = tester.widget( + find + .descendant( + of: find.byType(AppConfirmationDialog), + matching: find.text(dialog.title), + ) + .first, + ); + expect(title.style!.fontSize, 20); + expect(title.style!.fontWeight, FontWeight.w600); + expect( + tester.widget(find.text(dialog.content)).style!.color, + palette.mutedForeground, + ); + final shape = + tester.widget(find.byType(Dialog)).shape! + as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(width < 640 ? 0 : 18)); + final confirmWidget = tester.widget( + find.byType(FilledButton), + ); + final confirmShape = + confirmWidget.style!.shape!.resolve({})! as RoundedRectangleBorder; + expect(confirmShape.borderRadius, BorderRadius.circular(24)); expect( Directionality.of(tester.element(find.byType(AppConfirmationDialog))), locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index 1a837d0b2..c0ac6e13b 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -139,7 +139,8 @@ void main() { (AppTypography.connectTrafficValue, 15, FontWeight.w600), (AppTypography.connectRawTitle, 12, FontWeight.w700), (AppTypography.connectRawCount, 12, FontWeight.w600), - (AppTypography.dialogTitle, 18, FontWeight.w700), + (AppTypography.dialogTitle, 16, FontWeight.w600), + (AppTypography.confirmationTitle, 20, FontWeight.w600), (AppTypography.dialogSubtitle, 13, FontWeight.w400), ]) { expect(style.fontSize, size); From e152faf9fd3afb99fff6e429fd913c4750d27b4e Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 01:28:06 +0800 Subject: [PATCH 19/60] fix(ui): close visual gaps found in native comparison --- docs/app.md | 7 +- lib/l10n/app_en.arb | 14 +- lib/l10n/app_fa.arb | 14 +- lib/l10n/app_ru.arb | 14 +- lib/l10n/app_zh.arb | 14 +- lib/l10n/app_zh_Hant.arb | 14 +- lib/pages/advanced/xray/page.dart | 173 +++++++++--------- lib/pages/connect/routing/page.dart | 87 ++++----- lib/pages/connect/view.dart | 28 ++- lib/pages/servers/page.dart | 5 +- lib/pages/settings/backup/page.dart | 4 +- lib/pages/settings/page.dart | 3 +- lib/pages/shared/widgets/page_app_bar.dart | 15 +- test/pages/advanced/xray/log_test.dart | 65 ++++++- test/pages/connect/view_test.dart | 8 +- test/pages/main/page_app_bar_test.dart | 161 ++++++++++++++++ test/pages/servers/manual_groups_test.dart | 2 +- .../appearance_startup_update_test.dart | 2 +- test/pages/settings/backup_test.dart | 42 +++++ 19 files changed, 521 insertions(+), 151 deletions(-) diff --git a/docs/app.md b/docs/app.md index 9c43f5fc4..be74f4138 100644 --- a/docs/app.md +++ b/docs/app.md @@ -30,7 +30,8 @@ 颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。 App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。 业务页使用公共顶部工具栏及页标题区:手机显示品牌,宽屏显示当前工作区路径; -页标题为手机24、宽屏26,副文14。主要页面动作在标题旁,窄屏自然下排。 +页标题为手机24、宽屏26,副文14。主要页面动作在标题旁,窄屏自然下排; +手机服务器主页使用紧凑动作与标题同行,保留长标题和辅助字号的可达性。 长资源名和大字号下标题区可独立滚动,保留正文和固定操作区的可用空间,不缩小字号或截断资源身份。 字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 @@ -215,7 +216,9 @@ VPN 隧道有独立主入口,公共参数与平台策略沿一条内容流呈 ### Xray -Xray 有独立主入口,依次展示真实运行摘要、诊断入口和工具分组,不与隧道共用二级标签。 +Xray 有独立主入口,分为运行状态、检查与诊断、连接与资源、共享与接口四张卡,不与隧道共用二级标签。 +核心名称与真实版本同行;当前连接身份仅来自活动运行快照,无运行时不以准备中的连接计划替代。 +运行时长仅在已连接且实际可用时显示;日志与最近运行配置入口归入检查与诊断。 只读诊断页显示当前阶段、原错误、版本、运行时长及实际运行 JSON/日志入口;刷新只重新读取状态, 不启停 VPN、不发起网络测试,也不清除或宣称解决连接错误。无运行记录使用空状态,不能用部分规则预览代替实际 Core 输入。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f09f09c4f..fc315635b 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,9 @@ { + "connectionPlanCaption": "Used for this connection", + "connectionPlanConnectedCaption": "Confirm changes to apply and reconnect", + "connectionSessionTitle": "This session", + "connectionSessionCaption": "Upload, download, and duration of this connection", + "connectionSessionDuration": "Duration", "tunnelIpv6Enabled": "IPv6 enabled", "tunnelIpv6Disabled": "IPv6 disabled", "tunnelSystemCapture": "System capture", @@ -3228,5 +3233,12 @@ "completeConfigurationRoutingTitle": "Configuration routing", "completeConfigurationRoutingNotice": "Uses routing and DNS from the complete configuration. App routing preferences are kept.", "xrayLogPreferences": "Recording preferences", - "xrayLogFiles": "Log files" + "xrayLogFiles": "Log files", + "xrayChecksAndDiagnostics": "Checks and diagnostics", + "xrayConnectionResources": "Connection and resources", + "xraySharingInterfaces": "Sharing and interfaces", + "xrayCurrentConnection": "Current connection", + "xrayNotRunning": "Not running", + "xrayLogsEnabled": "Recording enabled", + "xrayLogsDisabled": "Recording disabled" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 4dd2add03..d1d9f04ec 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,9 @@ { + "connectionPlanCaption": "برای این اتصال", + "connectionPlanConnectedCaption": "برای اعمال تغییرات و اتصال مجدد، تأیید کنید.", + "connectionSessionTitle": "نشست جاری", + "connectionSessionCaption": "ارسال، دریافت و مدت این اتصال", + "connectionSessionDuration": "مدت", "tunnelIpv6Enabled": "IPv6 روشن است", "tunnelIpv6Disabled": "IPv6 خاموش است", "tunnelSystemCapture": "کنترل سیستم", @@ -1047,5 +1052,12 @@ "completeConfigurationRoutingTitle": "مسیریابی پیکربندی", "completeConfigurationRoutingNotice": "از مسیریابی و DNS پیکربندی کامل استفاده می‌شود. تنظیمات مسیریابی عادی حفظ می‌شوند.", "xrayLogPreferences": "تنظیمات ثبت رویداد", - "xrayLogFiles": "فایل‌های گزارش" + "xrayLogFiles": "فایل‌های گزارش", + "xrayChecksAndDiagnostics": "بررسی و عیب‌یابی", + "xrayConnectionResources": "اتصال و منابع", + "xraySharingInterfaces": "اشتراک‌گذاری و رابط‌ها", + "xrayCurrentConnection": "اتصال فعلی", + "xrayNotRunning": "در حال اجرا نیست", + "xrayLogsEnabled": "ثبت رویداد فعال است", + "xrayLogsDisabled": "ثبت رویداد غیرفعال است" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a9ddce5a9..10d248681 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,9 @@ { + "connectionPlanCaption": "Для этого подключения", + "connectionPlanConnectedCaption": "Подтвердите изменения для применения и переподключения", + "connectionSessionTitle": "Текущий сеанс", + "connectionSessionCaption": "Передача, приём и длительность этого подключения", + "connectionSessionDuration": "Длительность", "tunnelIpv6Enabled": "IPv6 включён", "tunnelIpv6Disabled": "IPv6 выключен", "tunnelSystemCapture": "Системный VPN", @@ -1047,5 +1052,12 @@ "completeConfigurationRoutingTitle": "Маршруты конфигурации", "completeConfigurationRoutingNotice": "Используются маршруты и DNS полной конфигурации. Настройки обычной маршрутизации сохраняются.", "xrayLogPreferences": "Параметры записи", - "xrayLogFiles": "Файлы журналов" + "xrayLogFiles": "Файлы журналов", + "xrayChecksAndDiagnostics": "Проверки и диагностика", + "xrayConnectionResources": "Подключение и ресурсы", + "xraySharingInterfaces": "Общий доступ и интерфейсы", + "xrayCurrentConnection": "Текущее подключение", + "xrayNotRunning": "Не запущено", + "xrayLogsEnabled": "Запись включена", + "xrayLogsDisabled": "Запись выключена" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fce0d0633..ab85ef374 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,9 @@ { + "connectionPlanCaption": "本次连接使用", + "connectionPlanConnectedCaption": "更改后确认应用并重新连接", + "connectionSessionTitle": "本次会话", + "connectionSessionCaption": "此连接的上传、下载与连接时长", + "connectionSessionDuration": "时长", "tunnelIpv6Enabled": "IPv6 已开启", "tunnelIpv6Disabled": "IPv6 已关闭", "tunnelSystemCapture": "系统接管", @@ -1047,5 +1052,12 @@ "completeConfigurationRoutingTitle": "配置自带路由", "completeConfigurationRoutingNotice": "使用完整配置内的路由与 DNS,普通路由偏好保持不变。", "xrayLogPreferences": "记录偏好", - "xrayLogFiles": "日志文件" + "xrayLogFiles": "日志文件", + "xrayChecksAndDiagnostics": "检查与诊断", + "xrayConnectionResources": "连接与资源", + "xraySharingInterfaces": "共享与接口", + "xrayCurrentConnection": "当前连接", + "xrayNotRunning": "尚未运行", + "xrayLogsEnabled": "记录已开启", + "xrayLogsDisabled": "记录已关闭" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 3e33a325c..f9fdd38e8 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,9 @@ { + "connectionPlanCaption": "本次連線使用", + "connectionPlanConnectedCaption": "變更後確認套用並重新連線", + "connectionSessionTitle": "本次工作階段", + "connectionSessionCaption": "此連線的上傳、下載與連線時長", + "connectionSessionDuration": "時長", "tunnelIpv6Enabled": "IPv6 已開啟", "tunnelIpv6Disabled": "IPv6 已關閉", "tunnelSystemCapture": "系統接管", @@ -1047,5 +1052,12 @@ "completeConfigurationRoutingTitle": "設定自帶路由", "completeConfigurationRoutingNotice": "使用完整設定內的路由與 DNS,一般路由偏好保持不變。", "xrayLogPreferences": "記錄偏好", - "xrayLogFiles": "日誌檔案" + "xrayLogFiles": "日誌檔案", + "xrayChecksAndDiagnostics": "檢查與診斷", + "xrayConnectionResources": "連線與資源", + "xraySharingInterfaces": "共享與介面", + "xrayCurrentConnection": "目前連線", + "xrayNotRunning": "尚未執行", + "xrayLogsEnabled": "記錄已開啟", + "xrayLogsDisabled": "記錄已關閉" } diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index cf2ee7265..a64b34e74 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -95,8 +95,11 @@ class XrayRuntimePage extends StatelessWidget { _section( icon: LucideIcons.activity, title: l.prototypeRuntimeStatus, + children: [_runtimeCard(context, controller, l)], + ), + _section( + title: l.xrayChecksAndDiagnostics, children: [ - _runtimeCard(context, controller, l), if (onDiagnostics != null) _pushRow( context, @@ -105,21 +108,48 @@ class XrayRuntimePage extends StatelessWidget { subtitle: state.connection.failed ? l.prototypeConnectionFailed : l.xrayDiagnosticsHint, - icon: LucideIcons.stethoscope, enabled: true, onTap: () => onDiagnostics!(context), ), + if (!state.systemExtension) + _pushRow( + context, + mobile: mobile, + title: l.prototypeLogs, + subtitle: controller.logsEnabled + ? l.xrayLogsEnabled + : l.xrayLogsDisabled, + enabled: !disabled && onLogSettings != null, + onTap: () async { + final saved = await onLogSettings?.call( + context, + ); + if (saved == true && + controller.isPageActive) { + await controller.load(showLoading: false); + } + }, + ), + _pushRow( + context, + mobile: mobile, + title: l.prototypeRecentXrayConfiguration, + subtitle: controller.runtime == null + ? l.xrayNoRuntimeConfiguration + : l.prototypeReadOnlyRuntimeConfiguration, + enabled: controller.runtime != null, + onTap: () => + controller.openConfig(context, onConfig), + ), ], ), _section( - icon: LucideIcons.settings, - title: l.prototypeSettings, + title: l.xrayConnectionResources, children: [ _pushRow( context, mobile: mobile, title: l.prototypeRoutingData, - icon: LucideIcons.hardDrive, trailing: AppActivityBuilder( builder: (context, activity) => activity.downloading @@ -133,7 +163,6 @@ class XrayRuntimePage extends StatelessWidget { context, mobile: mobile, title: l.prototypeDataUpdates, - icon: LucideIcons.refreshCw, trailing: AppActivityBuilder( builder: (context, activity) => activity.downloading @@ -148,7 +177,6 @@ class XrayRuntimePage extends StatelessWidget { mobile: mobile, title: l.prototypeSpeedTest, subtitle: controller.speedSummary(l), - icon: LucideIcons.zap, trailing: AppActivityBuilder( builder: (context, activity) => activity.pinging @@ -163,43 +191,18 @@ class XrayRuntimePage extends StatelessWidget { context, mobile: mobile, title: l.serverDialingTitle, - icon: LucideIcons.network, enabled: !disabled, onTap: () => onServerDialing!(context), ), ], ), - if (!state.systemExtension) - _section( - icon: LucideIcons.fileText, - title: l.prototypeLogs, - children: [ - _pushRow( - context, - mobile: mobile, - title: l.prototypeLogs, - icon: LucideIcons.fileText, - enabled: !disabled && onLogSettings != null, - onTap: () async { - final saved = await onLogSettings?.call( - context, - ); - if (saved == true && - controller.isPageActive) { - await controller.load(showLoading: false); - } - }, - ), - ], - ), _section( - icon: LucideIcons.slidersHorizontal, - title: l.lanProxyConfiguration, - description: - '${l.lanProxySecurityHint} ${l.lanProxyNextStartNotice}', + title: l.xraySharingInterfaces, action: PageHelpButton( title: l.lanProxyTitle, messages: [ + l.lanProxySecurityHint, + l.lanProxyNextStartNotice, l.lanProxyProtocolHint, l.lanProxyRawJsonNotice, ], @@ -209,7 +212,6 @@ class XrayRuntimePage extends StatelessWidget { title: l.lanProxyTitle, titleMaxLines: 4, titleStyle: AppTypography.rowTitle, - minHeight: 64, contentPadding: const EdgeInsets.all(12), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -241,41 +243,17 @@ class XrayRuntimePage extends StatelessWidget { } }, ), - ], - ), - _section( - icon: LucideIcons.fileJson, - title: l.prototypeRuntimeConfiguration, - children: [ - _pushRow( - context, - mobile: mobile, - title: l.prototypeRecentXrayConfiguration, - subtitle: - l.prototypeReadOnlyRuntimeConfiguration, - icon: LucideIcons.fileJson, - enabled: controller.runtime != null, - onTap: () => - controller.openConfig(context, onConfig), - ), - ], - ), - if (AppPlatform.isDesktop && onLocalApi != null) - _section( - icon: LucideIcons.terminal, - title: l.localApiTitle, - children: [ + if (AppPlatform.isDesktop && onLocalApi != null) _pushRow( context, mobile: mobile, title: l.localApiTitle, subtitle: l.localApiSummary, - icon: LucideIcons.terminal, enabled: true, onTap: () => onLocalApi!(context), ), - ], - ), + ], + ), if (controller.failed) Padding( padding: const EdgeInsets.all(20), @@ -297,7 +275,7 @@ class XrayRuntimePage extends StatelessWidget { ); Widget _section({ - required IconData icon, + IconData? icon, required String title, String? description, Widget? action, @@ -319,25 +297,58 @@ class XrayRuntimePage extends StatelessWidget { AppLocalizations l, ) { final palette = ColorManager.palette(context); + final runtime = controller.connected || controller.runtimeBusy + ? controller.state.connection.runtime + : null; + final entries = + runtime?.entries.map((entry) => entry.name).join(' + ') ?? ''; + final identity = runtime == null + ? (controller.connected || controller.runtimeBusy + ? '—' + : l.xrayNotRunning) + : runtime.profile?.name ?? + runtime.rawName ?? + (entries.isEmpty + ? l.prototypeCompleteXrayConfiguration + : '$entries${runtime.finalExit == null ? '' : ' → ${runtime.finalExit!.name}'}'); + final statusColor = controller.connected + ? palette.runningText + : controller.state.connection.failed + ? palette.destructive + : controller.runtimeBusy + ? palette.restartingText + : palette.foreground; return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - PolicyValueRow( - title: l.prototypeXrayCore, - value: controller.connected - ? l.prototypeRunningNormally - : controller.statusLabel(l), - valueTextDirection: null, - valueStyle: AppTypography.rowTitle.copyWith( - color: controller.connected - ? palette.runningText - : palette.foreground, + SettingRow( + key: const ValueKey('xray-core-runtime'), + title: 'Xray-core', + subtitle: controller.xrayVersion, + subtitleStyle: AppTypography.supporting, + trailing: ShadBadge.secondary( + backgroundColor: controller.connected + ? palette.runningSurface + : controller.state.connection.failed + ? palette.destructiveSurface + : palette.muted, + foregroundColor: statusColor, + child: Text( + controller.statusLabel(l), + style: AppTypography.badge.copyWith(color: statusColor), + ), ), ), - PolicyValueRow( - title: l.prototypeVersion, - value: controller.xrayVersion, + SettingRow( + key: const ValueKey('xray-current-connection'), + title: l.xrayCurrentConnection, + subtitle: identity, + subtitleStyle: AppTypography.supporting, ), - PolicyValueRow(title: l.prototypeUptime, value: controller.uptime), + if (controller.connected && + controller.uptime.isNotEmpty && + controller.uptime != '—') + PolicyValueRow(title: l.prototypeUptime, value: controller.uptime), ], ); } @@ -348,7 +359,6 @@ class XrayRuntimePage extends StatelessWidget { required String title, String? subtitle, Widget? trailing, - required IconData icon, required bool enabled, required VoidCallback onTap, }) => SettingRow( @@ -359,11 +369,6 @@ class XrayRuntimePage extends StatelessWidget { contentPadding: const EdgeInsets.all(12), minHeight: mobile ? 56 : 48, decorateLeading: false, - leading: Icon( - icon, - size: 20, - color: ColorManager.palette(context).mutedForeground, - ), trailing: trailing ?? _chevron(context), enabled: enabled, onTap: onTap, diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart index 5ed2a8a4e..95c98d999 100644 --- a/lib/pages/connect/routing/page.dart +++ b/lib/pages/connect/routing/page.dart @@ -250,54 +250,59 @@ class _RoutingPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 12, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsetsDirectional.only(end: 12, top: 2), - child: Icon( - icon, - size: 20, - color: selected ? palette.primary : palette.mutedStrong, + LayoutBuilder( + builder: (context, constraints) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 12, top: 2), + child: Icon( + icon, + size: 20, + color: selected ? palette.primary : palette.mutedStrong, + ), ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - Text(title, style: AppTypography.sectionTitle), - Text( - description, - style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text(title, style: AppTypography.sectionTitle), + Text( + description, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), ), - ), - ], + ], + ), ), - ), - if (selected) - Flexible( - child: Container( - key: useKey, - margin: const EdgeInsetsDirectional.only(start: 8), - padding: const EdgeInsets.symmetric( - horizontal: 9, - vertical: 4, + if (selected) + ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth * .42, ), - decoration: BoxDecoration( - color: palette.selectedSurface, - borderRadius: BorderRadius.circular(AppRadii.control), - ), - child: Text( - l.prototypeCurrentSelection, - style: AppTypography.badge.copyWith( - color: palette.primaryText, + child: Container( + key: useKey, + margin: const EdgeInsetsDirectional.only(start: 8), + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 4, + ), + decoration: BoxDecoration( + color: palette.selectedSurface, + borderRadius: BorderRadius.circular(AppRadii.control), + ), + child: Text( + l.prototypeCurrentSelection, + style: AppTypography.badge.copyWith( + color: palette.primaryText, + ), ), ), ), - ), - ], + ], + ), ), if (diagnostic != null) Semantics( diff --git a/lib/pages/connect/view.dart b/lib/pages/connect/view.dart index b4c0e7eef..c913db8df 100644 --- a/lib/pages/connect/view.dart +++ b/lib/pages/connect/view.dart @@ -84,6 +84,7 @@ class ConnectView extends StatelessWidget { String? detail, VoidCallback? action, { bool selected = false, + bool includeLabel = true, }) => Semantics( selected: selected, button: action != null, @@ -121,7 +122,7 @@ class ConnectView extends StatelessWidget { const SizedBox(height: 4), Text( [ - if (label != value) label, + if (includeLabel && label != value) label, if (detail != null && detail.isNotEmpty) detail, ].join(' · '), style: AppTypography.supporting.copyWith( @@ -182,7 +183,16 @@ class ConnectView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l.connectionPlan, style: AppTypography.panelTitle), - const SizedBox(height: 14), + const SizedBox(height: 4), + Text( + view.phase == ConnectionPhase.connected + ? l.connectionPlanConnectedCaption + : l.connectionPlanCaption, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + const SizedBox(height: 16), choice( expert || !methodEnabled ? LucideIcons.fileJson @@ -203,6 +213,7 @@ class ConnectView extends StatelessWidget { method, methodDetail, methodEnabled && !expert && !_busy ? onMethod : null, + includeLabel: false, ), ], ), @@ -259,10 +270,17 @@ class ConnectView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - l.prototypeThisConnection, + l.connectionSessionTitle, style: AppTypography.panelTitle, ), - const SizedBox(height: 14), + const SizedBox(height: 4), + Text( + l.connectionSessionCaption, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + const SizedBox(height: 16), trafficBuilder?.call(false) ?? TrafficReadout(view: view), ], ), @@ -539,7 +557,7 @@ class TrafficReadout extends StatelessWidget { live ? '${formatTraffic(view.downloadSpeed, connection: true)}/s' : '—', index: 1, ), - metric(l.prototypeUptime, elapsed, null, index: 2), + metric(l.connectionSessionDuration, elapsed, null, index: 2), ]; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/pages/servers/page.dart b/lib/pages/servers/page.dart index 90db79d93..a2843a63b 100644 --- a/lib/pages/servers/page.dart +++ b/lib/pages/servers/page.dart @@ -53,7 +53,8 @@ class _ServersPageState extends State { appBar: PageAppBar.forPage( context, title: Text(l.prototypeServers), - description: l.serversPageDescription, + description: mobile ? '' : l.serversPageDescription, + inlineHeadingAction: mobile, headingAction: Wrap( spacing: 10, runSpacing: 10, @@ -69,7 +70,7 @@ class _ServersPageState extends State { key: const ValueKey('servers-add'), onPressed: () => controller.addServers(context), icon: const Icon(LucideIcons.plus, size: 18), - label: Text(l.prototypeAddServer), + label: Text(mobile ? l.prototypeAdd : l.prototypeAddServer), ), ], ), diff --git a/lib/pages/settings/backup/page.dart b/lib/pages/settings/backup/page.dart index 0e776e10a..85707b6f4 100644 --- a/lib/pages/settings/backup/page.dart +++ b/lib/pages/settings/backup/page.dart @@ -321,7 +321,9 @@ class BackupPage extends StatelessWidget { ShadAlert( icon: const Icon(LucideIcons.circleHelp, size: 18), description: Text(l.backupSensitiveWarning), - descriptionStyle: AppTypography.supporting, + descriptionStyle: AppTypography.supporting.copyWith( + color: palette.mutedStrong, + ), ), Text( backup.settings.lastSuccess == null diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index 39ad1f9de..ac0a6241b 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -95,7 +95,7 @@ class SettingsPage extends StatelessWidget { children: [ SettingRow( title: l10n.prototypeStartup, - value: state.connectOnLaunch + subtitle: state.connectOnLaunch ? l10n.prototypeConnectAfterAppLaunch : l10n.settingsManualConnection, showChevron: true, @@ -126,6 +126,7 @@ class SettingsPage extends StatelessWidget { if (BackupService.supported) SettingRow( title: l10n.backupTitle, + subtitle: l10n.backupPageDescription, minHeight: rowHeight, contentPadding: rowPadding, titleStyle: AppTypography.rowTitle, diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index d934a03f7..2e71c124a 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -28,6 +28,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { this.bottom, }) : description = null, headingAction = null, + inlineHeadingAction = false, _headingHeight = null; const PageAppBar._({ @@ -35,6 +36,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { required this.title, this.description, this.headingAction, + this.inlineHeadingAction = false, this.leading, this.actions, this.bottom, @@ -47,6 +49,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { required Widget title, String? description, Widget? headingAction, + bool inlineHeadingAction = false, Widget? leading, List? actions, PreferredSizeWidget? bottom, @@ -79,7 +82,8 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), )..layout( - maxWidth: headingAction != null && width >= 540 + maxWidth: + headingAction != null && (width >= 540 || inlineHeadingAction) ? (width - 20) / 2 : width, ); @@ -107,7 +111,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { 8 + (headingAction == null ? blockHeight - : width < 540 + : width < 540 && !inlineHeadingAction ? blockHeight + 16 + actionHeight : math.max(blockHeight, actionHeight)); final media = MediaQuery.of(context); @@ -126,6 +130,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { title: title, description: effectiveDescription, headingAction: headingAction, + inlineHeadingAction: inlineHeadingAction, leading: leading, actions: actions, bottom: bottom, @@ -136,6 +141,10 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { final Widget title; final String? description; final Widget? headingAction; + + /// Keeps a compact page action beside a wrapping title on narrow screens. + /// The action is constrained to half the heading so identity remains visible. + final bool inlineHeadingAction; final double? _headingHeight; final Widget? leading; final List? actions; @@ -316,7 +325,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { if (headingAction == null) return identity; return LayoutBuilder( builder: (context, constraints) { - if (constraints.maxWidth < 540) { + if (constraints.maxWidth < 540 && !inlineHeadingAction) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [identity, const SizedBox(height: 16), headingAction!], diff --git a/test/pages/advanced/xray/log_test.dart b/test/pages/advanced/xray/log_test.dart index 8d7534da4..0e20bd549 100644 --- a/test/pages/advanced/xray/log_test.dart +++ b/test/pages/advanced/xray/log_test.dart @@ -57,6 +57,7 @@ void main() { app( XrayRuntimePage( createController: () => root, + onDiagnostics: (_) {}, onGeodata: (_) {}, onUpdates: (_) {}, onSpeedTest: (_) {}, @@ -94,10 +95,66 @@ void main() { l.prototypeRoutingData, l.prototypeDataUpdates, l.prototypeSpeedTest, - l.prototypeRuntimeConfiguration, + l.prototypeRecentXrayConfiguration, + l.xrayChecksAndDiagnostics, + l.xrayConnectionResources, + l.xraySharingInterfaces, ]) { expect(find.text(text), findsOneWidget); } + final core = find.byKey(const ValueKey('xray-core-runtime')); + expect(tester.widget(core).title, 'Xray-core'); + expect(tester.widget(core).subtitle, '26.10.31'); + expect( + find.descendant(of: core, matching: find.byType(ShadBadge)), + findsOneWidget, + ); + expect(find.text(l.prototypeVersion), findsNothing); + expect( + tester + .widget( + find.byKey(const ValueKey('xray-current-connection')), + ) + .subtitle, + l.xrayNotRunning, + ); + final checks = find.byWidgetPredicate( + (w) => w is SettingSection && w.title == l.xrayChecksAndDiagnostics, + ); + for (final title in [ + l.xrayDiagnostics, + l.prototypeRecentXrayConfiguration, + if (!systemExtension) l.prototypeLogs, + ]) { + expect( + find.descendant( + of: checks, + matching: find.byWidgetPredicate( + (w) => w is SettingRow && w.title == title, + ), + ), + findsOneWidget, + ); + } + final resources = find.byWidgetPredicate( + (w) => w is SettingSection && w.title == l.xrayConnectionResources, + ); + for (final title in [ + l.prototypeRoutingData, + l.prototypeDataUpdates, + l.prototypeSpeedTest, + ]) { + expect( + find.descendant( + of: resources, + matching: find.byWidgetPredicate( + (w) => w is SettingRow && w.title == title, + ), + ), + findsOneWidget, + ); + } + expect(find.byType(SettingSection), findsNWidgets(4)); expect(tester.takeException(), isNull); await tester.pumpWidget(const SizedBox()); await tester.pumpAndSettle(); @@ -193,6 +250,12 @@ void main() { class _Controller extends XrayRuntimeController { _Controller({required super.coordinator, required this.systemExtension}); final bool systemExtension; + + // This UI fixture supplies the same public metadata getter as production. + // The real reader can still publish unknown native metadata asynchronously. + @override + String get xrayVersion => '26.10.31'; + @override Future load({bool showLoading = true}) async { final configuration = ConnectionConfiguration( diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index 869d2a689..23a82b159 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -176,7 +176,7 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(ConnectView)), )!; - final title = find.text(l.prototypeThisConnection); + final title = find.text(l.connectionSessionTitle); expect(title, findsOneWidget); expect( find.ancestor(of: title, matching: find.byType(InkWell)), @@ -333,7 +333,7 @@ void main() { l.prototypeConnected, l.connectionPlan, l.connectionRuntimeTitle, - l.prototypeThisConnection, + l.connectionSessionTitle, ] .map( (title) => tester.getRect( @@ -399,7 +399,7 @@ void main() { final session = tester.getRect( find .ancestor( - of: find.text(l.prototypeThisConnection), + of: find.text(l.connectionSessionTitle), matching: find.byType(Card), ) .first, @@ -435,7 +435,7 @@ void main() { expect(tester.takeException(), isNull); final context = tester.element(find.byType(ConnectView)); final l = AppLocalizations.of(context)!; - expect(find.text(l.prototypeThisConnection), findsOneWidget); + expect(find.text(l.connectionSessionTitle), findsOneWidget); expect(find.text(l.connectionPlan), findsOneWidget); expect(find.text(l.prototypeServers), findsOneWidget); expect( diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index 7405284b5..b7f92e31b 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -111,6 +111,167 @@ void main() { ); } + for (final inline in [false, true]) { + testWidgets('compact mobile heading action opts into one row: $inline', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var actions = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('zh'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('服务器'), + description: '', + inlineHeadingAction: inline, + headingAction: FilledButton.icon( + key: const Key('compact-heading-action'), + onPressed: () => actions++, + icon: const Icon(Icons.add, size: 18), + label: const Text('导入'), + ), + ), + body: const SizedBox( + key: Key('compact-page-body'), + width: double.infinity, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final heading = find.byKey(const ValueKey('page-heading')); + final title = find.descendant(of: heading, matching: find.text('服务器')); + final action = find.byKey(const Key('compact-heading-action')); + final titleBounds = tester.getRect(title); + final actionBounds = tester.getRect(action); + expect(tester.widget(title).maxLines, isNull); + expect(action.hitTestable(), findsOneWidget); + if (inline) { + expect(titleBounds.overlaps(actionBounds), isFalse); + expect(titleBounds.center.dy, closeTo(actionBounds.center.dy, .01)); + expect(titleBounds.right, lessThanOrEqualTo(actionBounds.left)); + expect( + tester.getRect(find.byKey(const Key('compact-page-body'))).top, + lessThanOrEqualTo(132), + ); + } else { + expect(actionBounds.top, greaterThanOrEqualTo(titleBounds.bottom + 16)); + } + expect(actionBounds.right, lessThanOrEqualTo(390 - 16)); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }); + } + + for (final locale in [const Locale('ru'), const Locale('fa')]) { + testWidgets( + 'inline heading wraps long identity and preserves short viewport actions: $locale', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 360); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var imports = 0; + var saves = 0; + const titleText = 'Настройки подключения — Tokyo Singapore — настройки'; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), + child: Directionality( + textDirection: locale.languageCode == 'fa' + ? TextDirection.rtl + : TextDirection.ltr, + child: child!, + ), + ), + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text(titleText), + description: '', + inlineHeadingAction: true, + headingAction: TextButton( + key: const Key('inline-import'), + onPressed: () => imports++, + child: const Text('Import'), + ), + ), + body: const SizedBox.expand(key: Key('inline-page-body')), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton( + key: const Key('inline-save'), + onPressed: () => saves++, + child: const Text('Save'), + ), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final heading = find.byKey(const ValueKey('page-heading')); + final title = find.descendant( + of: heading, + matching: find.text(titleText), + ); + expect(tester.widget(title).maxLines, isNull); + expect( + tester.widget(title).overflow, + isNot(TextOverflow.ellipsis), + ); + final titleBounds = tester.getRect(title); + expect(titleBounds.left, greaterThanOrEqualTo(16)); + expect(titleBounds.right, lessThanOrEqualTo(374)); + expect( + tester.getSize(find.byKey(const Key('inline-page-body'))).height, + greaterThanOrEqualTo(80), + ); + final headingScrollable = find.descendant( + of: heading, + matching: find.byType(Scrollable), + ); + expect( + tester + .state(headingScrollable) + .position + .maxScrollExtent, + greaterThan(0), + ); + final action = find.byKey(const Key('inline-import')); + await tester.ensureVisible(action); + await tester.pumpAndSettle(); + expect(action.hitTestable(), findsOneWidget); + await tester.tap(action); + await tester.tap(find.byKey(const Key('inline-save'))); + await tester.pumpAndSettle(); + expect(imports, 1); + expect(saves, 1); + expect(tester.takeException(), isNull); + }, + ); + } + for (final height in [844.0, 360.0]) { testWidgets( 'long legal resource heading keeps content and footer reachable at 390x$height/2x', diff --git a/test/pages/servers/manual_groups_test.dart b/test/pages/servers/manual_groups_test.dart index 61fb83c8a..c815afb6e 100644 --- a/test/pages/servers/manual_groups_test.dart +++ b/test/pages/servers/manual_groups_test.dart @@ -412,7 +412,7 @@ void main() { expect(add.hitTestable(), findsOneWidget); expect(tester.widget(add).onPressed, isNotNull); expect( - find.descendant(of: add, matching: find.text(l.prototypeAddServer)), + find.descendant(of: add, matching: find.text(l.prototypeAdd)), findsOneWidget, ); expect(find.byTooltip(l.manualGroupNew), findsNothing); diff --git a/test/pages/settings/appearance_startup_update_test.dart b/test/pages/settings/appearance_startup_update_test.dart index cbd097e98..212c9fe10 100644 --- a/test/pages/settings/appearance_startup_update_test.dart +++ b/test/pages/settings/appearance_startup_update_test.dart @@ -253,7 +253,7 @@ void main() { router.pop(); await tester.pumpAndSettle(); expect( - tester.widget(startup()).value, + tester.widget(startup()).subtitle, l.prototypeConnectAfterAppLaunch, ); expect(find.byType(ShadSwitch), findsNothing); diff --git a/test/pages/settings/backup_test.dart b/test/pages/settings/backup_test.dart index 65ec02bce..30890ed2f 100644 --- a/test/pages/settings/backup_test.dart +++ b/test/pages/settings/backup_test.dart @@ -15,6 +15,7 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/service/advanced/xray/geodata/service.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; import 'package:onexray/service/settings/backup/assets.dart'; @@ -86,6 +87,47 @@ void main() { home: BackupPage(service: service), ); + for (final brightness in Brightness.values) { + testWidgets( + 'sensitive backup notice keeps a readable foreground in $brightness', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(brightness), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(brightness), + child: ShadToaster(child: child!), + ), + home: BackupPage(service: service), + ), + ); + await tester.pumpAndSettle(); + final context = tester.element(find.byType(BackupPage)); + final l = AppLocalizations.of(context)!; + final notice = find.text(l.backupSensitiveWarning); + await tester.ensureVisible(notice); + await tester.pumpAndSettle(); + final text = tester.widget( + find.descendant(of: notice, matching: find.byType(RichText)), + ); + final foreground = text.text.style!.color!; + final palette = ColorManager.palette(context); + expect(foreground, palette.mutedStrong); + final first = foreground.computeLuminance(); + final second = palette.background.computeLuminance(); + final contrast = first > second + ? (first + .05) / (second + .05) + : (second + .05) / (first + .05); + expect(contrast, greaterThanOrEqualTo(4.5)); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + for (final platform in [TargetPlatform.iOS, TargetPlatform.macOS]) { for (final locale in [ const Locale('en'), From e27716b223d3257e68711d013c2f5c476fb51764 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 02:11:06 +0800 Subject: [PATCH 20/60] fix(ui): unify form cards and responsive page actions --- docs/app.md | 2 + lib/l10n/app_en.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/pages/connect/json_editor/page.dart | 84 +++++---- lib/pages/connect/routing/custom/page.dart | 9 +- .../connect/routing/smart/exit_picker.dart | 9 +- lib/pages/connect/routing/smart/page.dart | 9 +- lib/pages/connect/routing/smart/regions.dart | 9 +- lib/pages/servers/editor/page.dart | 12 +- lib/pages/servers/form_page.dart | 27 +++ lib/pages/servers/import/page.dart | 8 +- lib/pages/shared/widgets/page_action_bar.dart | 61 ++++--- lib/pages/theme/layout.dart | 1 - test/pages/connect/raw_editor_test.dart | 162 +++++++++++++++++- test/pages/connect/view_test.dart | 8 +- test/pages/servers/editor_dismissal_test.dart | 61 ++++++- test/pages/servers/import_test.dart | 66 +++++++ .../shared/widgets/page_action_bar_test.dart | 125 +++++++++++--- 21 files changed, 533 insertions(+), 125 deletions(-) diff --git a/docs/app.md b/docs/app.md index be74f4138..d594fd85a 100644 --- a/docs/app.md +++ b/docs/app.md @@ -19,6 +19,8 @@ - 宽布局先避让横向安全区,再分配侧栏内容宽度。侧栏高度不足时可滚动访问全部入口与更新提醒, 品牌保持单行;横竖屏切换不重置当前页面或各 Tab 的导航栈。 - 完整页面的共享 `PageActionBar` 固定在底部,保留安全区、键盘避让和动作换行;弹窗保留自己的操作区。 + 宽屏按钮按文字自然取宽,macOS 最低42、iPad最低44;手机动作等宽且最低48。 + 手机草稿页显示取消与保存或完成,已有高级模板的删除独立一行,沿用原有退出及删除确认。 添加、编辑、分享和更新提醒保持原有页面或弹窗形式,均留在当前 Tab。 - 异步操作使用按钮内 loading,只限制冲突操作,不全局禁用交互。保存成功使用共享 toast; 失败、取消不显示成功提示。恢复默认只更新草稿,仍需保存;导航、选择和草稿增删不重复提示。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fc315635b..9fecdf11d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1,4 +1,5 @@ { + "configurationContentTitle": "Configuration content", "connectionPlanCaption": "Used for this connection", "connectionPlanConnectedCaption": "Confirm changes to apply and reconnect", "connectionSessionTitle": "This session", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index d1d9f04ec..ecb537219 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1,4 +1,5 @@ { + "configurationContentTitle": "محتوای پیکربندی", "connectionPlanCaption": "برای این اتصال", "connectionPlanConnectedCaption": "برای اعمال تغییرات و اتصال مجدد، تأیید کنید.", "connectionSessionTitle": "نشست جاری", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 10d248681..2a137c609 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1,4 +1,5 @@ { + "configurationContentTitle": "Содержимое конфигурации", "connectionPlanCaption": "Для этого подключения", "connectionPlanConnectedCaption": "Подтвердите изменения для применения и переподключения", "connectionSessionTitle": "Текущий сеанс", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index ab85ef374..d238809eb 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1,4 +1,5 @@ { + "configurationContentTitle": "配置内容", "connectionPlanCaption": "本次连接使用", "connectionPlanConnectedCaption": "更改后确认应用并重新连接", "connectionSessionTitle": "本次会话", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index f9fdd38e8..7911e7837 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1,4 +1,5 @@ { + "configurationContentTitle": "設定內容", "connectionPlanCaption": "本次連線使用", "connectionPlanConnectedCaption": "變更後確認套用並重新連線", "connectionSessionTitle": "本次工作階段", diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index 879ff3179..883b4f7e7 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -74,6 +74,37 @@ class _JsonConfigurationEditorState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); const gap = 16.0; + final canClose = + !state.closeBlocked && + !state.confirmingClose && + !state.closeAllowed; + final delete = widget.configurationId != null && controller.advanced + ? ShadButton.destructive( + enabled: controller.canDelete, + onPressed: controller.canDelete + ? () => controller.delete(context) + : null, + child: ButtonProgress( + busy: state.deleting, + child: Text(l10n.prototypeDelete), + ), + ) + : null; + final cancel = ShadButton.outline( + enabled: canClose, + onPressed: canClose ? () => controller.closePage(context) : null, + child: Text(l10n.prototypeCancel), + ); + final save = ShadButton( + enabled: controller.canSave, + onPressed: controller.canSave + ? () => controller.save(context) + : null, + child: ButtonProgress( + busy: state.loaded && state.busy && !state.deleting, + child: Text(l10n.prototypeSave), + ), + ); return CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.escape): () => @@ -256,38 +287,27 @@ class _JsonConfigurationEditorState extends State { ), bottomNavigationBar: PageActionBar( children: [ - if (widget.configurationId != null && - controller.advanced) - ShadButton.destructive( - enabled: controller.canDelete, - onPressed: controller.canDelete - ? () => controller.delete(context) - : null, - child: ButtonProgress( - busy: state.deleting, - child: Text(l10n.prototypeDelete), - ), - ), - if (!mobile) - ShadButton.outline( - onPressed: - state.closeBlocked || - state.confirmingClose || - state.closeAllowed - ? null - : () => controller.closePage(context), - child: Text(l10n.prototypeCancel), - ), - ShadButton( - enabled: controller.canSave, - onPressed: controller.canSave - ? () => controller.save(context) - : null, - child: ButtonProgress( - busy: state.loaded && state.busy && !state.deleting, - child: Text(l10n.prototypeSave), - ), - ), + if (mobile && delete != null) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + delete, + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: cancel), + const SizedBox(width: AppSpacing.actionGap), + Expanded(child: save), + ], + ), + ], + ) + else ...[ + ?delete, + cancel, + save, + ], ], ), ), diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index e7e0826df..eb50055ed 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -300,11 +300,10 @@ class _CustomRoutingEditorPageState extends State { ), PageActionBar( children: [ - if (!mobile) - ShadButton.outline( - onPressed: () => controller.cancel(context), - child: Text(l.prototypeCancel), - ), + ShadButton.outline( + onPressed: () => controller.cancel(context), + child: Text(l.prototypeCancel), + ), ShadButton( enabled: !state.busy && diff --git a/lib/pages/connect/routing/smart/exit_picker.dart b/lib/pages/connect/routing/smart/exit_picker.dart index 07c8cf5ca..7c49a9ee7 100644 --- a/lib/pages/connect/routing/smart/exit_picker.dart +++ b/lib/pages/connect/routing/smart/exit_picker.dart @@ -175,11 +175,10 @@ class ServerExitPickerView extends StatelessWidget { bottomNavigationBar: PageActionBar( maxWidth: AppLayout.routingEditorMaxWidth, children: [ - if (!mobile) - OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l.prototypeCancel), - ), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l.prototypeCancel), + ), FilledButton( onPressed: controller.canFinish ? () => controller.complete(context) diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index d4a9d89c6..99b07df9c 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -105,11 +105,10 @@ class _SmartRoutingEditorPageState extends State { ), PageActionBar( children: [ - if (!mobile) - OutlinedButton( - onPressed: () => controller.cancel(context), - child: Text(l.prototypeCancel), - ), + OutlinedButton( + onPressed: () => controller.cancel(context), + child: Text(l.prototypeCancel), + ), FilledButton( onPressed: state.busy || state.original == null ? null diff --git a/lib/pages/connect/routing/smart/regions.dart b/lib/pages/connect/routing/smart/regions.dart index 2e8ff6e43..42613ef8d 100644 --- a/lib/pages/connect/routing/smart/regions.dart +++ b/lib/pages/connect/routing/smart/regions.dart @@ -247,11 +247,10 @@ class _DirectRegionsPageState extends State { bottomNavigationBar: PageActionBar( maxWidth: AppLayout.routingEditorMaxWidth, children: [ - if (!mobile) - OutlinedButton( - onPressed: () => controller.cancel(context), - child: Text(l.prototypeCancel), - ), + OutlinedButton( + onPressed: () => controller.cancel(context), + child: Text(l.prototypeCancel), + ), FilledButton( onPressed: state.busy || state.failed ? null diff --git a/lib/pages/servers/editor/page.dart b/lib/pages/servers/editor/page.dart index 067fd7f27..30f88e002 100644 --- a/lib/pages/servers/editor/page.dart +++ b/lib/pages/servers/editor/page.dart @@ -7,7 +7,6 @@ import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; class ServerEditorPage extends StatelessWidget { @@ -30,8 +29,6 @@ class ServerEditorPage extends StatelessWidget { final controller = context.read(); final l = AppLocalizations.of(context)!; final p = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return PopScope( canPop: state.canPop, onPopInvokedWithResult: (didPop, result) { @@ -41,13 +38,8 @@ class ServerEditorPage extends StatelessWidget { title: l.prototypeEditServer, subtitle: state.name, onClose: () => controller.closePage(context), - body: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 16 : 28, - 16, - mobile ? 16 : 28, - 0, - ), + body: ServerFormSection( + title: l.configurationContentTitle, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart index 4adc4458a..d2d871fd0 100644 --- a/lib/pages/servers/form_page.dart +++ b/lib/pages/servers/form_page.dart @@ -5,6 +5,33 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/layout.dart'; + +/// A single configuration card within a server form. Other form sections keep +/// their own surfaces instead of receiving a second page-wide card. +class ServerFormSection extends StatelessWidget { + const ServerFormSection({ + super.key, + required this.title, + required this.child, + }); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final gutter = mobile ? 16.0 : 28.0; + return SettingSection( + title: title, + padding: EdgeInsets.fromLTRB(gutter, 16, gutter, 0), + children: [child], + ); + } +} /// Full content presentation for import and server editing. Choice and /// confirmation dialogs keep their existing modal surfaces. diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 98752b474..6b82df276 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -251,7 +251,7 @@ class ServerImportFormPage extends StatelessWidget { child: _subscription(context), ) else if (manual) - _manual(context, mobile: mobile) + _manual(context) else _paste(context), _ImportFeedback(state: state, showError: !manual), @@ -355,11 +355,11 @@ class ServerImportFormPage extends StatelessWidget { ); } - Widget _manual(BuildContext context, {required bool mobile}) { + Widget _manual(BuildContext context) { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - return Padding( - padding: EdgeInsets.fromLTRB(mobile ? 16 : 28, 16, mobile ? 16 : 28, 0), + return ServerFormSection( + title: l10n.configurationContentTitle, child: Column( spacing: 7, crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/lib/pages/shared/widgets/page_action_bar.dart b/lib/pages/shared/widgets/page_action_bar.dart index 970cabc95..e067f04a0 100644 --- a/lib/pages/shared/widgets/page_action_bar.dart +++ b/lib/pages/shared/widgets/page_action_bar.dart @@ -30,6 +30,10 @@ class PageActionBar extends StatelessWidget { final minHeight = mobile ? AppLayout.mobilePageActionMinHeight : AppLayout.pageActionMinHeight; + final buttonMinHeight = AppLayout.touchExtent( + mobile ? AppLayout.mobileButtonMinHeight : AppLayout.buttonMinHeight, + platform: Theme.of(context).platform, + ); return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: Material( @@ -52,8 +56,7 @@ class PageActionBar extends StatelessWidget { horizontalPadding ?? (mobile ? AppSpacing.mobilePage : AppSpacing.page), vertical: - verticalPadding ?? - (minHeight - AppLayout.pageActionButtonMinHeight) / 2, + verticalPadding ?? (minHeight - buttonMinHeight) / 2, ), child: SizedBox( width: double.infinity, @@ -77,9 +80,8 @@ class PageActionBar extends StatelessWidget { if (index > 0) SizedBox(width: spacing), Expanded( child: ConstrainedBox( - constraints: const BoxConstraints( - minHeight: AppLayout - .pageActionButtonMinHeight, + constraints: BoxConstraints( + minHeight: buttonMinHeight, ), child: children[index], ), @@ -89,23 +91,22 @@ class PageActionBar extends StatelessWidget { ), ), ) - : Wrap( - alignment: WrapAlignment.end, - spacing: spacing, - runSpacing: AppSpacing.actionRunGap, - children: [ - for (final child in children) - ConstrainedBox( - constraints: BoxConstraints( - minWidth: mobile - ? 0 - : AppLayout.pageActionButtonMinWidth, - minHeight: - AppLayout.pageActionButtonMinHeight, + : _wideButtons( + context, + Wrap( + alignment: WrapAlignment.end, + spacing: spacing, + runSpacing: AppSpacing.actionRunGap, + children: [ + for (final child in children) + ConstrainedBox( + constraints: BoxConstraints( + minHeight: buttonMinHeight, + ), + child: child, ), - child: child, - ), - ], + ], + ), ), ), ), @@ -116,4 +117,22 @@ class PageActionBar extends StatelessWidget { ), ); } + + Widget _wideButtons(BuildContext context, Widget child) { + final shad = ShadTheme.maybeOf(context); + if (shad == null) return child; + // Shad paints the outline's two border pixels outside its content height. + final sizes = shad.outlineButtonTheme.sizesTheme ?? shad.buttonSizesTheme; + final regular = sizes.regular ?? shad.buttonSizesTheme.regular!; + return ShadTheme( + data: shad.copyWith( + outlineButtonTheme: shad.outlineButtonTheme.copyWith( + sizesTheme: sizes.copyWith( + regular: regular.copyWith(height: regular.height - 2), + ), + ), + ), + child: child, + ); + } } diff --git a/lib/pages/theme/layout.dart b/lib/pages/theme/layout.dart index dbafe128d..f842844f3 100644 --- a/lib/pages/theme/layout.dart +++ b/lib/pages/theme/layout.dart @@ -27,7 +27,6 @@ abstract final class AppLayout { static const pageActionMinHeight = 72.0; static const mobilePageActionMinHeight = 64.0; static const pageActionButtonMinHeight = 48.0; - static const pageActionButtonMinWidth = 150.0; static const buttonMinHeight = 42.0; static const mobileButtonMinHeight = 48.0; static const setupContentMaxWidth = 760.0; diff --git a/test/pages/connect/raw_editor_test.dart b/test/pages/connect/raw_editor_test.dart index dc038c1bf..0d3b4abfe 100644 --- a/test/pages/connect/raw_editor_test.dart +++ b/test/pages/connect/raw_editor_test.dart @@ -200,6 +200,123 @@ void main() { }, ); + for (final (advanced, existing) in [ + (false, false), + (true, false), + (true, true), + ]) { + testWidgets( + '${advanced ? "advanced" : "Raw"} mobile footer keeps cancel guarded with large text and keyboard (existing: $existing)', + (tester) async { + final fixture = _DiagnosticFixture( + advanced, + configurationId: existing ? 7 : null, + ); + addTearDown(fixture.dispose); + final session = await _mountFullEditor( + tester, + fixture, + size: const Size(320, 844), + textScale: 1.5, + locale: const Locale('ru'), + ); + final controller = fixture.controller; + final l = AppLocalizations.of(session.context)!; + controller.name.text = 'Unsaved'; + controller.text.text = '{"outbounds":[{}]}'; + await tester.pump(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + tester + .widget(find.byType(CodeEditor)) + .focusNode! + .requestFocus(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final cancel = find.widgetWithText(ShadButton, l.prototypeCancel); + final save = find.widgetWithText(ShadButton, l.prototypeSave); + final delete = find.widgetWithText(ShadButton, l.prototypeDelete); + for (final action in [cancel, save, if (existing) delete]) { + expect(action.hitTestable(), findsOneWidget); + final rect = tester.getRect(action); + expect(rect.height, greaterThanOrEqualTo(48)); + expect(rect.left, greaterThanOrEqualTo(0)); + expect(rect.right, lessThanOrEqualTo(320)); + expect(rect.bottom, lessThanOrEqualTo(544)); + } + expect( + tester.getRect(cancel).top, + closeTo(tester.getRect(save).top, .01), + ); + if (existing) { + expect( + tester.getRect(cancel).top - tester.getRect(delete).bottom, + closeTo(8, .01), + ); + expect(tester.widget(delete).enabled, isTrue); + } else { + expect(delete, findsNothing); + } + expect(tester.takeException(), isNull); + + await _requestFullEditorExit(tester, 'cancel', settle: false); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byType(AppConfirmationDialog), findsOneWidget); + expect(controller.state.confirmingClose, isTrue); + expect(tester.widget(cancel).enabled, isFalse); + expect(tester.widget(cancel).onPressed, isNull); + await tester.tap(find.text(l.jsonEditorKeepEditing)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(controller.name.text, 'Unsaved'); + expect(controller.text.text, '{"outbounds":[{}]}'); + expect(session.result.isCompleted, isFalse); + + final completion = Completer(); + addTearDown(() { + if (!completion.isCompleted) completion.complete(null); + }); + fixture.onSave(() => completion.future); + final saving = controller.save(session.context); + await tester.pump(const Duration(milliseconds: 100)); + expect(controller.state.busy, isTrue); + for (final action in [cancel, save, if (existing) delete]) { + expect(tester.widget(action).enabled, isFalse); + expect(tester.widget(action).onPressed, isNull); + } + expect(session.result.isCompleted, isFalse); + completion.completeError( + const FormatException('Configuration rejected'), + ); + await saving.timeout(const Duration(seconds: 3)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(controller.state.dirty, isTrue); + expect(tester.widget(cancel).enabled, isTrue); + + await _requestFullEditorExit(tester, 'cancel', settle: false); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text(l.jsonEditorDiscardChanges)); + await tester.pump(); + // The dialog and editor reverse in sequence. The popped result can + // complete before the editor's own route transition has been removed. + for ( + var frame = 0; + frame < 20 && + find.byType(JsonConfigurationEditorPage).evaluate().isNotEmpty; + frame++ + ) { + await tester.pump(const Duration(milliseconds: 50)); + } + expect(session.result.isCompleted, isTrue); + expect(await session.result.future, isNull); + expect(find.byType(JsonConfigurationEditorPage), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + for (final advanced in [false, true]) { final label = advanced ? 'advanced' : 'Raw'; for (final restored in [false, true]) { @@ -610,12 +727,30 @@ void main() { } class _DiagnosticFixture { - _DiagnosticFixture(this.advanced, {ConfigurationTransferService? transfer}) { + _DiagnosticFixture( + this.advanced, { + this.configurationId, + ConfigurationTransferService? transfer, + }) { coordinator = ConnectionCoordinator(database: db); raw = _DiagnosticRawSave(database: db, coordinator: coordinator); custom = _DiagnosticAdvancedSave(database: db, coordinator: coordinator); + if (advanced && configurationId != null) { + custom.current = CustomRoutingEditorDraft( + original: RoutingProfileData( + id: configurationId!, + name: 'Saved advanced', + advanced: true, + data: '{"outbounds":[{}]}', + ), + state: AdvancedRoutingDocument.parse( + '{"outbounds":[{}]}', + name: 'Saved advanced', + ).state, + ); + } controller = JsonConfigurationEditorController( - configurationId: null, + configurationId: configurationId, kind: advanced ? ConfigurationKind.customAdvanced : ConfigurationKind.raw, service: raw, customService: custom, @@ -624,6 +759,7 @@ class _DiagnosticFixture { } final db = AppDatabase.forTesting(NativeDatabase.memory()); final bool advanced; + final int? configurationId; late final ConnectionCoordinator coordinator; late final _DiagnosticRawSave raw; late final _DiagnosticAdvancedSave custom; @@ -647,22 +783,29 @@ class _DiagnosticFixture { Future<({BuildContext context, Completer result})> _mountFullEditor( WidgetTester tester, - _DiagnosticFixture fixture, -) async { + _DiagnosticFixture fixture, { + Size size = const Size(1200, 1000), + double textScale = 1, + Locale locale = const Locale('en'), +}) async { tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 1000); + tester.view.physicalSize = size; addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); final result = Completer(); await tester.pumpWidget( MaterialApp( theme: AppTheme.light, - locale: const Locale('en'), + locale: locale, localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - builder: (_, child) => ShadTheme( - data: AppTheme.shad(Brightness.light), - child: ShadToaster(child: child!), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), ), home: Builder( builder: (context) => Scaffold( @@ -671,6 +814,7 @@ Future<({BuildContext context, Completer result})> _mountFullEditor( await Navigator.of(context).push( MaterialPageRoute( builder: (_) => JsonConfigurationEditorPage( + configurationId: fixture.configurationId, kind: fixture.advanced ? ConfigurationKind.customAdvanced : ConfigurationKind.raw, diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index 23a82b159..f4422ff6b 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -733,9 +733,13 @@ void main() { ); await tester.binding.setSurfaceSize(const Size(1200, 800)); await tester.pumpAndSettle(); + final saveButton = tester.getRect(find.byType(FilledButton)); + final saveLabel = tester.getRect(find.text('Save')); + expect(saveButton.width, greaterThan(saveLabel.width)); + expect(saveButton.width, lessThan(150)); expect( - tester.getSize(find.byType(FilledButton)).width, - greaterThanOrEqualTo(AppLayout.pageActionButtonMinWidth), + saveButton.width, + lessThan(tester.getSize(find.byType(TextButton)).width), ); expect( tester.getSize(find.byType(PageActionBar)).height, diff --git a/test/pages/servers/editor_dismissal_test.dart b/test/pages/servers/editor_dismissal_test.dart index 656f3a0df..ac29f38c5 100644 --- a/test/pages/servers/editor_dismissal_test.dart +++ b/test/pages/servers/editor_dismissal_test.dart @@ -9,13 +9,14 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/servers/editor/page.dart'; +import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/server.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:re_editor/re_editor.dart'; -import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadCard, ShadTheme, ShadToaster; const _initial = '{"tag":"Original","protocol":"freedom"}'; const _edited = @@ -54,10 +55,11 @@ void main() { }); Future<({BuildContext context, Completer result})> openEditor( - WidgetTester tester, - ) async { + WidgetTester tester, { + Size size = const Size(1200, 1000), + }) async { tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 1000); + tester.view.physicalSize = size; addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); final result = Completer(); @@ -128,6 +130,57 @@ void main() { } } + testWidgets('node JSON card keeps its draft and save above the keyboard', ( + tester, + ) async { + final session = await openEditor(tester, size: const Size(390, 844)); + final l = AppLocalizations.of(session.context)!; + final editor = find.byType(OutboundJsonEditor); + final card = find.ancestor(of: editor, matching: find.byType(ShadCard)); + expect(card, findsOneWidget); + expect( + find.descendant( + of: card, + matching: find.text(l.configurationContentTitle), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: card, + matching: find.text(l.prototypeXrayOutboundJson), + ), + findsOneWidget, + ); + expect(tester.getRect(editor).left, greaterThan(tester.getRect(card).left)); + expect(tester.getRect(editor).right, lessThan(tester.getRect(card).right)); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + tester + .widget(find.byType(CodeEditor)) + .focusNode! + .requestFocus(); + await tester.pump(); + await tester.ensureVisible(editor); + await tester.pump(const Duration(milliseconds: 200)); + + final save = find.widgetWithText(FilledButton, l.prototypeSave); + expect(save.hitTestable(), findsOneWidget); + expect(tester.widget(save).onPressed, isNotNull); + expect(tester.getRect(save).bottom, lessThanOrEqualTo(544)); + expect( + tester + .getRect(find.byType(CodeEditor)) + .overlaps(Rect.fromLTRB(0, 0, 390, tester.getRect(save).top)), + isTrue, + ); + expect(controller.text.text, _initial); + expect(controller.state.dirty, isFalse); + expect(tester.takeException(), isNull); + }); + for (final restored in [false, true]) { testWidgets('clean editor closes without saving (restored: $restored)', ( tester, diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index a54909545..ea28bcb9c 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -18,6 +18,7 @@ import 'package:onexray/pages/servers/subscription/form_view.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/shared/db/config_writer.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; @@ -27,6 +28,7 @@ import 'package:onexray/service/servers/outbound/state_db.dart'; import 'package:onexray/service/connect/raw/validator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:package_info_plus/package_info_plus.dart'; +import 'package:re_editor/re_editor.dart' show CodeEditor; void main() { setUp(() { @@ -34,6 +36,70 @@ void main() { addTearDown(bus.close); }); + testWidgets('manual JSON card keeps its input and add above the keyboard', ( + tester, + ) async { + _mobileViewport(tester); + final controller = ServerImportController( + loadSubscription: (_) async => null, + ); + addTearDown(controller.close); + const source = '{"tag":"Manual draft","protocol":"freedom"}'; + controller.jsonText.text = source; + await tester.pumpWidget( + _app( + ServerImportFormPage( + controller: controller, + action: ServerImportAction.json, + ), + ), + ); + await tester.pump(); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportFormPage)), + )!; + final editor = find.byType(OutboundJsonEditor); + final card = find.ancestor(of: editor, matching: find.byType(ShadCard)); + expect(card, findsOneWidget); + expect( + find.descendant( + of: card, + matching: find.text(l.configurationContentTitle), + ), + findsOneWidget, + ); + expect( + find.descendant(of: card, matching: find.text(l.prototypeXrayNodeJson)), + findsOneWidget, + ); + expect(tester.getRect(editor).left, greaterThan(tester.getRect(card).left)); + expect(tester.getRect(editor).right, lessThan(tester.getRect(card).right)); + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + tester + .widget(find.byType(CodeEditor)) + .focusNode! + .requestFocus(); + await tester.pump(); + await tester.ensureVisible(editor); + await tester.pump(const Duration(milliseconds: 200)); + + final add = find.widgetWithText(FilledButton, l.prototypeAdd); + expect(add.hitTestable(), findsOneWidget); + expect(tester.widget(add).onPressed, isNotNull); + expect(tester.getRect(add).bottom, lessThanOrEqualTo(544)); + expect( + tester + .getRect(find.byType(CodeEditor)) + .overlaps(Rect.fromLTRB(0, 0, 390, tester.getRect(add).top)), + isTrue, + ); + expect(controller.jsonText.text, source); + expect(tester.takeException(), isNull); + }); + test( 'panels share the default UA and preserve manual input while loading', () async { diff --git a/test/pages/shared/widgets/page_action_bar_test.dart b/test/pages/shared/widgets/page_action_bar_test.dart index 1c2fda812..28f2a26bf 100644 --- a/test/pages/shared/widgets/page_action_bar_test.dart +++ b/test/pages/shared/widgets/page_action_bar_test.dart @@ -12,6 +12,8 @@ Widget _app({ required bool shad, bool mobile = true, double bottomInset = 0, + double keyboardInset = 0, + bool saveEnabled = true, String saveLabel = 'Save', VoidCallback? onCancel, VoidCallback? onSave, @@ -22,6 +24,7 @@ Widget _app({ textScaler: TextScaler.noScaling, padding: EdgeInsets.only(bottom: bottomInset), viewPadding: EdgeInsets.only(bottom: bottomInset), + viewInsets: EdgeInsets.only(bottom: keyboardInset), ), child: ShadTheme( data: AppTheme.shad(Brightness.light, mobile: mobile), @@ -40,7 +43,7 @@ Widget _app({ ), ShadButton( key: _save, - onPressed: onSave ?? () {}, + onPressed: saveEnabled ? (onSave ?? () {}) : null, child: Text(saveLabel), ), ] @@ -52,7 +55,7 @@ Widget _app({ ), FilledButton( key: _save, - onPressed: onSave ?? () {}, + onPressed: saveEnabled ? (onSave ?? () {}) : null, child: Text(saveLabel), ), ], @@ -145,26 +148,104 @@ void main() { }), ); - testWidgets('$variant desktop footer preserves compact right alignment', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1200, 900); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpWidget(_app(shad: shad, mobile: false)); - await tester.pumpAndSettle(); + for (final (platform, height) in [ + (TargetPlatform.macOS, 42.0), + (TargetPlatform.iOS, 44.0), + ]) { + testWidgets('$variant wide footer uses natural widths on $platform', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1200, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final actions = []; + await tester.pumpWidget( + _app( + shad: shad, + mobile: false, + onCancel: () => actions.add('cancel'), + onSave: () => actions.add('save'), + ), + ); + await tester.pumpAndSettle(); + final cancel = tester.getRect(find.byKey(_cancel)); + final save = tester.getRect(find.byKey(_save)); + for (final key in [_cancel, _save]) { + final bounds = tester.getRect(find.byKey(key)); + final label = tester.getRect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RichText), + ), + ); + expect( + bounds.width, + closeTo( + label.width + + AppSpacing.buttonHorizontal * 2 + + (shad && key == _cancel ? 2 : 0), + .01, + ), + ); + expect(bounds.width, lessThan(150)); + expect(bounds.height, height); + } + expect(cancel.width, greaterThan(save.width)); + expect(save.left - cancel.right, AppSpacing.actionGap); + expect( + save.right, + (1200 + AppLayout.standardMaxWidth) / 2 - AppSpacing.page, + ); + expect( + tester.getSize(find.byType(PageActionBar)).height, + AppLayout.pageActionMinHeight, + ); + await tester.tap(find.byKey(_cancel)); + await tester.tap(find.byKey(_save)); + await tester.pump(); + expect(actions, ['cancel', 'save']); + expect(tester.takeException(), isNull); + }, variant: TargetPlatformVariant.only(platform)); + } - final cancel = tester.getRect(find.byKey(_cancel)); - final save = tester.getRect(find.byKey(_save)); - expect(cancel.width, AppLayout.pageActionButtonMinWidth); - expect(save.width, AppLayout.pageActionButtonMinWidth); - expect(save.left - cancel.right, AppSpacing.actionGap); - expect( - save.right, - (1200 + AppLayout.standardMaxWidth) / 2 - AppSpacing.page, - ); - expect(tester.takeException(), isNull); - }); + testWidgets( + '$variant iPad short footer avoids the keyboard and preserves disabled actions', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1032, 360); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + var cancels = 0; + var saves = 0; + await tester.pumpWidget( + _app( + shad: shad, + mobile: false, + bottomInset: 20, + keyboardInset: 160, + saveEnabled: false, + onCancel: () => cancels++, + onSave: () => saves++, + ), + ); + await tester.pumpAndSettle(); + final action = tester.getRect(find.byKey(_save)); + expect(action.height, 44); + expect(action.bottom, lessThanOrEqualTo(360 - 160 - 20)); + expect(find.byKey(_cancel).hitTestable(), findsOneWidget); + await tester.tap(find.byKey(_save)); + await tester.tap(find.byKey(_cancel)); + await tester.pumpAndSettle(); + expect(saves, 0); + expect(cancels, 1); + expect( + tester.getSize(find.byType(PageActionBar)).height, + 72 + 20 + 160, + ); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); } } From 30f3ff214d0a06e0fea94c6bc6f38d4a37aa0fe6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 09:18:58 +0800 Subject: [PATCH 21/60] fix(ui): finish detail layouts and prevent action label clipping --- lib/l10n/app_en.arb | 14 +- lib/l10n/app_fa.arb | 14 +- lib/l10n/app_ru.arb | 14 +- lib/l10n/app_zh.arb | 14 +- lib/l10n/app_zh_Hant.arb | 14 +- lib/pages/advanced/tunnel/apple/page.dart | 1 + lib/pages/advanced/tunnel/details.dart | 2 + lib/pages/advanced/xray/data_update/page.dart | 120 +++--- lib/pages/advanced/xray/dialing/page.dart | 77 ++-- lib/pages/advanced/xray/fields.dart | 74 ++++ lib/pages/advanced/xray/log/settings.dart | 1 + lib/pages/advanced/xray/ping/page.dart | 210 ++++----- lib/pages/connect/json_editor/page.dart | 403 ++++++++++++------ lib/pages/settings/widgets.dart | 60 +-- test/pages/advanced/xray/dialing_test.dart | 9 + .../pages/advanced/xray/settings_ui_test.dart | 51 +++ test/pages/settings/backup_test.dart | 85 ++++ 17 files changed, 769 insertions(+), 394 deletions(-) create mode 100644 lib/pages/advanced/xray/fields.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9fecdf11d..d71a663c7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3241,5 +3241,17 @@ "xrayCurrentConnection": "Current connection", "xrayNotRunning": "Not running", "xrayLogsEnabled": "Recording enabled", - "xrayLogsDisabled": "Recording disabled" + "xrayLogsDisabled": "Recording disabled", + "tunnelDetailDescription": "View and manage how this device connects.", + "pageXrayConfigurationDescription": "Manage configuration used by the core and connections.", + "xrayLatencyTitle": "Latency probing", + "xrayAutomaticSubscriptionUpdates": "Automatically update subscriptions", + "xraySubscriptionUpdateInterval": "Subscription update interval", + "xrayAutomaticGeodataUpdates": "Automatically update routing data", + "xrayGeodataUpdateInterval": "Routing data update interval", + "serverDialingParametersTitle": "Address resolution and connection", + "routingAdvancedCreateTitle": "New advanced template", + "routingAdvancedEditTitle": "Edit advanced template", + "routingAdvancedCardTitle": "Advanced JSON template", + "configurationNameLabel": "Name" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index ecb537219..6a99cec8c 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1060,5 +1060,17 @@ "xrayCurrentConnection": "اتصال فعلی", "xrayNotRunning": "در حال اجرا نیست", "xrayLogsEnabled": "ثبت رویداد فعال است", - "xrayLogsDisabled": "ثبت رویداد غیرفعال است" + "xrayLogsDisabled": "ثبت رویداد غیرفعال است", + "tunnelDetailDescription": "روش اتصال این دستگاه را مشاهده و مدیریت کنید.", + "pageXrayConfigurationDescription": "پیکربندی مورد استفاده هسته و اتصال‌ها را مدیریت کنید.", + "xrayLatencyTitle": "بررسی تأخیر", + "xrayAutomaticSubscriptionUpdates": "به‌روزرسانی خودکار اشتراک‌ها", + "xraySubscriptionUpdateInterval": "بازه به‌روزرسانی اشتراک‌ها", + "xrayAutomaticGeodataUpdates": "به‌روزرسانی خودکار داده‌های مسیریابی", + "xrayGeodataUpdateInterval": "بازه به‌روزرسانی داده‌های مسیریابی", + "serverDialingParametersTitle": "حل آدرس و اتصال", + "routingAdvancedCreateTitle": "قالب پیشرفته جدید", + "routingAdvancedEditTitle": "ویرایش قالب پیشرفته", + "routingAdvancedCardTitle": "قالب پیشرفته JSON", + "configurationNameLabel": "نام" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2a137c609..8dc3d9c91 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1060,5 +1060,17 @@ "xrayCurrentConnection": "Текущее подключение", "xrayNotRunning": "Не запущено", "xrayLogsEnabled": "Запись включена", - "xrayLogsDisabled": "Запись выключена" + "xrayLogsDisabled": "Запись выключена", + "tunnelDetailDescription": "Просматривайте и настраивайте подключение этого устройства.", + "pageXrayConfigurationDescription": "Настройка параметров ядра и подключений.", + "xrayLatencyTitle": "Проверка задержки", + "xrayAutomaticSubscriptionUpdates": "Автоматически обновлять подписки", + "xraySubscriptionUpdateInterval": "Интервал обновления подписок", + "xrayAutomaticGeodataUpdates": "Автоматически обновлять данные маршрутизации", + "xrayGeodataUpdateInterval": "Интервал обновления данных маршрутизации", + "serverDialingParametersTitle": "Разрешение адресов и подключение", + "routingAdvancedCreateTitle": "Новый расширенный шаблон", + "routingAdvancedEditTitle": "Изменить расширенный шаблон", + "routingAdvancedCardTitle": "Расширенный шаблон JSON", + "configurationNameLabel": "Название" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d238809eb..400298302 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1060,5 +1060,17 @@ "xrayCurrentConnection": "当前连接", "xrayNotRunning": "尚未运行", "xrayLogsEnabled": "记录已开启", - "xrayLogsDisabled": "记录已关闭" + "xrayLogsDisabled": "记录已关闭", + "tunnelDetailDescription": "查看并管理此设备的连接方式。", + "pageXrayConfigurationDescription": "管理内核与连接使用的配置。", + "xrayLatencyTitle": "延迟探测", + "xrayAutomaticSubscriptionUpdates": "自动更新订阅", + "xraySubscriptionUpdateInterval": "订阅更新周期", + "xrayAutomaticGeodataUpdates": "自动更新路由数据", + "xrayGeodataUpdateInterval": "路由数据更新周期", + "serverDialingParametersTitle": "地址解析与连接", + "routingAdvancedCreateTitle": "新建高级模板", + "routingAdvancedEditTitle": "编辑高级模板", + "routingAdvancedCardTitle": "高级 JSON 模板", + "configurationNameLabel": "名称" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 7911e7837..d2094297d 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1060,5 +1060,17 @@ "xrayCurrentConnection": "目前連線", "xrayNotRunning": "尚未執行", "xrayLogsEnabled": "記錄已開啟", - "xrayLogsDisabled": "記錄已關閉" + "xrayLogsDisabled": "記錄已關閉", + "tunnelDetailDescription": "檢視並管理此裝置的連線方式。", + "pageXrayConfigurationDescription": "管理核心與連線使用的設定。", + "xrayLatencyTitle": "延遲探測", + "xrayAutomaticSubscriptionUpdates": "自動更新訂閱", + "xraySubscriptionUpdateInterval": "訂閱更新週期", + "xrayAutomaticGeodataUpdates": "自動更新路由資料", + "xrayGeodataUpdateInterval": "路由資料更新週期", + "serverDialingParametersTitle": "位址解析與連線", + "routingAdvancedCreateTitle": "新增進階範本", + "routingAdvancedEditTitle": "編輯進階範本", + "routingAdvancedCardTitle": "進階 JSON 範本", + "configurationNameLabel": "名稱" } diff --git a/lib/pages/advanced/tunnel/apple/page.dart b/lib/pages/advanced/tunnel/apple/page.dart index b38f799d3..cb86e57aa 100644 --- a/lib/pages/advanced/tunnel/apple/page.dart +++ b/lib/pages/advanced/tunnel/apple/page.dart @@ -51,6 +51,7 @@ class AppleVpnPage extends StatelessWidget { final controller = context.read(); return PolicyDetailScaffold( title: l.prototypeAppleSystemVpn, + description: l.tunnelDetailDescription, help: [ l.prototypeAllowLocalNetworkHint, l.prototypeBypassCellularServicesHint, diff --git a/lib/pages/advanced/tunnel/details.dart b/lib/pages/advanced/tunnel/details.dart index 3f62607ac..f082ed7f2 100644 --- a/lib/pages/advanced/tunnel/details.dart +++ b/lib/pages/advanced/tunnel/details.dart @@ -44,6 +44,7 @@ class TunnelDnsPage extends StatelessWidget { ); return PolicyDetailScaffold( title: l.prototypeTunnelDns, + description: l.tunnelDetailDescription, controller: controller, help: [ l.tunnelDnsServerNameHint, @@ -120,6 +121,7 @@ class TunnelNetworkPage extends StatelessWidget { final l = AppLocalizations.of(context)!; return PolicyDetailScaffold( title: 'IPv6', + description: l.tunnelDetailDescription, controller: controller, canSave: controller.validationHint(l) == null, contentPadding: EdgeInsets.zero, diff --git a/lib/pages/advanced/xray/data_update/page.dart b/lib/pages/advanced/xray/data_update/page.dart index bbc48b8d3..c1c286b2d 100644 --- a/lib/pages/advanced/xray/data_update/page.dart +++ b/lib/pages/advanced/xray/data_update/page.dart @@ -1,4 +1,3 @@ -import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/shared/widgets/page_help.dart'; @@ -14,7 +13,7 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; class AutoUpdatePage extends StatelessWidget { const AutoUpdatePage({super.key}); @@ -34,7 +33,8 @@ class AutoUpdatePage extends StatelessWidget { return Scaffold( appBar: PageAppBar.forPage( context, - title: Text(l.prototypeDataUpdates), + title: Text(l.prototypeAutomaticUpdates), + description: l.pageXrayConfigurationDescription, actions: [ const AppActivityIndicator(pinging: false), PageHelpButton( @@ -100,48 +100,59 @@ class AutoUpdatePage extends StatelessWidget { spacing: 20, children: [ SettingSection( - title: l.prototypeSubscriptions, - icon: LucideIcons.refreshCw, + title: l.prototypeUpdateInterval, padding: EdgeInsets.zero, - dividerIndent: 0, children: [ - _automaticUpdates( - mobile: mobile, - title: l.prototypeAutomaticUpdates, + XrayToggleField( + title: l.xrayAutomaticSubscriptionUpdates, value: value.subscriptionEnabled, onChanged: state.saving ? null : controller.updateSubscriptionEnabled, ), - _interval( - l, - value.subscriptionInterval, - value.subscriptionEnabled && !state.saving, - controller.updateSubscriptionInterval, - mobile: mobile, + const SizedBox(height: 16), + XrayLabeledField( + key: const ValueKey( + 'subscription-update-interval-field', + ), + label: l.xraySubscriptionUpdateInterval, + child: SettingSelect( + key: const ValueKey( + 'subscription-update-interval', + ), + value: value.subscriptionInterval, + entries: _intervals(l), + onChanged: + value.subscriptionEnabled && !state.saving + ? controller.updateSubscriptionInterval + : null, + ), ), - ], - ), - SettingSection( - title: l.prototypeRoutingData, - icon: LucideIcons.globe2, - padding: EdgeInsets.zero, - dividerIndent: 0, - children: [ - _automaticUpdates( - mobile: mobile, - title: l.prototypeAutomaticUpdates, + const SizedBox(height: 24), + XrayToggleField( + title: l.xrayAutomaticGeodataUpdates, value: value.geoDataEnable, onChanged: state.saving ? null : controller.updateGeoDataEnable, ), - _interval( - l, - value.geoDataInterval, - value.geoDataEnable && !state.saving, - controller.updateGeoDataInterval, - mobile: mobile, + const SizedBox(height: 16), + XrayLabeledField( + key: const ValueKey( + 'geodata-update-interval-field', + ), + label: l.xrayGeodataUpdateInterval, + child: SettingSelect( + key: const ValueKey( + 'geodata-update-interval', + ), + value: value.geoDataInterval, + entries: _intervals(l), + onChanged: + value.geoDataEnable && !state.saving + ? controller.updateGeoDataInterval + : null, + ), ), ], ), @@ -162,44 +173,9 @@ class AutoUpdatePage extends StatelessWidget { ), ); - Widget _automaticUpdates({ - required bool mobile, - required String title, - required bool value, - required ValueChanged? onChanged, - }) => SettingRow( - title: title, - titleStyle: AppTypography.rowTitle, - minHeight: mobile ? 56 : 48, - contentPadding: const EdgeInsets.all(12), - enabled: onChanged != null, - onTap: onChanged == null ? null : () => onChanged(!value), - trailing: AppSwitch( - value: value, - enabled: onChanged != null, - onChanged: onChanged, - ), - ); - - Widget _interval( - AppLocalizations l, - AutoUpdateInterval value, - bool enabled, - ValueChanged onChanged, { - required bool mobile, - }) => SettingRow( - title: l.prototypeUpdateInterval, - titleStyle: AppTypography.rowTitle, - minHeight: mobile ? 56 : 48, - contentPadding: const EdgeInsets.all(12), - trailing: SettingSelect( - value: value, - entries: { - AutoUpdateInterval.oneDay: l.prototypeEveryDay, - AutoUpdateInterval.threeDays: l.prototypeEveryThreeDays, - AutoUpdateInterval.oneWeek: l.prototypeEveryWeek, - }, - onChanged: enabled ? onChanged : null, - ), - ); + Map _intervals(AppLocalizations l) => { + AutoUpdateInterval.oneDay: l.prototypeEveryDay, + AutoUpdateInterval.threeDays: l.prototypeEveryThreeDays, + AutoUpdateInterval.oneWeek: l.prototypeEveryWeek, + }; } diff --git a/lib/pages/advanced/xray/dialing/page.dart b/lib/pages/advanced/xray/dialing/page.dart index e4d9fe7aa..1c37571c2 100644 --- a/lib/pages/advanced/xray/dialing/page.dart +++ b/lib/pages/advanced/xray/dialing/page.dart @@ -1,4 +1,3 @@ -import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -6,6 +5,7 @@ import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/advanced/xray/dialing/controller.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/dialing.dart'; @@ -32,6 +32,7 @@ class ServerDialingPage extends StatelessWidget { : AppSpacing.advancedDesktopGutter(width); return PolicyDetailScaffold( title: l.serverDialingTitle, + description: l.pageXrayConfigurationDescription, controller: controller, canSave: state.draft != null, help: [ @@ -57,55 +58,45 @@ class ServerDialingPage extends StatelessWidget { : Padding( padding: EdgeInsets.fromLTRB(gutter, 20, gutter, 24), child: SettingSection( - title: l.serverDialingTitle, + title: l.serverDialingParametersTitle, padding: EdgeInsets.zero, children: [ - SettingRow( - title: l.serverDialingDomainStrategy, - trailing: SizedBox( - width: mobile ? 165 : 210, - child: SettingSelect( - key: const ValueKey('server-dialing-strategy'), - value: controller.dialing.domainStrategy, - entries: { - ServerDomainStrategy.system: - l.serverDialingSystem, - ServerDomainStrategy.useIp: l.serverDialingUseIp, - ServerDomainStrategy.ipv4: 'IPv4', - ServerDomainStrategy.ipv6: 'IPv6', - ServerDomainStrategy.ipv4First: - l.serverDialingIpv4First, - ServerDomainStrategy.ipv6First: - l.serverDialingIpv6First, - }, - onChanged: controller.blocked - ? null - : controller.setDomainStrategy, - ), + XrayLabeledField( + key: const ValueKey('server-dialing-domain-field'), + label: l.serverDialingDomainStrategy, + child: SettingSelect( + key: const ValueKey('server-dialing-strategy'), + value: controller.dialing.domainStrategy, + entries: { + ServerDomainStrategy.system: l.serverDialingSystem, + ServerDomainStrategy.useIp: l.serverDialingUseIp, + ServerDomainStrategy.ipv4: 'IPv4', + ServerDomainStrategy.ipv6: 'IPv6', + ServerDomainStrategy.ipv4First: + l.serverDialingIpv4First, + ServerDomainStrategy.ipv6First: + l.serverDialingIpv6First, + }, + onChanged: controller.blocked + ? null + : controller.setDomainStrategy, ), ), - SettingRow( + const SizedBox(height: 16), + XrayToggleField( + switchKey: const ValueKey( + 'server-dialing-happy-eyeballs', + ), title: 'Happy Eyeballs', - titleMaxLines: 3, - subtitle: controller.happyEyeballsAvailable + description: controller.happyEyeballsAvailable ? null : l.serverDialingHappyEyeballsUnavailable, - enabled: - !controller.blocked && - controller.happyEyeballsAvailable, - trailing: Semantics( - label: 'Happy Eyeballs', - child: AppSwitch( - key: const ValueKey( - 'server-dialing-happy-eyeballs', - ), - value: controller.dialing.happyEyeballs, - enabled: - !controller.blocked && - controller.happyEyeballsAvailable, - onChanged: controller.setHappyEyeballs, - ), - ), + value: controller.dialing.happyEyeballs, + onChanged: + controller.blocked || + !controller.happyEyeballsAvailable + ? null + : controller.setHappyEyeballs, ), ], ), diff --git a/lib/pages/advanced/xray/fields.dart b/lib/pages/advanced/xray/fields.dart new file mode 100644 index 000000000..e691c48a2 --- /dev/null +++ b/lib/pages/advanced/xray/fields.dart @@ -0,0 +1,74 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; + +/// Detail forms keep their labels above full-width controls on every device. +class XrayLabeledField extends StatelessWidget { + const XrayLabeledField({super.key, required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + Text( + label, + style: AppTypography.fieldLabel.copyWith( + color: ColorManager.primaryText(context), + ), + ), + Semantics( + label: label, + child: SizedBox(width: double.infinity, child: child), + ), + ], + ); +} + +class XrayToggleField extends StatelessWidget { + const XrayToggleField({ + super.key, + this.switchKey, + required this.title, + this.description, + required this.value, + required this.onChanged, + }); + + final Key? switchKey; + final String title; + final String? description; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) => SettingRow( + title: title, + subtitleWidget: description == null + ? null + : Text( + description!, + style: AppTypography.supporting.copyWith( + color: ColorManager.secondaryText(context), + ), + ), + titleMaxLines: 4, + enabled: onChanged != null, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + onTap: onChanged == null ? null : () => onChanged!(!value), + leading: Semantics( + label: title, + child: AppSwitch( + key: switchKey, + value: value, + enabled: onChanged != null, + onChanged: onChanged, + ), + ), + ); +} diff --git a/lib/pages/advanced/xray/log/settings.dart b/lib/pages/advanced/xray/log/settings.dart index 8d2bfe1a2..7a6d982ed 100644 --- a/lib/pages/advanced/xray/log/settings.dart +++ b/lib/pages/advanced/xray/log/settings.dart @@ -57,6 +57,7 @@ class XrayLogSettingsPage extends StatelessWidget { appBar: PageAppBar.forPage( context, title: Text(l.prototypeLogs), + description: l.pageXrayConfigurationDescription, leading: BackButton(onPressed: cancel), actions: [ PageHelpButton( diff --git a/lib/pages/advanced/xray/ping/page.dart b/lib/pages/advanced/xray/ping/page.dart index d55e8c31d..b6d29e400 100644 --- a/lib/pages/advanced/xray/ping/page.dart +++ b/lib/pages/advanced/xray/ping/page.dart @@ -13,6 +13,7 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; import 'package:onexray/service/shared/ping/state.dart'; class PingPage extends StatelessWidget { @@ -34,7 +35,8 @@ class PingPage extends StatelessWidget { return Scaffold( appBar: PageAppBar.forPage( context, - title: Text(l.prototypeSpeedTest), + title: Text(l.xrayLatencyTitle), + description: l.pageXrayConfigurationDescription, actions: [ const AppActivityIndicator(downloading: false), PageHelpButton( @@ -110,57 +112,14 @@ class PingPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SettingSection( - title: l.prototypeTimeout, - icon: LucideIcons.activity, + title: l.xrayLatencyTitle, padding: EdgeInsets.zero, children: [ - SettingRow( - title: l.prototypeTimeout, - titleStyle: AppTypography.settingsSelect, - contentPadding: - const EdgeInsetsDirectional.symmetric( - horizontal: 12, - vertical: 12, - ), - trailing: SettingSelect( - value: value.timeout, - entries: { - for ( - var seconds = PingTimeout.min; - seconds <= PingTimeout.max; - seconds++ - ) - seconds: l.prototypeSeconds( - seconds.round(), - ), - }, - onChanged: state.saving - ? null - : (seconds) { - if (seconds != null) { - controller.updateTimeout(seconds); - } - }, - ), - ), - ], - ), - const SizedBox(height: 16), - SettingSection( - title: l.prototypeSpeedTestUrl, - icon: LucideIcons.globe, - padding: EdgeInsets.zero, - dividerIndent: 0, - children: [ - SettingRow( - title: l.prototypeSpeedTestUrl, - titleStyle: AppTypography.settingsSelect, - contentPadding: - const EdgeInsetsDirectional.symmetric( - horizontal: 12, - vertical: 12, - ), - trailing: SettingSelect( + XrayLabeledField( + key: const ValueKey('ping-url-field'), + label: l.prototypeSpeedTestUrl, + child: SettingSelect( + key: const ValueKey('ping-url-preset'), value: value.url.name, entries: { for (final option in PingUrl.values) @@ -177,90 +136,95 @@ class PingPage extends StatelessWidget { }, ), ), + const SizedBox(height: 16), if (value.url == PingUrl.custom) + XrayLabeledField( + key: const ValueKey('ping-custom-url-field'), + label: l.prototypeCustomUrl, + child: TextField( + key: const ValueKey('ping-custom-url'), + controller: controller.customUrlController, + focusNode: controller.customUrlFocusNode, + enabled: !state.saving, + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + keyboardType: TextInputType.url, + autocorrect: false, + style: AppTypography.settingsInput.copyWith( + color: palette.foreground, + ), + decoration: InputDecoration( + errorText: state.invalidUrl + ? l.prototypeEnterHttpUrl + : null, + errorMaxLines: 3, + isDense: true, + hintText: + 'https://example.com/generate_204', + hintTextDirection: TextDirection.ltr, + hintStyle: AppTypography.settingsInput + .copyWith( + color: palette.mutedForeground, + ), + ), + ), + ) + else Padding( padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 12, ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - l.prototypeCustomUrl, - style: AppTypography.fieldLabel - .copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - TextField( - key: const ValueKey('ping-custom-url'), - controller: - controller.customUrlController, - focusNode: - controller.customUrlFocusNode, - enabled: !state.saving, - textDirection: TextDirection.ltr, - textAlign: TextAlign.left, - keyboardType: TextInputType.url, - autocorrect: false, - style: AppTypography.settingsInput - .copyWith( - color: palette.foreground, - ), - decoration: InputDecoration( - errorText: state.invalidUrl - ? l.prototypeEnterHttpUrl - : null, - errorMaxLines: 3, - isDense: true, - hintText: 'https://example.com/generate_204', - hintTextDirection: TextDirection.ltr, - hintStyle: AppTypography.settingsInput - .copyWith( - color: palette.mutedForeground, - ), - ), - ), - ], - ), - ) - else - SizedBox( - height: 46, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, + child: SelectableText( + value.realUrl, + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + style: AppTypography.rowValue.copyWith( + color: palette.mutedStrong, ), - child: Align( - alignment: Alignment.centerLeft, - child: SelectableText( - value.realUrl, - textDirection: TextDirection.ltr, - textAlign: TextAlign.left, - style: - (mobile - ? AppTypography - .settingsDetailNote - : AppTypography - .settingsInput) - .copyWith( - color: palette.mutedStrong, - ), + ), + ), + const SizedBox(height: 16), + XrayLabeledField( + key: const ValueKey('ping-timeout-field'), + label: l.prototypeTimeout, + child: SettingSelect( + key: const ValueKey('ping-timeout'), + value: value.timeout, + entries: { + for ( + var seconds = PingTimeout.min; + seconds <= PingTimeout.max; + seconds++ + ) + seconds: l.prototypeSeconds( + seconds.round(), ), - ), + }, + onChanged: state.saving + ? null + : (seconds) { + if (seconds != null) { + controller.updateTimeout(seconds); + } + }, + ), + ), + const SizedBox(height: 16), + ShadAlert( + icon: const Icon( + LucideIcons.circleHelp, + size: 18, + ), + description: Text( + l.pingVpnAccuracyHint, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, ), ), + ), ], ), - const SizedBox(height: 16), - Text( - l.pingVpnAccuracyHint, - style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, - ), - ), if (state.error != null) Padding( padding: const EdgeInsetsDirectional.only( diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index 883b4f7e7..6776bb149 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -18,6 +18,7 @@ import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/pages/shared/widgets/json_editor.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/shared/widgets/section.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/service/shared/share/configuration_transfer.dart'; @@ -122,11 +123,16 @@ class _JsonConfigurationEditorState extends State { context, title: Text( controller.advanced - ? l10n.routingAdvancedJson + ? widget.configurationId == null + ? l10n.routingAdvancedCreateTitle + : l10n.routingAdvancedEditTitle : widget.configurationId == null ? l10n.prototypeAddRawJson : l10n.prototypeEditRawJson, ), + description: controller.advanced + ? l10n.routingAdvancedSummary + : null, leading: BackButton( onPressed: state.closeBlocked || @@ -136,17 +142,14 @@ class _JsonConfigurationEditorState extends State { : () => controller.closePage(context), ), actions: [ - PageHelpButton( - title: controller.advanced - ? l10n.routingAdvancedJson - : l10n.xrayRawPageTitle, - messages: controller.advanced - ? [l10n.routingAdvancedNotice] - : [ - l10n.prototypeRawManagedSettingsNotice, - l10n.prototypeRawAdditionalSettingsNotice, - ], - ), + if (!controller.advanced) + PageHelpButton( + title: l10n.xrayRawPageTitle, + messages: [ + l10n.prototypeRawManagedSettingsNotice, + l10n.prototypeRawAdditionalSettingsNotice, + ], + ), ], ), body: SafeArea( @@ -160,128 +163,147 @@ class _JsonConfigurationEditorState extends State { ), child: ResponsiveContent( desktopMaxWidth: AppLayout.routingMaxWidth, - child: RoutingCard( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConfigurationTransferTools( - controller: controller.transfers, - disabled: state.busy || !state.loaded, - ), - if (state.sharingDataCount case final count? - when count > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: _RawNote( - l10n.prototypeSharingDataSourceLinks( - count, + child: controller.advanced + ? _advancedContent(context, state, mobile: mobile) + : RoutingCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + ConfigurationTransferTools( + controller: controller.transfers, + disabled: state.busy || !state.loaded, ), - ), - ), - SizedBox(height: gap), - Text( - l10n.prototypeConfigurationName, - style: AppTypography.fieldLabel.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - ConstrainedBox( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent(44), - ), - child: TextField( - controller: controller.name, - maxLength: 32, - enabled: state.loaded, - style: AppTypography.rowValue, - decoration: InputDecoration( - hintText: l10n.prototypeConfigurationName, - hintStyle: AppTypography.rowValue - .copyWith( - color: palette.mutedForeground, + if (state.sharingDataCount + case final count? when count > 0) + Padding( + padding: const EdgeInsets.only( + top: 8, ), - counterText: '', - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12, + child: _RawNote( + l10n.prototypeSharingDataSourceLinks( + count, + ), ), - ), - ), - ), - SizedBox(height: gap), - Text( - controller.advanced - ? l10n.routingAdvancedTemplate - : l10n.prototypeCompleteXrayConfiguration, - style: AppTypography.fieldLabel.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - SizedBox( - height: AppJsonEditor.heightForViewport( - context, - mobile ? 392 : 412, - ), - child: AbsorbPointer( - absorbing: !state.loaded, - child: AppJsonEditor( - controller: controller.text, - kind: controller.advanced - ? JsonEditorKind.advancedRouting - : JsonEditorKind.raw, - diagnostic: - state.diagnostic ?? - (state.error == null - ? null - : JsonDiagnostic(state.error!)), - domainSuggestions: - state.domainSuggestions, - ipSuggestions: state.ipSuggestions, - ), - ), - ), - if (controller.advanced) - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: () => - controller.openDocumentation(context), - icon: const Icon(LucideIcons.bookOpen), - label: Text(l10n.prototypeDocumentation), - ), - ), - if (!state.loaded && state.busy) - const LinearProgressIndicator(), - if (!state.loaded && state.error != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Semantics( - liveRegion: true, - child: ConstrainedBox( + ), + SizedBox(height: gap), + Text( + l10n.prototypeConfigurationName, + style: AppTypography.fieldLabel + .copyWith( + color: palette.foreground, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( constraints: BoxConstraints( - maxHeight: - MediaQuery.sizeOf(context) - .height / - 4, + minHeight: AppLayout.touchExtent(44), ), - child: SingleChildScrollView( - child: SelectableText( - state.error!, - style: AppTypography.rawNote + child: TextField( + controller: controller.name, + maxLength: 32, + enabled: state.loaded, + style: AppTypography.rowValue, + decoration: InputDecoration( + hintText: + l10n.prototypeConfigurationName, + hintStyle: AppTypography.rowValue .copyWith( - color: palette.destructive, + color: + palette.mutedForeground, + ), + counterText: '', + contentPadding: + const EdgeInsets.symmetric( + horizontal: 12, ), ), ), ), - ), + SizedBox(height: gap), + Text( + controller.advanced + ? l10n.routingAdvancedTemplate + : l10n.prototypeCompleteXrayConfiguration, + style: AppTypography.fieldLabel + .copyWith( + color: palette.foreground, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: AppJsonEditor.heightForViewport( + context, + mobile ? 392 : 412, + ), + child: AbsorbPointer( + absorbing: !state.loaded, + child: AppJsonEditor( + controller: controller.text, + kind: controller.advanced + ? JsonEditorKind.advancedRouting + : JsonEditorKind.raw, + diagnostic: + state.diagnostic ?? + (state.error == null + ? null + : JsonDiagnostic( + state.error!, + )), + domainSuggestions: + state.domainSuggestions, + ipSuggestions: state.ipSuggestions, + ), + ), + ), + if (controller.advanced) + Align( + alignment: + AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: () => controller + .openDocumentation(context), + icon: const Icon( + LucideIcons.bookOpen, + ), + label: Text( + l10n.prototypeDocumentation, + ), + ), + ), + if (!state.loaded && state.busy) + const LinearProgressIndicator(), + if (!state.loaded && state.error != null) + Padding( + padding: const EdgeInsets.only( + top: 12, + ), + child: Semantics( + liveRegion: true, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: + MediaQuery.sizeOf(context) + .height / + 4, + ), + child: SingleChildScrollView( + child: SelectableText( + state.error!, + style: AppTypography.rawNote + .copyWith( + color: + palette.destructive, + ), + ), + ), + ), + ), + ), + ], ), - ], - ), - ), + ), ), ), ), @@ -317,6 +339,141 @@ class _JsonConfigurationEditorState extends State { }, ), ); + + Widget _advancedContent( + BuildContext context, + JsonConfigurationEditorState state, { + required bool mobile, + }) { + final l10n = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 20, + children: [ + SectionView( + title: l10n.prototypeConfigurationName, + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.configurationNameLabel, + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, + ), + ), + const SizedBox(height: 8), + ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent(44), + ), + child: Semantics( + label: l10n.configurationNameLabel, + child: TextField( + controller: controller.name, + maxLength: 32, + enabled: state.loaded, + style: AppTypography.rowValue, + decoration: InputDecoration( + hintText: l10n.prototypeConfigurationName, + hintStyle: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), + counterText: '', + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + ), + ), + ), + ), + ), + ], + ), + ), + SectionView( + title: l10n.routingAdvancedCardTitle, + icon: LucideIcons.fileJson, + action: PageHelpButton( + title: l10n.routingAdvancedCardTitle, + messages: [l10n.routingAdvancedNotice], + ), + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'JSON', + style: AppTypography.fieldLabel.copyWith( + color: palette.foreground, + ), + ), + const SizedBox(height: 8), + SizedBox( + height: AppJsonEditor.heightForViewport( + context, + mobile ? 392 : 412, + ), + child: AbsorbPointer( + absorbing: !state.loaded, + child: AppJsonEditor( + controller: controller.text, + kind: JsonEditorKind.advancedRouting, + diagnostic: + state.diagnostic ?? + (state.error == null + ? null + : JsonDiagnostic(state.error!)), + domainSuggestions: state.domainSuggestions, + ipSuggestions: state.ipSuggestions, + ), + ), + ), + const SizedBox(height: 12), + ConfigurationTransferTools( + controller: controller.transfers, + disabled: state.busy || !state.loaded, + ), + if (state.sharingDataCount case final count? when count > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: _RawNote(l10n.prototypeSharingDataSourceLinks(count)), + ), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + onPressed: () => controller.openDocumentation(context), + icon: const Icon(LucideIcons.bookOpen), + label: Text(l10n.prototypeDocumentation), + ), + ), + if (!state.loaded && state.busy) const LinearProgressIndicator(), + if (!state.loaded && state.error != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Semantics( + liveRegion: true, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height / 4, + ), + child: SingleChildScrollView( + child: SelectableText( + state.error!, + style: AppTypography.rawNote.copyWith( + color: palette.destructive, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ); + } } class _RawNote extends StatelessWidget { diff --git a/lib/pages/settings/widgets.dart b/lib/pages/settings/widgets.dart index 50c8ea7dd..f802c8ffa 100644 --- a/lib/pages/settings/widgets.dart +++ b/lib/pages/settings/widgets.dart @@ -139,14 +139,15 @@ class SettingsActionButton extends StatelessWidget { builder: (context, constraints) { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + final labelStyle = AppTypography.control.copyWith( + fontFamily: AppFontFamily.sans, + ); + final locale = Localizations.localeOf(context); final painter = TextPainter( - text: TextSpan( - text: label, - style: AppTypography.control.copyWith(fontFamily: AppFontFamily.sans), - ), + text: TextSpan(text: label, style: labelStyle), textDirection: Directionality.of(context), textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.localeOf(context), + locale: locale, )..layout(); final progressWidth = busy ? 24.0 : 0.0; // Shad's outline border sits outside the button's content constraints. @@ -159,30 +160,33 @@ class SettingsActionButton extends StatelessWidget { painter.width.ceilToDouble() + 32 + progressWidth + borderExtent, ); final width = math.max(0.0, outerWidth - borderExtent); - painter.layout(maxWidth: math.max(1.0, width - 32 - progressWidth)); - final height = math.max( - AppLayout.touchExtent( - mobile ? 48 : 42, - platform: Theme.of(context).platform, - ) - - borderExtent, - painter.height.ceilToDouble() + 16, - ); painter.dispose(); - return ShadButton.raw( - variant: variant, - enabled: enabled && onPressed != null, - onPressed: onPressed, - width: width, - height: height, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - expands: true, - child: ButtonProgress( - busy: busy, - child: Text( - label, - textAlign: TextAlign.center, - style: AppTypography.control, + // Let the rendered label determine its height, including script fallback + // and wrapping. A measured tight height can clip a late line break. + return ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + mobile ? 48 : 42, + platform: Theme.of(context).platform, + ), + ), + child: ShadButton.raw( + variant: variant, + enabled: enabled && onPressed != null, + onPressed: onPressed, + width: width, + height: 0, + textStyle: labelStyle, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + expands: true, + child: ButtonProgress( + busy: busy, + child: Text( + label, + textAlign: TextAlign.center, + style: labelStyle, + locale: locale, + ), ), ), ); diff --git a/test/pages/advanced/xray/dialing_test.dart b/test/pages/advanced/xray/dialing_test.dart index 9cb83998b..e4bc9fed0 100644 --- a/test/pages/advanced/xray/dialing_test.dart +++ b/test/pages/advanced/xray/dialing_test.dart @@ -81,6 +81,15 @@ void main() { )!; expect(find.text(l.serverDialingSystem), findsOneWidget); expect(find.byType(SettingSelect), findsOneWidget); + final fieldRect = tester.getRect( + find.byKey(const ValueKey('server-dialing-domain-field')), + ); + final selectRect = tester.getRect( + find.byKey(const ValueKey('server-dialing-strategy')), + ); + expect(selectRect.left, closeTo(fieldRect.left, .5)); + expect(selectRect.right, closeTo(fieldRect.right, .5)); + expect(find.text(l.pageXrayConfigurationDescription), findsOneWidget); expect(find.byType(ShadSwitch), findsOneWidget); expect( tester.getSemantics(find.byType(ShadSwitch)).label, diff --git a/test/pages/advanced/xray/settings_ui_test.dart b/test/pages/advanced/xray/settings_ui_test.dart index c0d0607ea..031825e7d 100644 --- a/test/pages/advanced/xray/settings_ui_test.dart +++ b/test/pages/advanced/xray/settings_ui_test.dart @@ -261,6 +261,57 @@ void main() { expect(tester.takeException(), null); }); + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'Xray preference choices fill their single form card at $size', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + for (final (page, choices) in <(Widget, List)>[ + (const PingPage(), ['ping-url', 'ping-timeout']), + ( + const AutoUpdatePage(), + ['subscription-update-interval', 'geodata-update-interval'], + ), + ]) { + await tester.pumpWidget(app(page)); + await tester.pumpAndSettle(); + expect(find.byType(ShadCard), findsOneWidget); + final l = AppLocalizations.of( + tester.element(find.byType(PageActionBar)), + )!; + expect(find.text(l.pageXrayConfigurationDescription), findsOneWidget); + for (final choice in choices) { + final field = find.byKey(ValueKey('$choice-field')); + final control = find.byKey( + ValueKey(choice == 'ping-url' ? 'ping-url-preset' : choice), + ); + expect(field, findsOneWidget); + expect(control, findsOneWidget); + final fieldRect = tester.getRect(field); + final controlRect = tester.getRect(control); + expect(controlRect.left, closeTo(fieldRect.left, .5)); + expect(controlRect.right, closeTo(fieldRect.right, .5)); + expect(controlRect.top, greaterThan(fieldRect.top)); + expect( + find.ancestor(of: control, matching: find.byType(ShadCard)), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + } + await tester.pumpWidget(const SizedBox()); + await tester.pumpAndSettle(); + }, + ); + } + testWidgets('ping settings restore a custom URL', (tester) async { final pingState = PingState() ..url = PingUrl.custom diff --git a/test/pages/settings/backup_test.dart b/test/pages/settings/backup_test.dart index 30890ed2f..00feb8a92 100644 --- a/test/pages/settings/backup_test.dart +++ b/test/pages/settings/backup_test.dart @@ -9,6 +9,7 @@ import 'package:onexray/core/backup/storage.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/backup/page.dart'; +import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/settings/backup/existing_file_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; @@ -16,6 +17,7 @@ import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/service/advanced/xray/geodata/service.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; import 'package:onexray/service/settings/backup/assets.dart'; @@ -87,6 +89,89 @@ void main() { home: BackupPage(service: service), ); + for (final (label, width, scale, busy, locale) in [ + ('使用 iCloud Drive', 640.0, 1.0, false, const Locale('zh')), + ('Use iCloud Drive', 640.0, 1.0, false, const Locale('en')), + ('Сохранить и переподключить', 130.0, 2.0, false, const Locale('ru')), + ('ذخیره و اتصال مجدد', 140.0, 2.0, true, const Locale('fa')), + ]) { + testWidgets( + 'backup action measures its rendered label at $width/$scale/$locale', + (tester) async { + var calls = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: width < 390), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: child!, + ), + ), + home: Scaffold( + body: Center( + child: SizedBox( + width: width, + child: SettingsActionButton( + label: label, + busy: busy, + enabled: !busy, + variant: busy + ? ShadButtonVariant.outline + : ShadButtonVariant.primary, + onPressed: () => calls++, + ), + ), + ), + ), + ), + ); + await tester.pump(); + final action = find.byType(SettingsActionButton); + final bounds = tester.getRect(action); + expect(bounds.width, lessThanOrEqualTo(width)); + final labelFinder = find.text(label); + final rich = tester.widget( + find.descendant(of: labelFinder, matching: find.byType(RichText)), + ); + final labelBounds = tester.getRect(labelFinder); + expect(rich.text.style!.fontFamily, AppFontFamily.sans); + expect(rich.text.style!.fontSize, 14); + expect(labelBounds.left, greaterThanOrEqualTo(bounds.left)); + expect(labelBounds.right, lessThanOrEqualTo(bounds.right)); + expect(labelBounds.top, greaterThanOrEqualTo(bounds.top)); + expect(labelBounds.bottom, lessThanOrEqualTo(bounds.bottom)); + final painter = TextPainter( + text: rich.text, + textDirection: + rich.textDirection ?? + Directionality.of(tester.element(labelFinder)), + textScaler: rich.textScaler, + locale: rich.locale, + )..layout(maxWidth: labelBounds.width); + if (width == 640) { + expect(painter.computeLineMetrics(), hasLength(1)); + expect(bounds.height, 44); + } else { + expect(painter.computeLineMetrics().length, greaterThan(1)); + expect(bounds.height, greaterThan(48)); + } + expect(labelBounds.height, closeTo(painter.height, .01)); + painter.dispose(); + await tester.tap(action); + await tester.pump(); + expect(calls, busy ? 0 : 1); + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + } + for (final brightness in Brightness.values) { testWidgets( 'sensitive backup notice keeps a readable foreground in $brightness', From 0b131228321acd1437f2e4855206c403bd430e7b Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 09:51:45 +0800 Subject: [PATCH 22/60] fix(ui): align routing and resource pages with approved layouts --- docs/app.md | 8 +- lib/l10n/app_en.arb | 20 +- lib/l10n/app_fa.arb | 20 +- lib/l10n/app_ru.arb | 20 +- lib/l10n/app_zh.arb | 20 +- lib/l10n/app_zh_Hant.arb | 20 +- lib/pages/connect/json_editor/page.dart | 4 +- lib/pages/connect/routing/custom/page.dart | 428 +++++++++--------- .../connect/routing/custom/sections.dart | 2 +- lib/pages/connect/routing/options_form.dart | 57 ++- lib/pages/connect/routing/smart/page.dart | 22 +- lib/pages/connect/routing/widgets.dart | 246 ++++++---- lib/pages/servers/details.dart | 130 +++--- lib/pages/servers/import/page.dart | 155 +++---- lib/pages/servers/resource_json.dart | 103 +++++ lib/pages/servers/sort.dart | 19 +- lib/pages/servers/view.dart | 14 +- lib/pages/settings/about/page.dart | 4 + lib/pages/settings/controller.dart | 4 + lib/pages/settings/page.dart | 39 +- lib/pages/shared/widgets/app_switch.dart | 23 +- .../routing/custom_page_parity_test.dart | 192 ++++++++ test/pages/connect/routing/widgets_test.dart | 168 ++++++- test/pages/servers/import_test.dart | 52 ++- test/pages/servers/manual_groups_test.dart | 13 + test/pages/servers/resource_json_test.dart | 152 +++++++ .../pages/servers/subscription_open_test.dart | 15 +- .../appearance_startup_update_test.dart | 49 ++ test/pages/settings/backup_test.dart | 52 +++ .../pages/shared/widgets/app_switch_test.dart | 134 ++++++ 30 files changed, 1664 insertions(+), 521 deletions(-) create mode 100644 lib/pages/servers/resource_json.dart create mode 100644 test/pages/connect/routing/custom_page_parity_test.dart create mode 100644 test/pages/servers/resource_json_test.dart diff --git a/docs/app.md b/docs/app.md index d594fd85a..3cce3c340 100644 --- a/docs/app.md +++ b/docs/app.md @@ -49,6 +49,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 `descriptionBelow` 指定的说明仍位于卡片外。`SettingSection` 默认不绘制行间分隔线,需要时显式启用 `separated`。 导航和卡片共用现有字体家族与文字角色;导航辅助标签使用专属角色,不改写正文等已有角色。 共享触控下限按平台应用:iOS 为 44×44 pt,Android 为 48×48 dp,不依赖窗口宽度。 +共享开关保留紧凑轨道,以实际点击层补足触控下限;轨道及外围点击均只回调一次,桌面不扩大轨道。 弹窗关闭、返回及设置动作可保留较小的视觉图标,通过实际点击区域满足下限;横屏不切换为桌面触控尺寸。 入口数量选择器沿用相同触控下限,桌面保持紧凑。订阅可编辑字段关联本地化名称, 填写内容后仍保留字段身份、编辑动作和错误语义;Age 密钥默认遮蔽。 @@ -132,7 +133,8 @@ FakeDNS 开启时不能关闭嗅探,先关闭 FakeDNS 才能停用。 底层 `directRegions` 仍为 List 以读取历史配置,选择页仅投影首项;打开/取消不裁剪原列表。 地区页“完成”只返回单项或空列表至父级草稿,整个 Smart 保存后才持久化;取消地区页或 Smart 编辑不提交更改。 直连 DNS 开关归入 DNS,FakeDNS 归入流量嗅探。 -常规路由的规则列表、FakeDNS/Fragment、DNS 和嗅探使用同级卡片,避免重复嵌套外框。 +常规路由依次展示名称、有序规则、代理连接、DNS 与嗅探;导入、导出与分享工具位于页末独立卡片。 +Fragment 归入代理连接,FakeDNS 归入嗅探,不重复嵌套外框。 所有宽度均从有序规则列表进入独立规则页,不保留常驻右侧编辑器;取消规则页不修改父草稿。 FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、删除、拖动和上下移动。 调整窗口宽度保留各设置区的输入草稿、焦点、选区和展开状态。 @@ -172,6 +174,8 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 断开后仍保留原选择意图,具体策略见 [配置合同](xray-configuration.md#普通配置编译)。 完整配置可选择、收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。 普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。 +资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。 +导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。 节点、Raw 与高级 JSON 使用全宽表单和原有 `re_editor`,保留诊断位置、补全、撤销及固定保存区。 按已加载或成功保存的原文判断未保存修改,Raw/高级同时比较名称;光标移动不算修改,手动还原原文和名称恢复干净状态。 已修改时,关闭、取消、系统返回和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。 @@ -249,6 +253,7 @@ App 没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运 设置以单列分组管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。 首页按外观与语言、启动与系统、数据、关于分组;启动偏好进入独立页面,沿用即时保存并在返回后刷新摘要。 +备份入口实时显示当前绑定目标或未设置状态,长目标名称最多显示两行,进入详情查看完整位置。 首页主题选项按当前字号、标签实际宽度和可用空间自然换行,不按语言硬编码,也不缩小系统字号。 选中状态具有语义标记,点击后立即保存;Apple 图标行空间不足时将当前值移至标题下方自然换行。 语言及图标详情使用单列选择控件,图标保留预览;独立表单仍在保存前保留草稿,取消不写入。 @@ -256,6 +261,7 @@ Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢 自动备份开关位于分区标题旁,只显示一次标题,并保留开关名称、状态及操作中的禁用和进度反馈。 周期仍在同一分区编辑,Apple 位置选择保留 iCloud 主动作,敏感数据提醒及恢复、覆盖确认保持原规则。 关于展示 App/Xray-core 版本;有新版本时入口显示圆点,桌面侧栏保留更新提醒。 +更新说明有独立入口打开项目发行记录,原有版本检查与更新弹窗继续保留。 清理连接资源与清理全部数据是不同操作;成功留在业务页面,不重新进入 Setup, 保留范围、失败阶段与数据保护见 [清理数据](data-management.md#清理数据)。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index d71a663c7..4eabaa96a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3253,5 +3253,23 @@ "routingAdvancedCreateTitle": "New advanced template", "routingAdvancedEditTitle": "Edit advanced template", "routingAdvancedCardTitle": "Advanced JSON template", - "configurationNameLabel": "Name" + "configurationNameLabel": "Name", + "routingNormalCreateTitle": "New standard route", + "routingNormalEditTitle": "Edit standard route", + "routingNormalDescription": "Match rules in order; unmatched traffic uses VPN.", + "routingOrderedRulesTitle": "Ordered rules", + "routingEmptyRulesHint": "No rules yet. You can configure DNS and proxy connections before saving.", + "smartRoutingEditorDescription": "Edit direct, proxy, DNS, and traffic identification preferences.", + "routingEntryCountLabel": "Entry node count", + "routingSelectionModeLabel": "Selection method", + "configurationTransferTitle": "Import, export, and share", + "routingAdvancedDescription": "Use App node slots to configure DNS and routing independently.", + "configurationNameHint": "1–32 characters", + "serverImportPageDescription": "Choose how to add subscriptions, nodes, or configurations.", + "serverImportMethods": "Import method", + "manualGroupsDescription": "Manage groups and ordering for local nodes.", + "manualGroupsUngroupedFirst": "Ungrouped always appears first.", + "serverResourceInformation": "Resource information", + "serverProtocol": "Protocol", + "serverConfigurationJson": "Configuration JSON" } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 6a99cec8c..c4019d451 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1072,5 +1072,23 @@ "routingAdvancedCreateTitle": "قالب پیشرفته جدید", "routingAdvancedEditTitle": "ویرایش قالب پیشرفته", "routingAdvancedCardTitle": "قالب پیشرفته JSON", - "configurationNameLabel": "نام" + "configurationNameLabel": "نام", + "routingNormalCreateTitle": "مسیر عادی جدید", + "routingNormalEditTitle": "ویرایش مسیر عادی", + "routingNormalDescription": "قوانین به‌ترتیب بررسی می‌شوند؛ ترافیک بدون تطبیق از VPN استفاده می‌کند.", + "routingOrderedRulesTitle": "قوانین مرتب‌شده", + "routingEmptyRulesHint": "هنوز قانونی وجود ندارد. می‌توانید پیش از ذخیره، DNS و اتصال پروکسی را تنظیم کنید.", + "smartRoutingEditorDescription": "تنظیمات اتصال مستقیم، پروکسی، DNS و شناسایی ترافیک را ویرایش کنید.", + "routingEntryCountLabel": "تعداد گره‌های ورودی", + "routingSelectionModeLabel": "روش انتخاب", + "configurationTransferTitle": "وارد کردن، صادر کردن و اشتراک‌گذاری", + "routingAdvancedDescription": "با جایگاه‌های گره برنامه، DNS و مسیریابی را جداگانه تنظیم کنید.", + "configurationNameHint": "۱ تا ۳۲ نویسه", + "serverImportPageDescription": "روش افزودن اشتراک‌ها، گره‌ها یا پیکربندی‌ها را انتخاب کنید.", + "serverImportMethods": "روش وارد کردن", + "manualGroupsDescription": "گروه‌ها و ترتیب گره‌های محلی را مدیریت کنید.", + "manualGroupsUngroupedFirst": "گره‌های بدون گروه همیشه در ابتدا نمایش داده می‌شوند.", + "serverResourceInformation": "اطلاعات منبع", + "serverProtocol": "پروتکل", + "serverConfigurationJson": "JSON پیکربندی" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 8dc3d9c91..423c585a7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1072,5 +1072,23 @@ "routingAdvancedCreateTitle": "Новый расширенный шаблон", "routingAdvancedEditTitle": "Изменить расширенный шаблон", "routingAdvancedCardTitle": "Расширенный шаблон JSON", - "configurationNameLabel": "Название" + "configurationNameLabel": "Название", + "routingNormalCreateTitle": "Новый обычный маршрут", + "routingNormalEditTitle": "Изменить обычный маршрут", + "routingNormalDescription": "Правила проверяются по порядку; остальной трафик идёт через VPN.", + "routingOrderedRulesTitle": "Упорядоченные правила", + "routingEmptyRulesHint": "Правил пока нет. Перед сохранением можно настроить DNS и прокси.", + "smartRoutingEditorDescription": "Настройка прямого подключения, прокси, DNS и распознавания трафика.", + "routingEntryCountLabel": "Число входных узлов", + "routingSelectionModeLabel": "Метод выбора", + "configurationTransferTitle": "Импорт, экспорт и обмен", + "routingAdvancedDescription": "Независимо настройте DNS и маршрутизацию с узлами приложения.", + "configurationNameHint": "1–32 символа", + "serverImportPageDescription": "Выберите способ добавления подписок, узлов или конфигураций.", + "serverImportMethods": "Способ импорта", + "manualGroupsDescription": "Управление группами и порядком локальных узлов.", + "manualGroupsUngroupedFirst": "Узлы без группы всегда отображаются первыми.", + "serverResourceInformation": "Информация о ресурсе", + "serverProtocol": "Протокол", + "serverConfigurationJson": "JSON конфигурации" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 400298302..b5c8ffba5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1072,5 +1072,23 @@ "routingAdvancedCreateTitle": "新建高级模板", "routingAdvancedEditTitle": "编辑高级模板", "routingAdvancedCardTitle": "高级 JSON 模板", - "configurationNameLabel": "名称" + "configurationNameLabel": "名称", + "routingNormalCreateTitle": "新建常规路由", + "routingNormalEditTitle": "编辑常规路由", + "routingNormalDescription": "按顺序匹配规则,未匹配流量使用 VPN。", + "routingOrderedRulesTitle": "有序规则", + "routingEmptyRulesHint": "还没有规则,可先配置 DNS 与代理连接后保存。", + "smartRoutingEditorDescription": "编辑直连、代理、DNS 与流量识别偏好。", + "routingEntryCountLabel": "接入节点数量", + "routingSelectionModeLabel": "选路方式", + "configurationTransferTitle": "导入、导出与分享", + "routingAdvancedDescription": "使用 App 节点槽,独立配置 DNS 与路由。", + "configurationNameHint": "1–32 个字符", + "serverImportPageDescription": "选择一种方式添加订阅、节点或配置。", + "serverImportMethods": "导入方式", + "manualGroupsDescription": "管理本地节点的分组与排列顺序。", + "manualGroupsUngroupedFirst": "未分组固定显示在最前。", + "serverResourceInformation": "资源信息", + "serverProtocol": "协议", + "serverConfigurationJson": "配置 JSON" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index d2094297d..453371f72 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1072,5 +1072,23 @@ "routingAdvancedCreateTitle": "新增進階範本", "routingAdvancedEditTitle": "編輯進階範本", "routingAdvancedCardTitle": "進階 JSON 範本", - "configurationNameLabel": "名稱" + "configurationNameLabel": "名稱", + "routingNormalCreateTitle": "新增一般路由", + "routingNormalEditTitle": "編輯一般路由", + "routingNormalDescription": "依序比對規則,未符合的流量使用 VPN。", + "routingOrderedRulesTitle": "有序規則", + "routingEmptyRulesHint": "尚未設定規則,可先設定 DNS 與代理連線再儲存。", + "smartRoutingEditorDescription": "編輯直連、代理、DNS 與流量識別偏好。", + "routingEntryCountLabel": "接入節點數量", + "routingSelectionModeLabel": "選路方式", + "configurationTransferTitle": "匯入、匯出與分享", + "routingAdvancedDescription": "使用 App 節點槽,獨立設定 DNS 與路由。", + "configurationNameHint": "1–32 個字元", + "serverImportPageDescription": "選擇一種方式新增訂閱、節點或設定。", + "serverImportMethods": "匯入方式", + "manualGroupsDescription": "管理本機節點的群組與排列順序。", + "manualGroupsUngroupedFirst": "未分組固定顯示在最前。", + "serverResourceInformation": "資源資訊", + "serverProtocol": "通訊協定", + "serverConfigurationJson": "設定 JSON" } diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index 6776bb149..fb7bcc50e 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -131,7 +131,7 @@ class _JsonConfigurationEditorState extends State { : l10n.prototypeEditRawJson, ), description: controller.advanced - ? l10n.routingAdvancedSummary + ? l10n.routingAdvancedDescription : null, leading: BackButton( onPressed: @@ -376,7 +376,7 @@ class _JsonConfigurationEditorState extends State { enabled: state.loaded, style: AppTypography.rowValue, decoration: InputDecoration( - hintText: l10n.prototypeConfigurationName, + hintText: l10n.configurationNameHint, hintStyle: AppTypography.rowValue.copyWith( color: palette.mutedForeground, ), diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index eb50055ed..4f723b5f5 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -18,6 +18,7 @@ import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/pages/shared/widgets/menu_picker.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; +import 'package:onexray/pages/shared/widgets/section.dart'; import 'package:onexray/service/connect/routing/custom/state.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -26,11 +27,13 @@ class CustomRoutingEditorPage extends StatefulWidget { final OpenCustomRule openRule; final Widget Function(BuildContext, CustomRoutingEditorController)? transferTools; + final CustomRoutingEditorController Function()? createController; const CustomRoutingEditorPage({ super.key, this.profileId, required this.openRule, this.transferTools, + this.createController, }); @override @@ -39,9 +42,9 @@ class CustomRoutingEditorPage extends StatefulWidget { } class _CustomRoutingEditorPageState extends State { - late final controller = CustomRoutingEditorController( - profileId: widget.profileId, - ); + late final controller = + widget.createController?.call() ?? + CustomRoutingEditorController(profileId: widget.profileId); bool _started = false; @override @@ -68,16 +71,24 @@ class _CustomRoutingEditorPageState extends State { final l = AppLocalizations.of(context)!; final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final name = state.name.trim(); return Scaffold( appBar: PageAppBar.forPage( context, - title: Text(name.isEmpty ? l.prototypeCustomRouting : name), + title: Text( + widget.profileId == null + ? l.routingNormalCreateTitle + : l.routingNormalEditTitle, + ), + description: l.routingNormalDescription, leading: BackButton(onPressed: () => controller.cancel(context)), actions: [ PageHelpButton( title: l.prototypeCustomRouting, - messages: [l.prototypeCustomImportHint, l.routingCustomDnsHint], + messages: [ + l.prototypeRulesMatchInOrder, + l.prototypeCustomImportHint, + l.routingCustomDnsHint, + ], ), ], ), @@ -98,170 +109,50 @@ class _CustomRoutingEditorPageState extends State { desktopMaxWidth: AppLayout.routingMaxWidth, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 20, children: [ - RoutingCard( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 13, + _identity(context, mobile, state), + CustomRoutingSections( + rules: _rules(context, mobile, state), + editor: controller.inlineRule == null + ? null + : CustomRoutingRuleForm( + controller: controller.inlineRule!, + ), + options: _proxySettings(context, state), + dns: RoutingDnsSettingsForm( + value: state.dns, + enabled: !state.editingBlocked, + onChanged: controller.setDns, ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - spacing: mobile ? 8 : 0, - children: [ - widget.transferTools?.call( - context, - controller, - ) ?? - ConfigurationTransferTools( - controller: controller.transfer, - disabled: state.busy, - ), - ], + sniffing: RoutingSniffingSettingsForm( + value: state.sniffing, + enabled: !state.editingBlocked, + fakeDns: state.fakeDns, + leading: RoutingFakeDnsRow( + value: state.fakeDns, + enabled: + !state.editingBlocked && + (state.fakeDns || + state.sniffing.enabled), + onChanged: controller.setFakeDns, + ), + onChanged: controller.setSniffing, ), ), - const SizedBox(height: 16), - _identity(context, mobile, state), - SizedBox(height: mobile ? 12 : 16), - Builder( - builder: (context) { - final rules = RoutingCard( - key: const ValueKey('custom-rule-list'), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - if (!mobile) - RoutingCardHeader( - title: l.prototypeRuleList, - description: - l.prototypeRulesMatchInOrder, - ), - RoutingEntryCountRow( - value: state.entryCount, - onChanged: state.editingBlocked - ? null - : controller.setEntryCount, - ), - RoutingSelectionSettingsForm( - value: state.selection, - enabled: !state.editingBlocked, - entryCount: state.entryCount, - onChanged: controller.setSelection, - ), - ReorderableListView.builder( - shrinkWrap: true, - primary: false, - physics: - const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - buildDefaultDragHandles: false, - proxyDecorator: - (child, index, animation) => - Material( - color: ColorManager.palette( - context, - ).card, - elevation: 4, - borderRadius: - BorderRadius.circular( - AppRadii.control, - ), - clipBehavior: - Clip.antiAlias, - child: child, - ), - onReorderItem: controller.reorder, - itemCount: state.rules.length, - itemBuilder: (context, index) => - _rule( - context, - index, - mobile, - state, - ), - ), - _fallback(context, mobile), - Padding( - padding: const EdgeInsets.all(12), - child: OutlinedButton.icon( - onPressed: state.editingBlocked - ? null - : () => controller.editRule( - context, - widget.openRule, - ), - style: OutlinedButton.styleFrom( - minimumSize: - const Size.fromHeight(43), - visualDensity: mobile - ? null - : VisualDensity.standard, - foregroundColor: - ColorManager.palette(context) - .primary, - side: BorderSide( - color: Color.lerp( - ColorManager.palette(context) - .border, - ColorManager.palette(context) - .primary, - .55, - )!, - ), - textStyle: AppTypography.ruleAdd, - ), - icon: const Icon( - LucideIcons.plus, - size: 17, - ), - label: Text(l.prototypeAddRule), - ), - ), - ], - ), - ); - final inline = controller.inlineRule; - return CustomRoutingSections( - rules: rules, - editor: inline == null - ? null - : CustomRoutingRuleForm( - controller: inline, - ), - options: RoutingCard( - child: Column( - children: [ - RoutingFakeDnsRow( - value: state.fakeDns, - enabled: - !state.editingBlocked && - (state.fakeDns || - state.sniffing.enabled), - onChanged: controller.setFakeDns, - ), - RoutingFragmentRow( - value: state.fragment, - enabled: !state.editingBlocked, - divider: false, - onChanged: controller.setFragment, - ), - ], - ), - ), - dns: RoutingDnsSettingsForm( - value: state.dns, - enabled: !state.editingBlocked, - onChanged: controller.setDns, - ), - sniffing: RoutingSniffingSettingsForm( - value: state.sniffing, - enabled: !state.editingBlocked, - fakeDns: state.fakeDns, - onChanged: controller.setSniffing, + SectionView( + key: const ValueKey('custom-transfer-tools'), + title: l.configurationTransferTitle, + padding: EdgeInsets.zero, + child: + widget.transferTools?.call( + context, + controller, + ) ?? + ConfigurationTransferTools( + controller: controller.transfer, + disabled: state.busy, ), - ); - }, ), ], ), @@ -337,78 +228,165 @@ class _CustomRoutingEditorPageState extends State { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); final error = controller.nameError(l); - final field = ShadInput( - controller: controller.name, - enabled: state.loaded && !state.editingBlocked, - maxLength: 32, - constraints: const BoxConstraints(minHeight: 40), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - style: AppTypography.rowValue, - decoration: error == null - ? null - : ShadDecoration( - border: ShadBorder.all(color: palette.destructive, width: 1), - ), - ); - final delete = OutlinedButton.icon( - onPressed: state.busy ? null : () => controller.delete(context), - style: OutlinedButton.styleFrom( - foregroundColor: palette.destructive, - minimumSize: const Size(0, 38), - visualDensity: mobile ? null : VisualDensity.standard, - padding: const EdgeInsets.symmetric(horizontal: 11), - textStyle: AppTypography.ruleAdd, + return SectionView( + key: const ValueKey('custom-identity'), + title: l.prototypeConfigurationName, + action: Text( + l.prototypeCustomRouteCount(controller.routeCount), + style: AppTypography.routeCount.copyWith( + color: palette.mutedForeground, + ), ), - icon: state.deleting - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.trash2, size: 16), - label: Text(l.prototypeDeleteRoute), - ); - return RoutingCard( - padding: const EdgeInsets.all(18), + padding: EdgeInsets.zero, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 10, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - spacing: mobile ? 10 : 16, - children: [ - Expanded( - child: Text( - l.prototypeRouteName, - style: AppTypography.fieldLabel.copyWith( - color: palette.foreground, - ), - ), - ), - Flexible( - flex: 0, - child: Text( - l.prototypeCustomRouteCount(controller.routeCount), - style: AppTypography.routeCount.copyWith( - color: palette.mutedForeground, - ), - ), - ), - ], + Text( + l.configurationNameLabel, + style: AppTypography.fieldLabel.copyWith(color: palette.foreground), ), - Row( - spacing: mobile ? 10 : 16, - children: [ - Expanded(child: field), - if (!mobile && state.original?.original != null) delete, - ], + const SizedBox(height: 8), + Semantics( + label: l.configurationNameLabel, + child: ShadInput( + controller: controller.name, + enabled: state.loaded && !state.editingBlocked, + maxLength: 32, + placeholder: Text(l.configurationNameHint), + constraints: BoxConstraints(minHeight: AppLayout.touchExtent(44)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + style: AppTypography.rowValue, + decoration: error == null + ? null + : ShadDecoration( + border: ShadBorder.all( + color: palette.destructive, + width: 1, + ), + ), + ), ), - if (error != null) + if (error != null) ...[ + const SizedBox(height: 8), Text( error, style: AppTypography.conditionRelation.copyWith( color: palette.destructive, ), ), - if (mobile && state.original?.original != null) delete, + ], + if (state.original?.original != null) ...[ + const SizedBox(height: 12), + Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + onPressed: state.busy ? null : () => controller.delete(context), + style: OutlinedButton.styleFrom( + foregroundColor: palette.destructive, + minimumSize: Size(0, AppLayout.touchExtent(mobile ? 48 : 42)), + padding: const EdgeInsets.symmetric(horizontal: 11), + textStyle: AppTypography.ruleAdd, + ), + icon: state.deleting + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.trash2, size: 16), + label: Text(l.prototypeDeleteRoute), + ), + ), + ], + ], + ), + ); + } + + Widget _rules( + BuildContext context, + bool mobile, + CustomRoutingEditorState state, + ) { + final l = AppLocalizations.of(context)!; + return SectionView( + key: const ValueKey('custom-rule-list'), + title: l.routingOrderedRulesTitle, + icon: LucideIcons.listOrdered, + action: OutlinedButton.icon( + onPressed: state.editingBlocked + ? null + : () => controller.editRule(context, widget.openRule), + style: OutlinedButton.styleFrom( + minimumSize: Size(0, AppLayout.touchExtent(mobile ? 48 : 42)), + textStyle: AppTypography.ruleAdd, + ), + icon: const Icon(LucideIcons.plus, size: 17), + label: Text(l.prototypeAddRule), + ), + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.rules.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Text( + l.routingEmptyRulesHint, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context).mutedForeground, + ), + ), + ), + ReorderableListView.builder( + shrinkWrap: true, + primary: false, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + buildDefaultDragHandles: false, + proxyDecorator: (child, index, animation) => Material( + color: ColorManager.palette(context).card, + elevation: 4, + borderRadius: BorderRadius.circular(AppRadii.control), + clipBehavior: Clip.antiAlias, + child: child, + ), + onReorderItem: controller.reorder, + itemCount: state.rules.length, + itemBuilder: (context, index) => + _rule(context, index, mobile, state), + ), + _fallback(context, mobile), + ], + ), + ); + } + + Widget _proxySettings(BuildContext context, CustomRoutingEditorState state) { + final l = AppLocalizations.of(context)!; + return RoutingCard( + key: const ValueKey('custom-proxy-settings'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + RoutingCardHeader( + title: l.smartRoutingProxyConnection, + icon: LucideIcons.gitBranch, + ), + RoutingEntryCountRow( + value: state.entryCount, + fullWidth: true, + onChanged: state.editingBlocked ? null : controller.setEntryCount, + ), + RoutingSelectionSettingsForm( + value: state.selection, + enabled: !state.editingBlocked, + entryCount: state.entryCount, + fullWidth: true, + onChanged: controller.setSelection, + ), + RoutingFragmentRow( + value: state.fragment, + enabled: !state.editingBlocked, + divider: false, + onChanged: controller.setFragment, + ), ], ), ); diff --git a/lib/pages/connect/routing/custom/sections.dart b/lib/pages/connect/routing/custom/sections.dart index f6fbfe5fe..dfd9ab340 100644 --- a/lib/pages/connect/routing/custom/sections.dart +++ b/lib/pages/connect/routing/custom/sections.dart @@ -32,7 +32,7 @@ class _CustomRoutingSectionsState extends State { @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ KeyedSubtree(key: _rules, child: widget.rules), if (widget.editor case final editor?) diff --git a/lib/pages/connect/routing/options_form.dart b/lib/pages/connect/routing/options_form.dart index 221f49e91..a564fa383 100644 --- a/lib/pages/connect/routing/options_form.dart +++ b/lib/pages/connect/routing/options_form.dart @@ -8,6 +8,7 @@ import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/service/connect/routing/options.dart'; import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/page_help.dart'; /// Ordinary routing forms edit business values, never generated Core JSON. class RoutingDnsSettingsForm extends StatelessWidget { @@ -72,6 +73,7 @@ class RoutingDnsSettingsForm extends StatelessWidget { children: [ RoutingCardHeader( title: l.routingDnsSettings, + icon: LucideIcons.network, help: [l.routingDnsSettingsHint, l.routingDnsSameExitOnlyHint], ), ?leading, @@ -228,6 +230,7 @@ class RoutingSniffingSettingsForm extends StatelessWidget { children: [ RoutingCardHeader( title: l.routingSniffingSettings, + icon: LucideIcons.scanSearch, help: [ l.routingSniffingHint, l.routingSniffingRouteOnlyHint, @@ -276,6 +279,7 @@ class RoutingSelectionSettingsForm extends StatelessWidget { final ValueChanged onChanged; final bool enabled; final int entryCount; + final bool fullWidth; const RoutingSelectionSettingsForm({ super.key, @@ -283,6 +287,7 @@ class RoutingSelectionSettingsForm extends StatelessWidget { required this.onChanged, this.enabled = true, this.entryCount = 1, + this.fullWidth = false, }); @override @@ -292,20 +297,50 @@ class RoutingSelectionSettingsForm extends StatelessWidget { 'roundRobin': l.routingSelectionRoundRobin, 'leastPing': l.routingSelectionLeastPing, }; + final picker = _OptionPicker( + semanticLabel: fullWidth ? l.routingSelectionModeLabel : null, + value: value.type, + labels: choices, + enabled: enabled, + onChanged: (selected) => onChanged(value.copyWith(type: selected)), + ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - RoutingSettingRow( - icon: LucideIcons.gitBranch, - title: l.routingSelectionStrategy, - help: l.routingSelectionHint, - below: _OptionPicker( - value: value.type, - labels: choices, - enabled: enabled, - onChanged: (selected) => onChanged(value.copyWith(type: selected)), + if (fullWidth) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + l.routingSelectionModeLabel, + style: AppTypography.fieldLabel.copyWith( + color: ColorManager.palette(context).foreground, + ), + ), + ), + PageHelpButton( + title: l.routingSelectionModeLabel, + messages: [l.routingSelectionHint], + ), + ], + ), + const SizedBox(height: 8), + picker, + ], + ), + ) + else + RoutingSettingRow( + icon: LucideIcons.gitBranch, + title: l.routingSelectionStrategy, + help: l.routingSelectionHint, + below: picker, ), - ), if (value.type == 'leastPing') ...[ if (entryCount < 2) _HelpPadding(text: l.routingSelectionSingleHint), _RoutingDetails( @@ -378,7 +413,7 @@ class _OptionSwitch extends StatelessWidget { title: title, enabled: enabled, divider: divider, - trailing: Semantics( + leading: Semantics( label: title, child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index 99b07df9c..e35ad29c2 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -59,6 +59,7 @@ class _SmartRoutingEditorPageState extends State { appBar: PageAppBar.forPage( context, title: Text(l.prototypeSmartRouting), + description: l.smartRoutingEditorDescription, leading: BackButton(onPressed: () => controller.cancel(context)), ), body: SafeArea( @@ -146,7 +147,7 @@ class SmartRoutingEditorContent extends StatelessWidget { @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 16, + spacing: 20, children: [ _directSettings(context), _proxySettings(context), @@ -165,6 +166,7 @@ class SmartRoutingEditorContent extends StatelessWidget { children: [ RoutingCardHeader( title: l.smartRoutingDirectAndBlock, + icon: LucideIcons.route, help: [ l.prototypeDirectPrivateAddressesHint, l.prototypeDirectAppleServicesHint, @@ -185,6 +187,8 @@ class SmartRoutingEditorContent extends StatelessWidget { 'directPrivate', smart.directPrivate, state, + description: l.prototypeDirectPrivateAddressesHint, + divider: false, ), _switch( l.prototypeDirectAppleServices, @@ -192,6 +196,8 @@ class SmartRoutingEditorContent extends StatelessWidget { 'directApple', smart.directApple, state, + description: l.prototypeDirectAppleServicesHint, + divider: false, ), _switch( l.directWindowsServices, @@ -199,6 +205,8 @@ class SmartRoutingEditorContent extends StatelessWidget { 'directWindows', smart.directWindows, state, + description: l.directWindowsServicesHint, + divider: false, ), _switch( l.prototypeBlockAdDomains, @@ -206,6 +214,7 @@ class SmartRoutingEditorContent extends StatelessWidget { 'blockAds', smart.blockAds, state, + description: l.prototypeBlockAdDomainsHint, divider: false, ), ], @@ -220,9 +229,13 @@ class SmartRoutingEditorContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - RoutingCardHeader(title: l.smartRoutingProxyConnection), + RoutingCardHeader( + title: l.smartRoutingProxyConnection, + icon: LucideIcons.gitBranch, + ), RoutingEntryCountRow( value: smart.entryCount, + fullWidth: true, onChanged: state.busy ? null : (count) => controller.update('entryCount', count), @@ -231,6 +244,7 @@ class SmartRoutingEditorContent extends StatelessWidget { value: smart.selection, enabled: !state.busy, entryCount: controller.effectiveEntryCount, + fullWidth: true, onChanged: (value) => controller.update('selection', value.toJson()), ), @@ -290,16 +304,18 @@ class SmartRoutingEditorContent extends StatelessWidget { bool value, SmartRoutingEditorState state, { Widget? below, + String? description, String? help, bool divider = true, }) => RoutingSettingRow( icon: icon, title: title, + description: description, help: help, divider: divider, enabled: state.original != null && !state.busy, below: below, - trailing: Semantics( + leading: Semantics( label: title, child: AppSwitch( value: value, diff --git a/lib/pages/connect/routing/widgets.dart b/lib/pages/connect/routing/widgets.dart index 87f04f294..a85a68b90 100644 --- a/lib/pages/connect/routing/widgets.dart +++ b/lib/pages/connect/routing/widgets.dart @@ -30,7 +30,7 @@ class RoutingFakeDnsRow extends StatelessWidget { help: l.routingFakeDnsHint, enabled: enabled, divider: divider, - trailing: Semantics( + leading: Semantics( label: l.routingFakeDns, child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), @@ -61,7 +61,7 @@ class RoutingFragmentRow extends StatelessWidget { help: l.routingFragmentHint, enabled: enabled, divider: divider, - trailing: Semantics( + leading: Semantics( label: l.routingFragment, child: AppSwitch(value: value, enabled: enabled, onChanged: onChanged), ), @@ -96,12 +96,14 @@ class RoutingCard extends StatelessWidget { class RoutingCardHeader extends StatelessWidget { final String title; + final IconData? icon; final String? description; final List help; const RoutingCardHeader({ super.key, required this.title, + this.icon, this.description, this.help = const [], }); @@ -109,6 +111,10 @@ class RoutingCardHeader extends StatelessWidget { @override Widget build(BuildContext context) { final palette = ColorManager.palette(context); + final titleText = Text( + title, + style: AppTypography.routingCardTitle.copyWith(color: palette.foreground), + ); return SizedBox( width: double.infinity, child: Stack( @@ -124,12 +130,23 @@ class RoutingCardHeader extends StatelessWidget { constraints: const BoxConstraints(minHeight: 24), child: Align( alignment: AlignmentDirectional.centerStart, - child: Text( - title, - style: AppTypography.routingCardTitle.copyWith( - color: palette.foreground, - ), - ), + child: icon == null + ? titleText + : Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 3), + child: Icon( + icon, + size: 19, + color: palette.primaryText, + ), + ), + const SizedBox(width: 10), + Expanded(child: titleText), + ], + ), ), ), if (description != null) ...[ @@ -213,6 +230,7 @@ class RoutingSearchField extends StatelessWidget { class RoutingSettingRow extends StatelessWidget { final IconData icon; final String title; + final Widget? leading; final String? description; final String? help; final String? value; @@ -226,6 +244,7 @@ class RoutingSettingRow extends StatelessWidget { super.key, required this.icon, required this.title, + this.leading, this.description, this.help, this.value, @@ -297,7 +316,7 @@ class RoutingSettingRow extends StatelessWidget { children: [ Row( children: [ - Icon(icon, size: 20, color: foreground), + leading ?? Icon(icon, size: 20, color: foreground), const SizedBox(width: 13), Expanded(child: copy), ], @@ -329,7 +348,8 @@ class RoutingSettingRow extends StatelessWidget { else Row( children: [ - Icon(icon, size: mobile ? 20 : 22, color: foreground), + leading ?? + Icon(icon, size: mobile ? 20 : 22, color: foreground), const SizedBox(width: 13), Expanded(flex: navigation ? 2 : 1, child: copy), if (!mobile && value != null) ...[ @@ -400,8 +420,14 @@ class RoutingControlFrame extends StatelessWidget { class EntryCountPicker extends StatelessWidget { final int value; final ValueChanged? onChanged; + final bool fullWidth; - const EntryCountPicker({super.key, required this.value, this.onChanged}); + const EntryCountPicker({ + super.key, + required this.value, + this.onChanged, + this.fullWidth = false, + }); @override Widget build(BuildContext context) { @@ -411,106 +437,168 @@ class EntryCountPicker extends StatelessWidget { 34, platform: Theme.of(context).platform, ); - return GestureDetector( + + Widget segment(int count) => Semantics( + label: l.prototypeUseEntryServers(count), + selected: value == count, + button: true, + enabled: onChanged != null, + child: Tooltip( + message: l.prototypeUseEntryServers(count), + excludeFromSemantics: true, + child: SizedBox( + width: fullWidth ? null : extent, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onChanged == null ? null : () => onChanged!(count), + child: Container( + constraints: BoxConstraints(minHeight: extent), + decoration: BoxDecoration( + color: value == count + ? palette.selectedSurface + : palette.muted, + borderRadius: BorderRadiusDirectional.horizontal( + start: Radius.circular( + count == 1 ? AppRadii.control - 1 : 0, + ), + end: Radius.circular(count == 3 ? AppRadii.control - 1 : 0), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 6), + foregroundDecoration: BoxDecoration( + border: count < 3 + ? BorderDirectional( + end: BorderSide(color: palette.border), + ) + : null, + ), + child: Center( + heightFactor: 1, + child: ExcludeSemantics( + child: Text( + '$count', + style: AppTypography.routingCount.copyWith( + color: onChanged == null + ? Theme.of(context).disabledColor + : value == count + ? palette.primaryText + : palette.mutedStrong, + ), + ), + ), + ), + ), + ), + ), + ), + ), + ); + + Widget picker(double segmentWidth) => GestureDetector( behavior: HitTestBehavior.opaque, excludeFromSemantics: true, onTapUp: onChanged == null ? null : (details) { final logicalX = Directionality.of(context) == TextDirection.rtl - ? extent * 3 + 1 - details.localPosition.dx + ? segmentWidth * 3 + 1 - details.localPosition.dx : details.localPosition.dx - 1; - final segment = logicalX ~/ extent; + final index = logicalX ~/ segmentWidth; onChanged!( - segment < 0 + index < 0 ? 1 - : segment > 2 + : index > 2 ? 3 - : segment + 1, + : index + 1, ); }, child: RoutingControlFrame( clip: false, child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min, children: [ for (final count in const [1, 2, 3]) - Semantics( - label: l.prototypeUseEntryServers(count), - selected: value == count, - button: true, - enabled: onChanged != null, - child: Tooltip( - message: l.prototypeUseEntryServers(count), - excludeFromSemantics: true, - child: SizedBox( - width: extent, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onChanged == null - ? null - : () => onChanged!(count), - child: Container( - constraints: BoxConstraints(minHeight: extent), - decoration: BoxDecoration( - color: value == count - ? palette.selectedSurface - : palette.muted, - borderRadius: BorderRadiusDirectional.horizontal( - start: Radius.circular( - count == 1 ? AppRadii.control - 1 : 0, - ), - end: Radius.circular( - count == 3 ? AppRadii.control - 1 : 0, - ), - ), - ), - padding: const EdgeInsets.symmetric(vertical: 6), - foregroundDecoration: BoxDecoration( - border: count < 3 - ? BorderDirectional( - end: BorderSide(color: palette.border), - ) - : null, - ), - child: Center( - heightFactor: 1, - child: ExcludeSemantics( - child: Text( - '$count', - style: AppTypography.routingCount.copyWith( - color: onChanged == null - ? Theme.of(context).disabledColor - : value == count - ? palette.primaryText - : palette.mutedStrong, - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), + if (fullWidth) + Expanded(child: segment(count)) + else + segment(count), ], ), ), ); + + if (!fullWidth) return picker(extent); + return LayoutBuilder( + builder: (context, constraints) { + final minimumWidth = extent * 3 + 2; + final availableWidth = constraints.hasBoundedWidth + ? constraints.maxWidth + : minimumWidth; + final width = availableWidth < minimumWidth + ? minimumWidth + : availableWidth; + final child = SizedBox(width: width, child: picker((width - 2) / 3)); + return availableWidth < minimumWidth + ? SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: child, + ) + : child; + }, + ); } } class RoutingEntryCountRow extends StatelessWidget { final int value; final ValueChanged? onChanged; + final bool fullWidth; - const RoutingEntryCountRow({super.key, required this.value, this.onChanged}); + const RoutingEntryCountRow({ + super.key, + required this.value, + this.onChanged, + this.fullWidth = false, + }); @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; + if (fullWidth) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + l.routingEntryCountLabel, + style: AppTypography.fieldLabel.copyWith( + color: onChanged == null + ? Theme.of(context).disabledColor + : ColorManager.palette(context).foreground, + ), + ), + ), + PageHelpButton( + title: l.routingEntryCountLabel, + messages: [l.prototypeAutomaticEntryServersHint], + ), + ], + ), + const SizedBox(height: 8), + EntryCountPicker( + value: value, + onChanged: onChanged, + fullWidth: true, + ), + ], + ), + ); + } final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final picker = Align( diff --git a/lib/pages/servers/details.dart b/lib/pages/servers/details.dart index 39ff28f86..b781238a4 100644 --- a/lib/pages/servers/details.dart +++ b/lib/pages/servers/details.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/resource_json.dart'; import 'package:onexray/pages/servers/subscription/user_info.dart'; import 'package:onexray/pages/shared/share/params.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; @@ -141,39 +142,38 @@ class ServerResourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 20), SettingSection( - title: raw - ? l.xrayRawPageTitle - : provider - ? l.providerConfigurationTitle - : l.serverNodesTab, + title: l.serverResourceInformation, padding: EdgeInsets.zero, children: [ - SettingRow( - title: name, - subtitle: raw - ? l.prototypeCompleteXrayConfiguration - : controller.protocol(row), - leading: Icon( - raw || provider - ? LucideIcons.fileJson - : LucideIcons.server, - size: 20, + if (!raw && !provider) + SettingRow( + title: l.serverProtocol, + value: controller.protocol(row), + valueMaxLines: 3, + ) + else + SettingRow( + title: name, + subtitle: raw + ? l.prototypeCompleteXrayConfiguration + : l.providerConfigurationTitle, + leading: const Icon(LucideIcons.fileJson, size: 20), + titleTrailing: chosen + ? Icon( + LucideIcons.circleCheck, + size: 16, + color: ColorManager.palette(context).primary, + ) + : null, ), - titleTrailing: chosen - ? Icon( - LucideIcons.circleCheck, - size: 16, - color: ColorManager.palette(context).primary, - ) - : null, - ), SettingRow( title: l.prototypeSource, - value: raw + subtitle: raw ? l.prototypeManualAdditions : controller.sourceName(l, row), + showChevron: row.subId != 0, onTap: row.subId == 0 ? null : () => context.pushScoped( @@ -187,11 +187,11 @@ class ServerResourcePage extends StatelessWidget { if (!raw && !provider) SettingRow( title: l.prototypeSpeedTest, - value: controller.health(l, row), + subtitle: controller.health(l, row), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 20), if (raw || provider) ...[ Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -204,8 +204,14 @@ class ServerResourcePage extends StatelessWidget { ), ), ), - const SizedBox(height: 16), + const SizedBox(height: 20), ], + SettingSection( + title: l.serverConfigurationJson, + padding: EdgeInsets.zero, + children: [ServerResourceJsonPreview(row: row)], + ), + const SizedBox(height: 20), SettingSection( title: l.prototypeMoreActions, padding: EdgeInsets.zero, @@ -348,37 +354,6 @@ class ServerSourcePage extends StatelessWidget { ), ), ), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - FilledButton.icon( - onPressed: busy - ? null - : () => controller.sourceAction( - context, - source, - SourceAction.update, - ), - icon: busy - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.refreshCw, size: 17), - label: Text(l.prototypeCheckForUpdates), - ), - OutlinedButton.icon( - onPressed: busy - ? null - : () => controller.sourceAction( - context, - source, - SourceAction.edit, - ), - icon: const Icon(LucideIcons.pencil, size: 17), - label: Text(l.prototypeEditSubscription), - ), - ], - ), - const SizedBox(height: 16), SettingSection( title: l.prototypeSubscriptions, padding: EdgeInsets.zero, @@ -410,17 +385,48 @@ class ServerSourcePage extends StatelessWidget { ? source.userAgent : l.subscriptionUserAgentHint, ), + const SizedBox(height: 20), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: busy + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.update, + ), + icon: busy + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.refreshCw, size: 17), + label: Text(l.prototypeCheckForUpdates), + ), + OutlinedButton.icon( + onPressed: busy + ? null + : () => controller.sourceAction( + context, + source, + SourceAction.edit, + ), + icon: const Icon(LucideIcons.pencil, size: 17), + label: Text(l.prototypeEditSubscription), + ), + ], + ), ], ), if (package != null) ...[ - const SizedBox(height: 16), + const SizedBox(height: 20), SettingSection( title: '', padding: EdgeInsets.zero, children: [SubscriptionPackageDetails(info: package)], ), ], - const SizedBox(height: 16), + const SizedBox(height: 20), SettingSection( title: controller.providerSource(source) ? l.providerConfigurationTitle @@ -455,7 +461,7 @@ class ServerSourcePage extends StatelessWidget { ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 20), SettingSection( title: l.prototypeMoreActions, padding: EdgeInsets.zero, diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 6b82df276..2b4c2df39 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -67,6 +67,7 @@ class _ServersImportPageState extends State { canPop: state.canClose, child: ServerFormPage( title: l10n.prototypeAddServersToOneXray, + subtitle: l10n.serverImportPageDescription, onClose: () => controller.closeFlow(context), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -78,52 +79,42 @@ class _ServersImportPageState extends State { mobile ? 16 : 28, 0, ), - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - return Wrap( - spacing: 10, - runSpacing: 10, - children: [ - if (controller.supportsScan) - _choice( - context, - width, - ServerImportAction.scan, - LucideIcons.qrCode, - l10n.prototypeScanQrCode, - ), - _choice( - context, - width, - ServerImportAction.paste, - LucideIcons.clipboard, - l10n.prototypePasteLink, - ), - _choice( - context, - width, - ServerImportAction.subscription, - LucideIcons.link, - l10n.prototypeAddSubscription, - ), - _choice( - context, - width, - ServerImportAction.file, - LucideIcons.fileInput, - l10n.prototypeImportFile, - ), - _choice( - context, - width, - ServerImportAction.json, - LucideIcons.fileJson, - l10n.prototypeAddManually, - ), - ], - ); - }, + child: SettingSection( + title: l10n.serverImportMethods, + padding: EdgeInsets.zero, + children: [ + if (controller.supportsScan) + _choice( + context, + ServerImportAction.scan, + LucideIcons.qrCode, + l10n.prototypeScanQrCode, + ), + _choice( + context, + ServerImportAction.paste, + LucideIcons.clipboard, + l10n.prototypePasteLink, + ), + _choice( + context, + ServerImportAction.subscription, + LucideIcons.link, + l10n.prototypeAddSubscription, + ), + _choice( + context, + ServerImportAction.file, + LucideIcons.fileInput, + l10n.prototypeImportFile, + ), + _choice( + context, + ServerImportAction.json, + LucideIcons.fileJson, + l10n.prototypeAddManually, + ), + ], ), ), _ImportFeedback(state: state), @@ -137,66 +128,24 @@ class _ServersImportPageState extends State { Widget _choice( BuildContext context, - double width, ServerImportAction action, IconData icon, String title, - ) { - final palette = ColorManager.palette(context); - return SizedBox( - width: width, - child: OutlinedButton( - onPressed: controller.state.busy - ? null - : () => controller.open(context, action), - style: - OutlinedButton.styleFrom( - minimumSize: const Size(0, 74), - padding: const EdgeInsets.all(12), - textStyle: AppTypography.importMethod, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.card), - ), - ).copyWith( - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) - ? palette.mutedForeground - : states.contains(WidgetState.hovered) - ? palette.primary - : palette.foreground, - ), - backgroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.hovered) - ? palette.selectedSurface - : palette.card, - ), - side: WidgetStateProperty.resolveWith( - (states) => BorderSide( - color: states.contains(WidgetState.hovered) - ? palette.primary - : palette.border, - ), - ), - ), - child: Row( - children: [ - AppActivityBuilder( - builder: (context, activity) => - controller.state.openingAction == action || - (controller.state.activeAction == action && - (activity.downloading || controller.state.busy)) - ? const ButtonProgressIndicator(size: 22) - : Icon(icon, size: 22), - ), - const SizedBox(width: 10), - Expanded(child: Text(title)), - const SizedBox(width: 10), - const Icon(LucideIcons.arrowRightDir, size: 18), - ], - ), - ), - ); - } + ) => SettingRow( + key: ValueKey('server-import-choice-${action.name}'), + title: title, + enabled: !controller.state.busy, + showChevron: true, + onTap: () => controller.open(context, action), + leading: AppActivityBuilder( + builder: (context, activity) => + controller.state.openingAction == action || + (controller.state.activeAction == action && + (activity.downloading || controller.state.busy)) + ? const ButtonProgressIndicator(size: 20) + : Icon(icon, size: 20), + ), + ); } class ServerImportFormPage extends StatelessWidget { diff --git a/lib/pages/servers/resource_json.dart b/lib/pages/servers/resource_json.dart new file mode 100644 index 000000000..9495e84ba --- /dev/null +++ b/lib/pages/servers/resource_json.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; + +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; +import 'package:onexray/service/servers/outbound/state_db.dart'; +import 'package:onexray/service/shared/failure.dart'; + +/// A bounded view of this asset, independent of selection or compiled runtime. +class ServerResourceJsonPreview extends StatefulWidget { + const ServerResourceJsonPreview({super.key, required this.row}); + final CoreConfigData row; + + @override + State createState() => + _ServerResourceJsonPreviewState(); +} + +class _ServerResourceJsonPreviewState extends State { + final scroll = ScrollController(); + String? text; + Object? error; + + @override + void initState() { + super.initState(); + _read(); + } + + @override + void didUpdateWidget(ServerResourceJsonPreview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.row.data != widget.row.data || + oldWidget.row.type != widget.row.type || + oldWidget.row.name != widget.row.name) { + _read(); + } + } + + void _read() { + text = null; + error = null; + try { + final row = widget.row; + if (row.type == 'outbound') { + text = const JsonEncoder.withIndent(' ') + .convert(readOutboundFromDbData(row)); + } else { + final data = row.data; + if (data == null || data.isEmpty) { + throw const FormatException('Configuration database data is empty'); + } + final original = utf8.decode(base64Decode(data)); + if (jsonDecode(original) is! Map) { + throw const FormatException( + 'Configuration root must be a JSON object', + ); + } + // Complete configurations remain verbatim; no compile/adapt projection. + text = original; + } + } catch (failure) { + error = failure; + } + } + + @override + void dispose() { + scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: Directionality( + textDirection: TextDirection.ltr, + child: Scrollbar( + key: const ValueKey('server-resource-json-scrollbar'), + controller: scroll, + child: SingleChildScrollView( + controller: scroll, + primary: false, + child: SelectableText( + error == null + ? text! + : appFailureMessage(AppLocalizations.of(context)!, error), + key: const ValueKey('server-resource-json-text'), + textDirection: TextDirection.ltr, + style: AppTypography.supporting.copyWith( + fontFamily: AppFontFamily.mono, + color: error == null + ? ColorManager.primaryText(context) + : ColorManager.palette(context).destructive, + ), + ), + ), + ), + ), + ); +} diff --git a/lib/pages/servers/sort.dart b/lib/pages/servers/sort.dart index 499b4453f..68b99f2c6 100644 --- a/lib/pages/servers/sort.dart +++ b/lib/pages/servers/sort.dart @@ -49,13 +49,30 @@ class ServerSortButton extends StatelessWidget { } class ManualGroupOrderButton extends StatelessWidget { - const ManualGroupOrderButton({super.key, required this.controller}); + const ManualGroupOrderButton({ + super.key, + required this.controller, + this.showLabel = false, + }); final ServersController controller; + final bool showLabel; @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final searching = controller.query.trim().isNotEmpty; + final enabled = !searching && controller.catalog.manualGroups.length >= 2; + if (showLabel) { + return Tooltip( + message: searching ? l.manualOrderClearSearch : l.manualGroupOrder, + child: OutlinedButton.icon( + key: const ValueKey('manual-group-order'), + onPressed: enabled ? () => controller.openGroupOrder(context) : null, + icon: const Icon(LucideIcons.listOrdered, size: 17), + label: Text(l.manualGroupOrder), + ), + ); + } return IconButton( key: const ValueKey('manual-group-order'), tooltip: searching ? l.manualOrderClearSearch : l.manualGroupOrder, diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index 19119d68f..8e8ac7585 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -482,7 +482,11 @@ class ServerManualGroupsPage extends StatelessWidget { .where((group) => group.manual) .toList(); return Scaffold( - appBar: PageAppBar.forPage(context, title: Text(l.manualGroupsTitle)), + appBar: PageAppBar.forPage( + context, + title: Text(l.manualGroupsTitle), + description: l.manualGroupsDescription, + ), body: SafeArea( child: ResponsiveContent( child: ListView( @@ -506,7 +510,10 @@ class ServerManualGroupsPage extends StatelessWidget { icon: const Icon(LucideIcons.folderPlus, size: 17), label: Text(l.manualGroupNew), ), - ManualGroupOrderButton(controller: controller), + ManualGroupOrderButton( + controller: controller, + showLabel: true, + ), OutlinedButton.icon( onPressed: () => controller.choose( context, @@ -527,7 +534,8 @@ class ServerManualGroupsPage extends StatelessWidget { ), const SizedBox(height: 18), SettingSection( - title: '', + title: l.manualGroupsTitle, + description: l.manualGroupsUngroupedFirst, padding: EdgeInsets.zero, dividerIndent: 0, children: [ diff --git a/lib/pages/settings/about/page.dart b/lib/pages/settings/about/page.dart index 34f778b09..a4b653d08 100644 --- a/lib/pages/settings/about/page.dart +++ b/lib/pages/settings/about/page.dart @@ -76,6 +76,10 @@ class AboutOneXrayPage extends StatelessWidget { : () => controller.checkUpdate(context), ), ), + _LinkRow( + label: l10n.prototypeReleaseNotes, + link: SettingsLink.releaseNotes, + ), ], ), const SizedBox(height: 20), diff --git a/lib/pages/settings/controller.dart b/lib/pages/settings/controller.dart index 28bf013a8..e21f91577 100644 --- a/lib/pages/settings/controller.dart +++ b/lib/pages/settings/controller.dart @@ -76,6 +76,7 @@ class SettingsPageState { enum SettingsLink { documentation, + releaseNotes, review, community, feedback, @@ -275,6 +276,9 @@ class SettingsController extends PageCubit { emit(state.copyWith(clearFailure: true)); final uri = switch (link) { SettingsLink.documentation => DocURLHelper.docUri(), + SettingsLink.releaseNotes => Uri.parse( + 'https://github.com/YuanDevTeam/OneXray/releases', + ), SettingsLink.review => null, SettingsLink.community => Uri.parse('https://t.me/YuanDevTeam'), SettingsLink.feedback => Uri.parse( diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index ac0a6241b..a3df62366 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -124,13 +124,9 @@ class SettingsPage extends StatelessWidget { dividerIndent: 0, children: [ if (BackupService.supported) - SettingRow( - title: l10n.backupTitle, - subtitle: l10n.backupPageDescription, + SettingsBackupSummaryRow( minHeight: rowHeight, contentPadding: rowPadding, - titleStyle: AppTypography.rowTitle, - showChevron: true, onTap: () => controller.openSetting( context, AppPageDestination.backup, @@ -248,6 +244,39 @@ class SettingsPage extends StatelessWidget { ); } +/// Observes the existing device-local binding; opening Settings does not load it. +class SettingsBackupSummaryRow extends StatelessWidget { + const SettingsBackupSummaryRow({ + super.key, + required this.onTap, + this.service, + this.minHeight, + this.contentPadding = const EdgeInsets.all(12), + }); + + final VoidCallback onTap; + final BackupService? service; + final double? minHeight; + final EdgeInsetsGeometry contentPadding; + + @override + Widget build(BuildContext context) => BlocBuilder( + bloc: service ?? BackupService(), + builder: (context, backup) { + final l = AppLocalizations.of(context)!; + return SettingRow( + title: l.backupTitle, + subtitle: backup.settings.target?.label ?? l.backupNotConfigured, + minHeight: minHeight, + contentPadding: contentPadding, + titleStyle: AppTypography.rowTitle, + showChevron: true, + onTap: onTap, + ); + }, + ); +} + class _ThemeOptions extends StatelessWidget { final ThemeCode selected; final ValueChanged onSelected; diff --git a/lib/pages/shared/widgets/app_switch.dart b/lib/pages/shared/widgets/app_switch.dart index e889c4c39..00cb85772 100644 --- a/lib/pages/shared/widgets/app_switch.dart +++ b/lib/pages/shared/widgets/app_switch.dart @@ -1,4 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'package:material_ui/material_ui.dart' show Theme; +import 'package:onexray/pages/theme/layout.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Keeps the native accessibility state aligned with switch interaction. @@ -23,7 +25,7 @@ class AppSwitch extends StatelessWidget { final effectiveEnabled = enabled && onChanged != null; // ShadSwitch supplies toggled/tap semantics but omits enabled. iOS requires // an explicit enabled state when mapping a Flutter switch to UISwitch. - return Semantics( + final control = Semantics( enabled: effectiveEnabled, child: ShadSwitch( value: value, @@ -33,5 +35,24 @@ class AppSwitch extends StatelessWidget { height: height, ), ); + final touchExtent = AppLayout.touchExtent( + 0, + platform: Theme.of(context).platform, + ); + if (touchExtent == 0) return control; + // The track keeps its configured dimensions. Its own recognizer wins taps + // inside the track; this recognizer handles only the expanded touch area. + return GestureDetector( + behavior: HitTestBehavior.opaque, + excludeFromSemantics: true, + onTap: effectiveEnabled ? () => onChanged!(!value) : null, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: touchExtent, + minHeight: touchExtent, + ), + child: Center(widthFactor: 1, heightFactor: 1, child: control), + ), + ); } } diff --git a/test/pages/connect/routing/custom_page_parity_test.dart b/test/pages/connect/routing/custom_page_parity_test.dart new file mode 100644 index 000000000..a7ffe9106 --- /dev/null +++ b/test/pages/connect/routing/custom_page_parity_test.dart @@ -0,0 +1,192 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/connect/routing/custom/controller.dart'; +import 'package:onexray/pages/connect/routing/custom/page.dart'; +import 'package:onexray/pages/connect/routing/options_form.dart'; +import 'package:onexray/pages/connect/routing/widgets.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/routing/custom/editor.dart'; +import 'package:onexray/service/connect/routing/custom/state.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:onexray/service/shared/share/configuration_transfer.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + for (final width in [390.0, 1032.0, 1160.0]) { + testWidgets( + 'normal routing groups keep their real draft actions at $width', + (tester) async { + final bus = AppEventBus(); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final coordinator = _LayoutCoordinator(db); + final service = _LayoutService(database: db, coordinator: coordinator); + final controller = CustomRoutingEditorController(service: service); + addTearDown(() async { + await controller.close(); + coordinator.dispose(); + await db.close(); + await bus.close(); + }); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(width, 1000); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + var ruleRequests = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: width <= 720), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: width <= 720), + child: ShadToaster(child: child!), + ), + home: CustomRoutingEditorPage( + createController: () => controller, + openRule: (_, _) async { + ruleRequests++; + return RoutingRuleState(ruleTag: 'Added rule'); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.state.loaded, isTrue); + final l = AppLocalizations.of( + tester.element(find.byType(CustomRoutingEditorPage)), + )!; + final header = tester.widget(find.byType(PageAppBar)); + expect((header.title as Text).data, l.routingNormalCreateTitle); + expect(header.description, l.routingNormalDescription); + + final identity = find.byKey(const ValueKey('custom-identity')); + final rules = find.byKey(const ValueKey('custom-rule-list')); + final proxy = find.byKey(const ValueKey('custom-proxy-settings')); + final dns = find.byType(RoutingDnsSettingsForm); + final sniffing = find.byType(RoutingSniffingSettingsForm); + final transfer = find.byKey(const ValueKey('custom-transfer-tools')); + final sections = [identity, rules, proxy, dns, sniffing, transfer]; + for (var index = 1; index < sections.length; index++) { + final before = tester.getRect(sections[index - 1]); + final after = tester.getRect(sections[index]); + expect(after.top - before.bottom, closeTo(20, .01)); + expect(after.left, closeTo(before.left, .01)); + expect(after.width, closeTo(before.width, .01)); + } + expect( + find.descendant( + of: rules, + matching: find.byType(RoutingEntryCountRow), + ), + findsNothing, + ); + expect( + find.descendant(of: proxy, matching: find.byType(RoutingFragmentRow)), + findsOneWidget, + ); + expect( + find.descendant( + of: sniffing, + matching: find.byType(RoutingFakeDnsRow), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: transfer, + matching: find.byType(ConfigurationTransferTools), + ), + findsOneWidget, + ); + final count = find.byType(EntryCountPicker); + final selection = find.descendant( + of: find.byType(RoutingSelectionSettingsForm), + matching: find.byType(OutlinedButton), + ); + expect( + tester.getSize(count).width, + closeTo(tester.getSize(selection).width, .01), + ); + + final three = find.descendant(of: count, matching: find.text('3')); + await tester.ensureVisible(three); + await tester.tap(three); + await tester.pump(); + expect(controller.state.entryCount, 3); + expect(controller.profileState.entryCount, 3); + + final fragment = find.descendant( + of: find.byType(RoutingFragmentRow), + matching: find.byType(AppSwitch), + ); + await tester.ensureVisible(fragment); + await tester.tap(fragment); + await tester.pump(); + expect(controller.state.fragment, isTrue); + final fakeDns = find.descendant( + of: find.byType(RoutingFakeDnsRow), + matching: find.byType(AppSwitch), + ); + await tester.ensureVisible(fakeDns); + await tester.tap(fakeDns); + await tester.pump(); + expect(controller.state.fakeDns, isTrue); + expect(controller.state.sniffing.enabled, isTrue); + + final add = find.descendant( + of: rules, + matching: find.widgetWithText(OutlinedButton, l.prototypeAddRule), + ); + await tester.ensureVisible(add); + await tester.tap(add); + await tester.pump(); + expect(ruleRequests, 1); + expect(controller.state.rules.single.ruleTag, 'Added rule'); + expect(service.saves, 0); + expect(tester.takeException(), isNull); + }, + ); + } +} + +class _LayoutCoordinator extends ConnectionCoordinator { + _LayoutCoordinator(AppDatabase database) : super(database: database); + + @override + Future get configuration async => + ConnectionConfiguration(); +} + +class _LayoutService extends CustomRoutingEditorService { + _LayoutService({required super.database, required super.coordinator}); + int saves = 0; + + @override + Future load(int? id) async => + CustomRoutingEditorDraft( + state: RoutingProfileState(name: 'Layout draft'), + ); + + @override + Future> get rows async => []; + + @override + Future save( + CustomRoutingEditorDraft draft, { + required Future Function() confirmReconnect, + ConfigurationImportDraft? imported, + }) async { + saves++; + return null; + } +} diff --git a/test/pages/connect/routing/widgets_test.dart b/test/pages/connect/routing/widgets_test.dart index 62475d029..a14843aac 100644 --- a/test/pages/connect/routing/widgets_test.dart +++ b/test/pages/connect/routing/widgets_test.dart @@ -29,6 +29,11 @@ void main() { children: [ RoutingCardHeader(title: 'Direct', help: ['Direct traffic']), RoutingCardHeader(title: 'Proxy'), + RoutingCardHeader( + title: 'Proxy with icon', + icon: LucideIcons.gitBranch, + help: ['Proxy traffic'], + ), ], ), ), @@ -40,6 +45,11 @@ void main() { tester.getSize(headers.at(0)).height, tester.getSize(headers.at(1)).height, ); + expect( + tester.getSize(headers.at(2)).height, + tester.getSize(headers.at(1)).height, + ); + expect(find.byIcon(LucideIcons.gitBranch), findsOneWidget); expect(tester.takeException(), isNull); }); @@ -99,6 +109,134 @@ void main() { semantics.dispose(); } }); + + for (final direction in [TextDirection.ltr, TextDirection.rtl]) { + testWidgets( + 'full-width entry count maps border coordinates and disabled semantics on $platform ($direction)', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(320, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final semantics = tester.ensureSemantics(); + try { + var selected = 1; + var enabled = true; + late StateSetter update; + final changes = []; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light.copyWith(platform: platform), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(1.6)), + child: Directionality( + textDirection: direction, + child: Scaffold( + body: Padding( + padding: const EdgeInsets.all(16), + child: StatefulBuilder( + builder: (context, setState) { + update = setState; + return RoutingCard( + child: RoutingEntryCountRow( + value: selected, + fullWidth: true, + onChanged: enabled + ? (value) => setState(() { + selected = value; + changes.add(value); + }) + : null, + ), + ); + }, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final picker = find.byType(EntryCountPicker); + final l = AppLocalizations.of(tester.element(picker))!; + final rect = tester.getRect(picker); + final row = tester.getRect(find.byType(RoutingEntryCountRow)); + expect(rect.width, closeTo(row.width - 36, .01)); + final label = tester.getRect(find.text(l.routingEntryCountLabel)); + expect( + direction == TextDirection.ltr ? label.left : label.right, + closeTo( + direction == TextDirection.ltr ? rect.left : rect.right, + .01, + ), + ); + for (final count in [1, 2, 3]) { + final segment = tester.getRect( + find.bySemanticsLabel(l.prototypeUseEntryServers(count)), + ); + expect(segment.width, greaterThanOrEqualTo(extent)); + expect(segment.height, greaterThanOrEqualTo(extent)); + } + final segmentWidth = (rect.width - 2) / 3; + for ( + var physicalSegment = 0; + physicalSegment < 3; + physicalSegment++ + ) { + // The 1px outline is outside the child InkWells: this exercises + // the outer coordinate mapping rather than the segment callback. + await tester.tapAt( + Offset( + rect.left + 1 + segmentWidth * (physicalSegment + .5), + rect.top + .5, + ), + ); + await tester.pump(); + expect(changes.length, physicalSegment + 1); + expect( + changes.last, + direction == TextDirection.rtl + ? 3 - physicalSegment + : physicalSegment + 1, + ); + } + await tester.tap(find.text('2')); + await tester.pump(); + expect(changes.length, 4); + expect(changes.last, 2); + update(() => enabled = false); + await tester.pump(); + for (final count in [1, 2, 3]) { + final target = find.bySemanticsLabel( + l.prototypeUseEntryServers(count), + ); + final flags = tester + .getSemantics(target) + .getSemanticsData() + .flagsCollection; + expect(flags.isEnabled, Tristate.isFalse); + expect( + flags.isSelected, + count == 2 ? Tristate.isTrue : Tristate.isFalse, + ); + await tester.tapAt(tester.getCenter(target)); + await tester.pump(); + } + expect(changes.length, 4); + expect(selected, 2); + expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } + }, + ); + } } for (final (locale, mobile) in const [ @@ -117,6 +255,8 @@ void main() { addTearDown(tester.view.resetPhysicalSize); var selected = false; var fragmentSelected = false; + var fakeDnsChanges = 0; + var fragmentChanges = 0; await tester.pumpWidget( MaterialApp( theme: AppTheme.material(Brightness.light, mobile: mobile), @@ -138,13 +278,17 @@ void main() { children: [ RoutingFakeDnsRow( value: selected, - onChanged: (value) => - setState(() => selected = value), + onChanged: (value) => setState(() { + selected = value; + fakeDnsChanges++; + }), ), RoutingFragmentRow( value: fragmentSelected, - onChanged: (value) => - setState(() => fragmentSelected = value), + onChanged: (value) => setState(() { + fragmentSelected = value; + fragmentChanges++; + }), ), ], ), @@ -179,13 +323,29 @@ void main() { ); expect(tester.widget(fakeDnsSwitch).value, false); expect(tester.widget(fragmentSwitch).value, false); + final direction = Directionality.of(tester.element(fakeDnsSwitch)); + for (final (control, title) in [ + (fakeDnsSwitch, l.routingFakeDns), + (fragmentSwitch, l.routingFragment), + ]) { + final switchRect = tester.getRect(control); + final labelRect = tester.getRect(find.text(title)); + expect( + direction == TextDirection.rtl + ? switchRect.left > labelRect.right + : switchRect.right < labelRect.left, + isTrue, + ); + } await tester.tap(fakeDnsSwitch); await tester.pumpAndSettle(); expect(selected, true); + expect(fakeDnsChanges, 1); expect(tester.widget(fakeDnsSwitch).value, true); await tester.tap(fragmentSwitch); await tester.pumpAndSettle(); expect(fragmentSelected, true); + expect(fragmentChanges, 1); expect(tester.widget(fragmentSwitch).value, true); expect(tester.takeException(), isNull); }, diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index ea28bcb9c..06ff4a9d0 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -19,6 +19,7 @@ import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/shared/db/config_writer.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; @@ -36,6 +37,44 @@ void main() { addTearDown(bus.close); }); + testWidgets( + 'available import methods share one card and keep their real actions', + (tester) async { + _mobileViewport(tester); + final controller = ServerImportController( + loadSubscription: (_) async => null, + ); + addTearDown(controller.close); + await tester.pumpWidget(_app(ServersImportPage(controller: controller))); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(ServersImportPage)), + )!; + expect(find.text(l.serverImportPageDescription), findsOneWidget); + expect(find.byType(ShadCard), findsOneWidget); + final card = find.byType(ShadCard); + for (final action in [ + if (controller.supportsScan) ServerImportAction.scan, + ServerImportAction.paste, + ServerImportAction.subscription, + ServerImportAction.file, + ServerImportAction.json, + ]) { + final choice = find.byKey( + ValueKey('server-import-choice-${action.name}'), + ); + expect(find.descendant(of: card, matching: choice), findsOneWidget); + expect(tester.widget(choice).onTap, isNotNull); + expect(tester.widget(choice).enabled, isTrue); + } + expect( + find.byType(SettingRow), + findsNWidgets(controller.supportsScan ? 5 : 4), + ); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('manual JSON card keeps its input and add above the keyboard', ( tester, ) async { @@ -431,7 +470,7 @@ void main() { expect(controller.state.openingAction, isNull); expect( find.descendant( - of: find.widgetWithText(OutlinedButton, 'Import file'), + of: find.byKey(const ValueKey('server-import-choice-file')), matching: find.byType(ButtonProgressIndicator), ), findsOneWidget, @@ -451,14 +490,11 @@ void main() { expect(urls.length, 3); expect(find.textContaining('HTTP 403'), findsNWidgets(3)); expect(find.byType(ButtonProgressIndicator), findsNothing); - expect( - tester - .widget( - find.widgetWithText(OutlinedButton, 'Import file'), - ) - .onPressed, - isNotNull, + final fileChoice = tester.widget( + find.byKey(const ValueKey('server-import-choice-file')), ); + expect(fileChoice.enabled, isTrue); + expect(fileChoice.onTap, isNotNull); expect(tester.takeException(), isNull); }); diff --git a/test/pages/servers/manual_groups_test.dart b/test/pages/servers/manual_groups_test.dart index c815afb6e..929456204 100644 --- a/test/pages/servers/manual_groups_test.dart +++ b/test/pages/servers/manual_groups_test.dart @@ -12,6 +12,7 @@ import 'package:onexray/pages/servers/manual_group_dialogs.dart'; import 'package:onexray/pages/servers/page.dart'; import 'package:onexray/pages/servers/sources.dart'; import 'package:onexray/pages/servers/view.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; @@ -242,6 +243,18 @@ void main() { await settle(tester); expect(find.byType(ServerManualGroupsPage), findsOneWidget); expect(find.text('Empty folder'), findsOneWidget); + expect(find.text(l.manualGroupsDescription), findsOneWidget); + expect(find.text(l.manualGroupsUngroupedFirst), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is SettingSection && w.title == l.manualGroupsTitle, + ), + findsOneWidget, + ); + expect( + find.widgetWithText(OutlinedButton, l.manualGroupOrder), + findsOneWidget, + ); router!.pop(); await settle(tester); final sources = find.text(l.prototypeSubscriptions).first; diff --git a/test/pages/servers/resource_json_test.dart b/test/pages/servers/resource_json_test.dart new file mode 100644 index 000000000..ae283e200 --- /dev/null +++ b/test/pages/servers/resource_json_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/pages/servers/resource_json.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; + +CoreConfigData _row(String type, String text) => CoreConfigData( + id: 1, + name: 'Stored asset', + type: type, + tags: '', + data: base64Encode(utf8.encode(text)), + delay: 9000, + subId: 0, + manualSortOrder: 0, + favorite: false, +); + +Widget _app(CoreConfigData row) => MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView(child: ServerResourceJsonPreview(row: row)), + ), +); + +String _shown(WidgetTester tester) => tester + .widget( + find.byKey(const ValueKey('server-resource-json-text')), + ) + .data!; + +void main() { + testWidgets( + 'ordinary preview reads the stored outbound and refreshes when its data changes', + (tester) async { + final outbound = { + 'tag': 'Actual node', + 'protocol': 'socks', + 'settings': { + 'servers': [ + {'address': '127.0.0.1', 'port': 18080}, + ], + }, + 'unknownField': {'keep': true}, + }; + final row = _row( + 'outbound', + jsonEncode({ + 'outbounds': [outbound], + }), + ); + final original = row.data; + await tester.pumpWidget(_app(row)); + await tester.pumpAndSettle(); + expect(jsonDecode(_shown(tester)), outbound); + expect(row.data, original); + final replacement = {...outbound, 'tag': 'Updated stored node'}; + await tester.pumpWidget( + _app( + _row( + 'outbound', + jsonEncode({ + 'outbounds': [replacement], + }), + ), + ), + ); + await tester.pumpAndSettle(); + expect(jsonDecode(_shown(tester)), replacement); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'complete JSON remains verbatim and its preview scrolls within a bounded height', + (tester) async { + final text = + ' ${const JsonEncoder.withIndent(' ').convert({'outbounds': [], 'unknown': List.generate(90, (i) => 'stored-$i')})}\n'; + await tester.pumpWidget(_app(_row('raw', text))); + await tester.pumpAndSettle(); + expect(_shown(tester), text); + expect( + tester.getSize(find.byType(ServerResourceJsonPreview)).height, + lessThanOrEqualTo(320), + ); + final scroll = tester + .widget( + find + .descendant( + of: find.byType(ServerResourceJsonPreview), + matching: find.byType(SingleChildScrollView), + ) + .first, + ) + .controller!; + expect(scroll.position.maxScrollExtent, greaterThan(0)); + await tester.drag( + find.byKey(const ValueKey('server-resource-json-scrollbar')), + const Offset(0, -120), + ); + await tester.pumpAndSettle(); + expect(scroll.offset, greaterThan(0)); + await tester.pumpWidget(_app(_row('Remnawave', text))); + await tester.pumpAndSettle(); + expect(_shown(tester), text); + expect( + tester + .widget(find.byType(SelectableText)) + .textDirection, + TextDirection.ltr, + ); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'unreadable data replaces the previous preview with a real error instead of empty JSON', + (tester) async { + final row = _row('raw', '{"stored":"valid"}'); + await tester.pumpWidget(_app(row)); + await tester.pumpAndSettle(); + expect(_shown(tester), '{"stored":"valid"}'); + for (final bad in [ + row.copyWith(data: const Value('not-base64!')), + _row('raw', '[]'), + ]) { + await tester.pumpWidget(_app(bad)); + await tester.pumpAndSettle(); + final text = tester.widget(find.byType(SelectableText)); + expect(text.data, isNot('{}')); + expect(text.data, isNot(contains('"stored":"valid"'))); + expect(text.data, isNotEmpty); + expect( + text.style!.color, + ColorManager.palette( + tester.element(find.byType(ServerResourceJsonPreview)), + ).destructive, + ); + expect(tester.takeException(), isNull); + } + }, + ); +} diff --git a/test/pages/servers/subscription_open_test.dart b/test/pages/servers/subscription_open_test.dart index 055140729..a47abd2f0 100644 --- a/test/pages/servers/subscription_open_test.dart +++ b/test/pages/servers/subscription_open_test.dart @@ -9,6 +9,7 @@ import 'package:onexray/core/network/client.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/import/controller.dart'; import 'package:onexray/pages/servers/import/page.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; @@ -56,10 +57,10 @@ void main() { addTearDown(controller.close); await _openMethods(tester, controller); final addSubscription = tester - .widget( - find.widgetWithText(OutlinedButton, 'Add subscription'), + .widget( + find.byKey(const ValueKey('server-import-choice-subscription')), ) - .onPressed!; + .onTap!; await tester.runAsync(() async { final loaded = controller.stream.firstWhere( (state) => state.url == 'https://provider.example/list' && !state.busy, @@ -67,7 +68,7 @@ void main() { await tester.tap(find.text('Import file')); await loaded.timeout(const Duration(seconds: 5)); }); - // Exercise the previous frame's enabled button before a form is rendered. + // Exercise the previous frame's enabled row before a form is rendered. addSubscription(); await tester.runAsync(() async { recoveredPackageInfo.complete({ @@ -102,10 +103,10 @@ void main() { addTearDown(controller.close); await _openMethods(tester, controller); final addSubscription = tester - .widget( - find.widgetWithText(OutlinedButton, 'Add subscription'), + .widget( + find.byKey(const ValueKey('server-import-choice-subscription')), ) - .onPressed!; + .onTap!; await tester.tap(find.text('Add subscription')); addSubscription(); await tester.runAsync(() => Future.delayed(Duration.zero)); diff --git a/test/pages/settings/appearance_startup_update_test.dart b/test/pages/settings/appearance_startup_update_test.dart index 212c9fe10..51cd7c1d7 100644 --- a/test/pages/settings/appearance_startup_update_test.dart +++ b/test/pages/settings/appearance_startup_update_test.dart @@ -16,6 +16,8 @@ import 'package:onexray/pages/settings/app_update/controller.dart'; import 'package:onexray/pages/settings/desktop/controller.dart'; import 'package:onexray/pages/settings/desktop/page.dart'; import 'package:onexray/pages/settings/page.dart'; +import 'package:onexray/pages/settings/about/page.dart'; +import 'package:onexray/pages/settings/controller.dart'; import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/settings/theme/page.dart'; @@ -549,6 +551,53 @@ void main() { expect(tester.takeException(), isNull); }); + for (final success in [true, false]) { + testWidgets( + 'About release notes uses the real releases link and launch feedback: $success', + (tester) async { + final urls = []; + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + final messenger = tester.binding.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + final args = call.arguments as Map; + urls.add(args['url'] as String); + return success; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + final bus = AppEventBus(); + addTearDown(bus.close); + await tester.pumpWidget( + app(BlocProvider.value(value: bus, child: const AboutOneXrayPage())), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(AboutOneXrayPage)), + )!; + final row = find.widgetWithText(SettingRow, l.prototypeReleaseNotes); + expect(row, findsOneWidget); + await tester.ensureVisible(row); + await tester.pumpAndSettle(); + final controller = tester.element(row).read(); + await tester.tap(row); + await tester.pumpAndSettle(); + expect(urls, ['https://github.com/YuanDevTeam/OneXray/releases']); + if (success) { + expect(controller.state.failure, isNull); + } else { + expect( + controller.state.failure, + 'No application could open this link.', + ); + expect( + find.textContaining('No application could open this link.'), + findsOneWidget, + ); + } + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets('failed update launch reports back after the dialog closes', ( tester, ) async { diff --git a/test/pages/settings/backup_test.dart b/test/pages/settings/backup_test.dart index 00feb8a92..438fa2cd8 100644 --- a/test/pages/settings/backup_test.dart +++ b/test/pages/settings/backup_test.dart @@ -9,6 +9,7 @@ import 'package:onexray/core/backup/storage.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/settings/backup/page.dart'; +import 'package:onexray/pages/settings/page.dart'; import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/settings/backup/existing_file_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; @@ -89,6 +90,57 @@ void main() { home: BackupPage(service: service), ); + testWidgets( + 'settings summary follows the existing backup binding without file operations', + (tester) async { + var opened = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SizedBox( + width: 390, + child: SettingsBackupSummaryRow( + service: service, + onTap: () => opened++, + ), + ), + ), + ), + ); + await tester.pump(); + final context = tester.element(find.byType(SettingsBackupSummaryRow)); + final l = AppLocalizations.of(context)!; + expect(find.text(l.backupNotConfigured), findsOneWidget); + final name = + 'iCloud Drive / ${List.filled(12, 'long-folder/').join()}OneXray-backup.json'; + preferences.settings = BackupSettings( + target: BackupTarget('summary-target', name), + ); + await service.load(); + await tester.pump(); + final label = find.text(name); + expect(label, findsOneWidget); + expect(tester.widget(label).maxLines, 2); + expect(tester.widget(label).overflow, TextOverflow.ellipsis); + expect(find.text(l.backupNotConfigured), findsNothing); + await tester.tap(find.byType(SettingsBackupSummaryRow)); + await tester.pump(); + expect(opened, 1); + preferences.settings = const BackupSettings(); + await service.load(); + await tester.pump(); + expect(find.text(l.backupNotConfigured), findsOneWidget); + expect(storage.selections, isEmpty); + expect(storage.reads, 0); + expect(storage.writes, 0); + expect(tester.takeException(), isNull); + }, + ); + for (final (label, width, scale, busy, locale) in [ ('使用 iCloud Drive', 640.0, 1.0, false, const Locale('zh')), ('Use iCloud Drive', 640.0, 1.0, false, const Locale('en')), diff --git a/test/pages/shared/widgets/app_switch_test.dart b/test/pages/shared/widgets/app_switch_test.dart index 58d2f6749..510aa6cbf 100644 --- a/test/pages/shared/widgets/app_switch_test.dart +++ b/test/pages/shared/widgets/app_switch_test.dart @@ -9,6 +9,140 @@ import 'package:onexray/pages/theme/theme.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { + for (final (platform, minimum) in const [ + (TargetPlatform.iOS, 44.0), + (TargetPlatform.android, 48.0), + (TargetPlatform.macOS, 0.0), + ]) { + for (final (trackWidth, trackHeight) in const [ + (44.0, 26.0), + (60.0, 30.0), + ]) { + testWidgets( + 'switch keeps its track and one semantic toggle with a $platform touch target ($trackWidth x $trackHeight)', + (tester) async { + final semantics = tester.ensureSemantics(); + try { + var value = false; + var enabled = true; + late StateSetter update; + final changes = []; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light.copyWith(platform: platform), + home: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: Scaffold( + body: Center( + child: StatefulBuilder( + builder: (context, setState) { + update = setState; + return Semantics( + label: 'Feature', + child: AppSwitch( + value: value, + enabled: enabled, + width: trackWidth, + height: trackHeight, + onChanged: (next) => setState(() { + value = next; + changes.add(next); + }), + ), + ); + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final control = find.byType(AppSwitch); + final track = find.byType(ShadSwitch); + final bounds = tester.getRect(control); + final trackBounds = tester.getRect(track); + expect(trackBounds.width, trackWidth); + expect(trackBounds.height, trackHeight); + expect(bounds.width, greaterThanOrEqualTo(minimum)); + expect(bounds.height, greaterThanOrEqualTo(minimum)); + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != + Tristate.none, + ); + expect(toggles, findsOneWidget); + expect( + toggles.evaluate().single, + isSemantics( + label: 'Feature', + isEnabled: true, + isToggled: false, + hasTapAction: true, + ), + ); + + final edge = Offset(bounds.center.dx, bounds.top + .5); + if (minimum > 0) { + expect(trackBounds.contains(edge), isFalse); + await tester.tapAt(edge); + await tester.pumpAndSettle(); + expect(changes, [true]); + } else { + expect(bounds.size, trackBounds.size); + } + final beforeTrack = changes.length; + final nextTrackValue = !value; + await tester.tap(track); + await tester.pumpAndSettle(); + expect(changes.length, beforeTrack + 1); + expect(changes.last, nextTrackValue); + + final focus = tester.widget( + find.descendant( + of: control, + matching: find.byWidgetPredicate( + (widget) => widget is Focus && widget.focusNode != null, + ), + ), + ); + focus.focusNode!.requestFocus(); + await tester.pump(); + final beforeKeyboard = changes.length; + final nextKeyboardValue = !value; + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.pumpAndSettle(); + expect(changes.length, beforeKeyboard + 1); + expect(changes.last, nextKeyboardValue); + + update(() => enabled = false); + await tester.pumpAndSettle(); + expect(toggles, findsOneWidget); + expect( + toggles.evaluate().single, + isSemantics( + label: 'Feature', + isEnabled: false, + isToggled: value, + hasTapAction: false, + ), + ); + final beforeDisabled = List.of(changes); + await tester.tapAt(edge); + await tester.tap(track, warnIfMissed: false); + await tester.sendKeyEvent(LogicalKeyboardKey.space); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + expect(changes, beforeDisabled); + expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } + }, + ); + } + } + testWidgets('setting switch exposes its enabled state and activates once', ( tester, ) async { From d51b93d2a9e0ee8779c9d240bbf5a4b2c9faf491 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 09:58:10 +0800 Subject: [PATCH 23/60] style(ui): match prototype entry selectors and control radii --- lib/pages/connect/routing/widgets.dart | 14 ++- lib/pages/shared/widgets/setting_row.dart | 4 +- .../routing/custom_page_parity_test.dart | 14 ++- .../connect/routing/smart/layout_test.dart | 10 +- test/pages/connect/routing/widgets_test.dart | 95 +++++++++---------- 5 files changed, 73 insertions(+), 64 deletions(-) diff --git a/lib/pages/connect/routing/widgets.dart b/lib/pages/connect/routing/widgets.dart index a85a68b90..3586a51ea 100644 --- a/lib/pages/connect/routing/widgets.dart +++ b/lib/pages/connect/routing/widgets.dart @@ -6,6 +6,7 @@ import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; class RoutingFakeDnsRow extends StatelessWidget { final bool value; @@ -590,10 +591,17 @@ class RoutingEntryCountRow extends StatelessWidget { ], ), const SizedBox(height: 8), - EntryCountPicker( + SettingSelect( value: value, - onChanged: onChanged, - fullWidth: true, + entries: { + for (final count in const [1, 2, 3]) + count: l.prototypeUseEntryServers(count), + }, + onChanged: onChanged == null + ? null + : (selected) { + if (selected != null) onChanged!(selected); + }, ), ], ), diff --git a/lib/pages/shared/widgets/setting_row.dart b/lib/pages/shared/widgets/setting_row.dart index f410e21aa..1ce53335f 100644 --- a/lib/pages/shared/widgets/setting_row.dart +++ b/lib/pages/shared/widgets/setting_row.dart @@ -332,7 +332,7 @@ class SettingSelect extends StatelessWidget { onSelected: (value) => onChanged?.call(value), triggerBuilder: (open) => InkWell( onTap: onChanged == null ? null : open, - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(AppRadii.control), child: Container( constraints: BoxConstraints( minHeight: AppLayout.touchExtent( @@ -348,7 +348,7 @@ class SettingSelect extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( border: Border.all(color: ColorManager.border(context)), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(AppRadii.control), color: ColorManager.surface(context), ), child: Row( diff --git a/test/pages/connect/routing/custom_page_parity_test.dart b/test/pages/connect/routing/custom_page_parity_test.dart index a7ffe9106..f9c6cb0e6 100644 --- a/test/pages/connect/routing/custom_page_parity_test.dart +++ b/test/pages/connect/routing/custom_page_parity_test.dart @@ -10,6 +10,7 @@ import 'package:onexray/pages/connect/routing/widgets.dart'; import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; @@ -108,7 +109,7 @@ void main() { ), findsOneWidget, ); - final count = find.byType(EntryCountPicker); + final count = find.byType(SettingSelect); final selection = find.descendant( of: find.byType(RoutingSelectionSettingsForm), matching: find.byType(OutlinedButton), @@ -118,10 +119,13 @@ void main() { closeTo(tester.getSize(selection).width, .01), ); - final three = find.descendant(of: count, matching: find.text('3')); - await tester.ensureVisible(three); - await tester.tap(three); - await tester.pump(); + await tester.ensureVisible(count); + await tester.tap(count); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(MenuItemButton, l.prototypeUseEntryServers(3)), + ); + await tester.pumpAndSettle(); expect(controller.state.entryCount, 3); expect(controller.profileState.entryCount, 3); diff --git a/test/pages/connect/routing/smart/layout_test.dart b/test/pages/connect/routing/smart/layout_test.dart index e0673b646..9180a8887 100644 --- a/test/pages/connect/routing/smart/layout_test.dart +++ b/test/pages/connect/routing/smart/layout_test.dart @@ -9,6 +9,7 @@ import 'package:onexray/pages/connect/routing/options_form.dart'; import 'package:onexray/pages/connect/routing/smart/controller.dart'; import 'package:onexray/pages/connect/routing/smart/page.dart'; import 'package:onexray/pages/connect/routing/widgets.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/routing/region_catalog.dart'; @@ -262,13 +263,14 @@ void main() { await tester.pumpAndSettle(); expect(controller.state.draft.fakeDns, isTrue); - final count = find.descendant( - of: find.byType(EntryCountPicker), - matching: find.text('2'), - ); + final count = find.byType(SettingSelect); await tester.ensureVisible(count); await tester.tap(count); await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(MenuItemButton, l.prototypeUseEntryServers(2)), + ); + await tester.pumpAndSettle(); final selection = find .descendant( of: find.byType(RoutingSelectionSettingsForm), diff --git a/test/pages/connect/routing/widgets_test.dart b/test/pages/connect/routing/widgets_test.dart index a14843aac..677346029 100644 --- a/test/pages/connect/routing/widgets_test.dart +++ b/test/pages/connect/routing/widgets_test.dart @@ -1,4 +1,4 @@ -import 'dart:ui' show Tristate; +import 'dart:ui' show SemanticsAction, Tristate; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,6 +6,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/connect/routing/widgets.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:shadcn_ui/shadcn_ui.dart' show ShadSwitch, ShadTheme; @@ -112,7 +113,7 @@ void main() { for (final direction in [TextDirection.ltr, TextDirection.rtl]) { testWidgets( - 'full-width entry count maps border coordinates and disabled semantics on $platform ($direction)', + 'full-width entry dropdown keeps draft selection and disabled state on $platform ($direction)', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(320, 844); @@ -163,7 +164,8 @@ void main() { ), ); await tester.pumpAndSettle(); - final picker = find.byType(EntryCountPicker); + final picker = find.byType(SettingSelect); + expect(find.byType(EntryCountPicker), findsNothing); final l = AppLocalizations.of(tester.element(picker))!; final rect = tester.getRect(picker); final row = tester.getRect(find.byType(RoutingEntryCountRow)); @@ -176,59 +178,52 @@ void main() { .01, ), ); - for (final count in [1, 2, 3]) { - final segment = tester.getRect( - find.bySemanticsLabel(l.prototypeUseEntryServers(count)), - ); - expect(segment.width, greaterThanOrEqualTo(extent)); - expect(segment.height, greaterThanOrEqualTo(extent)); - } - final segmentWidth = (rect.width - 2) / 3; - for ( - var physicalSegment = 0; - physicalSegment < 3; - physicalSegment++ - ) { - // The 1px outline is outside the child InkWells: this exercises - // the outer coordinate mapping rather than the segment callback. - await tester.tapAt( - Offset( - rect.left + 1 + segmentWidth * (physicalSegment + .5), - rect.top + .5, + expect(rect.height, greaterThanOrEqualTo(extent)); + for (final count in [3, 1, 2]) { + final before = changes.length; + await tester.tap(picker); + await tester.pumpAndSettle(); + for (final option in [1, 2, 3]) { + expect( + find.widgetWithText( + MenuItemButton, + l.prototypeUseEntryServers(option), + ), + findsOneWidget, + ); + } + await tester.tap( + find.widgetWithText( + MenuItemButton, + l.prototypeUseEntryServers(count), ), ); - await tester.pump(); - expect(changes.length, physicalSegment + 1); - expect( - changes.last, - direction == TextDirection.rtl - ? 3 - physicalSegment - : physicalSegment + 1, - ); + await tester.pumpAndSettle(); + expect(changes.length, before + 1); + expect(changes.last, count); + expect(selected, count); + expect(tester.widget>(picker).value, count); } - await tester.tap(find.text('2')); - await tester.pump(); - expect(changes.length, 4); - expect(changes.last, 2); + expect(changes, [3, 1, 2]); update(() => enabled = false); await tester.pump(); - for (final count in [1, 2, 3]) { - final target = find.bySemanticsLabel( - l.prototypeUseEntryServers(count), - ); - final flags = tester - .getSemantics(target) + expect(tester.widget>(picker).onChanged, isNull); + final trigger = find.descendant( + of: picker, + matching: find.byType(InkWell), + ); + expect(tester.widget(trigger).onTap, isNull); + expect( + tester + .getSemantics(trigger) .getSemanticsData() - .flagsCollection; - expect(flags.isEnabled, Tristate.isFalse); - expect( - flags.isSelected, - count == 2 ? Tristate.isTrue : Tristate.isFalse, - ); - await tester.tapAt(tester.getCenter(target)); - await tester.pump(); - } - expect(changes.length, 4); + .hasAction(SemanticsAction.tap), + isFalse, + ); + await tester.tap(picker, warnIfMissed: false); + await tester.pumpAndSettle(); + expect(find.byType(MenuItemButton), findsNothing); + expect(changes, [3, 1, 2]); expect(selected, 2); expect(tester.takeException(), isNull); } finally { From 2d0c51c184ec326190c345a8c67441b5a83ef543 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 10:16:47 +0800 Subject: [PATCH 24/60] style(ui): apply shared input tokens to subscription fields --- lib/pages/servers/subscription/form_view.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/pages/servers/subscription/form_view.dart b/lib/pages/servers/subscription/form_view.dart index d94f64086..3cd50b14b 100644 --- a/lib/pages/servers/subscription/form_view.dart +++ b/lib/pages/servers/subscription/form_view.dart @@ -640,12 +640,12 @@ class SubscriptionFormView extends StatelessWidget { decoration: ShadDecoration( hasError: errorText != null, border: ShadBorder.all( - color: palette.border, - radius: BorderRadius.circular(AppRadii.card), + color: palette.input, + radius: BorderRadius.circular(AppRadii.control), ), errorBorder: ShadBorder.all( color: palette.destructive, - radius: BorderRadius.circular(AppRadii.card), + radius: BorderRadius.circular(AppRadii.control), ), ), ), From 3f9ac6df865bf41ff002df909ae5e7e7eb788bc9 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 10:43:15 +0800 Subject: [PATCH 25/60] fix(ui): align remaining tools and report partial imports accurately --- lib/l10n/app_en.arb | 11 +- lib/l10n/app_fa.arb | 10 +- lib/l10n/app_ru.arb | 10 +- lib/l10n/app_zh.arb | 10 +- lib/l10n/app_zh_Hant.arb | 10 +- lib/pages/advanced/local_api/page.dart | 103 +++---- lib/pages/advanced/tunnel/apple/wifi.dart | 1 + lib/pages/advanced/tunnel/interface/page.dart | 1 + lib/pages/advanced/xray/geodata/add.dart | 8 + lib/pages/advanced/xray/geodata/detail.dart | 253 ++++++++---------- lib/pages/advanced/xray/geodata/page.dart | 52 ++-- lib/pages/advanced/xray/geodata/view.dart | 146 +++++----- lib/pages/advanced/xray/log/settings.dart | 33 ++- lib/pages/servers/import/controller.dart | 49 +++- lib/pages/servers/import/page.dart | 33 ++- test/pages/advanced/local_api/page_test.dart | 26 ++ .../advanced/xray/geodata/add_page_test.dart | 2 + .../advanced/xray/geodata/rows_test.dart | 11 +- test/pages/advanced/xray/log_test.dart | 20 ++ test/pages/servers/import_test.dart | 189 ++++++++++++- 20 files changed, 631 insertions(+), 347 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4eabaa96a..bd0c28e94 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3271,5 +3271,14 @@ "manualGroupsUngroupedFirst": "Ungrouped always appears first.", "serverResourceInformation": "Resource information", "serverProtocol": "Protocol", - "serverConfigurationJson": "Configuration JSON" + "serverConfigurationJson": "Configuration JSON", + "geodataAddDescription": "Add an HTTPS source for GeoIP or GeoSite rules.", + "geodataValidatedBeforeAdding": "Adding downloads and validates the resource before saving its source.", + "geodataCategoriesAndReferences": "Categories and references", + "xrayRecordLogsHint": "Save core runtime logs to help diagnose problems.", + "xrayRecordDnsHint": "Logs may include the domains you visit.", + "xrayMaskLogIpHint": "Hide IP addresses in logs.", + "importPreviewIncomplete": "Import incomplete", + "importPreviewResultSummary": "Imported: {completed}. Failed: {failed}.", + "@importPreviewResultSummary": {"placeholders": {"completed": {"type": "int"}, "failed": {"type": "int"}}} } diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index c4019d451..3a4a7e86c 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -1090,5 +1090,13 @@ "manualGroupsUngroupedFirst": "گره‌های بدون گروه همیشه در ابتدا نمایش داده می‌شوند.", "serverResourceInformation": "اطلاعات منبع", "serverProtocol": "پروتکل", - "serverConfigurationJson": "JSON پیکربندی" + "serverConfigurationJson": "JSON پیکربندی", + "geodataAddDescription": "یک منبع HTTPS برای قوانین GeoIP یا GeoSite اضافه کنید.", + "geodataValidatedBeforeAdding": "هنگام افزودن، منبع پس از دریافت و اعتبارسنجی فایل ذخیره می‌شود.", + "geodataCategoriesAndReferences": "دسته‌ها و ارجاع‌ها", + "xrayRecordLogsHint": "گزارش‌های هسته را برای عیب‌یابی ذخیره کنید.", + "xrayRecordDnsHint": "گزارش‌ها ممکن است شامل دامنه‌های بازدیدشده باشند.", + "xrayMaskLogIpHint": "نشانی‌های IP را در گزارش‌ها پنهان کنید.", + "importPreviewIncomplete": "واردسازی کامل نشد", + "importPreviewResultSummary": "واردشده: {completed}؛ ناموفق: {failed}." } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 423c585a7..3a34c321d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1090,5 +1090,13 @@ "manualGroupsUngroupedFirst": "Узлы без группы всегда отображаются первыми.", "serverResourceInformation": "Информация о ресурсе", "serverProtocol": "Протокол", - "serverConfigurationJson": "JSON конфигурации" + "serverConfigurationJson": "JSON конфигурации", + "geodataAddDescription": "Добавьте источник HTTPS для правил GeoIP или GeoSite.", + "geodataValidatedBeforeAdding": "Перед сохранением источника ресурс скачивается и проверяется.", + "geodataCategoriesAndReferences": "Категории и ссылки", + "xrayRecordLogsHint": "Сохраняйте журналы ядра для диагностики проблем.", + "xrayRecordDnsHint": "Журналы могут содержать посещённые домены.", + "xrayMaskLogIpHint": "Скрывать IP-адреса в журналах.", + "importPreviewIncomplete": "Импорт завершён не полностью", + "importPreviewResultSummary": "Импортировано: {completed}. Не импортировано: {failed}." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b5c8ffba5..151778c8f 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1090,5 +1090,13 @@ "manualGroupsUngroupedFirst": "未分组固定显示在最前。", "serverResourceInformation": "资源信息", "serverProtocol": "协议", - "serverConfigurationJson": "配置 JSON" + "serverConfigurationJson": "配置 JSON", + "geodataAddDescription": "添加用于 GeoIP 或 GeoSite 规则的 HTTPS 数据来源。", + "geodataValidatedBeforeAdding": "添加时会下载并验证资源,成功后才保存来源。", + "geodataCategoriesAndReferences": "分类与引用", + "xrayRecordLogsHint": "保存内核运行日志,便于排查问题。", + "xrayRecordDnsHint": "日志可能包含访问的域名。", + "xrayMaskLogIpHint": "在日志中隐藏 IP 地址。", + "importPreviewIncomplete": "导入未完成", + "importPreviewResultSummary": "已导入 {completed} 项,失败 {failed} 项。" } diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 453371f72..8b9e9e2f4 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -1090,5 +1090,13 @@ "manualGroupsUngroupedFirst": "未分組固定顯示在最前。", "serverResourceInformation": "資源資訊", "serverProtocol": "通訊協定", - "serverConfigurationJson": "設定 JSON" + "serverConfigurationJson": "設定 JSON", + "geodataAddDescription": "新增用於 GeoIP 或 GeoSite 規則的 HTTPS 資料來源。", + "geodataValidatedBeforeAdding": "新增時會下載並驗證資源,成功後才儲存來源。", + "geodataCategoriesAndReferences": "分類與引用", + "xrayRecordLogsHint": "儲存核心執行日誌,便於排查問題。", + "xrayRecordDnsHint": "日誌可能包含造訪的網域。", + "xrayMaskLogIpHint": "在日誌中隱藏 IP 位址。", + "importPreviewIncomplete": "匯入未完成", + "importPreviewResultSummary": "已匯入 {completed} 項,失敗 {failed} 項。" } diff --git a/lib/pages/advanced/local_api/page.dart b/lib/pages/advanced/local_api/page.dart index 5be67bf86..0280dad72 100644 --- a/lib/pages/advanced/local_api/page.dart +++ b/lib/pages/advanced/local_api/page.dart @@ -1,10 +1,9 @@ -import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; -import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/local_api/controller.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; @@ -77,17 +76,16 @@ class LocalApiPage extends StatelessWidget { icon: LucideIcons.terminal, padding: EdgeInsets.zero, children: [ - SettingRow( + XrayToggleField( title: l.localApiEnable, - titleMaxLines: 4, - trailing: AppSwitch( - key: const ValueKey('local-api-enabled'), - value: state.enabled, - enabled: !state.busy, - onChanged: state.busy - ? null - : controller.setEnabled, + description: l.localApiAppRequired, + switchKey: const ValueKey( + 'local-api-enabled', ), + value: state.enabled, + onChanged: state.busy + ? null + : controller.setEnabled, ), Padding( padding: const EdgeInsets.symmetric( @@ -116,10 +114,11 @@ class LocalApiPage extends StatelessWidget { textAlign: TextAlign.left, autocorrect: false, style: AppTypography.rowValue, - decoration: const InputDecoration( + decoration: InputDecoration( hintText: '18587', hintTextDirection: TextDirection.ltr, + fillColor: palette.card, ), ), ), @@ -128,48 +127,35 @@ class LocalApiPage extends StatelessWidget { ), SettingRow( title: l.prototypeRuntimeStatus, - subtitleWidget: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - state.listening - ? l.localApiListening - : l.localApiStopped, - ), - if (state.runtimeError != null) - Text( - state.runtimeError!, - style: AppTypography - .settingsDetailNote - .copyWith( - color: palette.destructive, - ), - ), - ], + trailing: ShadBadge.secondary( + child: Text( + state.listening + ? l.localApiListening + : l.localApiStopped, + ), ), ), - Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Text( - l.localApiEndpoint, - style: AppTypography.rowTitle, - ), - const SizedBox(height: 8), - SelectableText( - state.saved!.endpoint, - key: const ValueKey( - 'local-api-endpoint', - ), - textDirection: TextDirection.ltr, - textAlign: TextAlign.left, - style: AppTypography.settingsInput, - ), - ], + if (state.runtimeError != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: Text( + state.runtimeError!, + style: AppTypography.settingsDetailNote + .copyWith(color: palette.destructive), + ), + ), + SettingRow( + title: l.localApiEndpoint, + subtitleWidget: SelectableText( + state.saved!.endpoint, + key: const ValueKey('local-api-endpoint'), + textDirection: TextDirection.ltr, + textAlign: TextAlign.left, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), ), ), ], @@ -178,11 +164,7 @@ class LocalApiPage extends StatelessWidget { SettingSection( title: l.localApiToken, icon: LucideIcons.keyRound, - action: PageHelpButton( - title: l.localApiToken, - messages: [l.localApiTokenHint], - ), - descriptionBelow: true, + description: l.localApiTokenHint, padding: EdgeInsets.zero, children: [ Align( @@ -225,13 +207,6 @@ class LocalApiPage extends StatelessWidget { ), ], ), - const SizedBox(height: 24), - Text( - l.localApiAppRequired, - style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, - ), - ), ], if (state.error != null) Padding( diff --git a/lib/pages/advanced/tunnel/apple/wifi.dart b/lib/pages/advanced/tunnel/apple/wifi.dart index d6c291b77..9fb1afbb2 100644 --- a/lib/pages/advanced/tunnel/apple/wifi.dart +++ b/lib/pages/advanced/tunnel/apple/wifi.dart @@ -23,6 +23,7 @@ class AppleWifiPage extends StatelessWidget { final controller = context.read(); return PolicyDetailScaffold( title: l.prototypeWifiRules, + description: l.tunnelDetailDescription, controller: controller, canSave: !controller.wifiConflict, contentPadding: EdgeInsets.zero, diff --git a/lib/pages/advanced/tunnel/interface/page.dart b/lib/pages/advanced/tunnel/interface/page.dart index 220ac53c4..8e5b347e3 100644 --- a/lib/pages/advanced/tunnel/interface/page.dart +++ b/lib/pages/advanced/tunnel/interface/page.dart @@ -88,6 +88,7 @@ class OutboundInterfaceView extends StatelessWidget { final selected = controller.value['xrayOutboundInterfaceName'] as String; return PolicyDetailScaffold( title: l.prototypeXrayOutboundInterface, + description: l.tunnelDetailDescription, controller: controller, canSave: !controller.loading && !controller.failed && selected.isNotEmpty, diff --git a/lib/pages/advanced/xray/geodata/add.dart b/lib/pages/advanced/xray/geodata/add.dart index becf08226..04063c84b 100644 --- a/lib/pages/advanced/xray/geodata/add.dart +++ b/lib/pages/advanced/xray/geodata/add.dart @@ -67,6 +67,7 @@ class GeoDataAddPage extends StatelessWidget { appBar: PageAppBar.forPage( context, title: Text(l.prototypeAddDataSource), + description: l.geodataAddDescription, leading: BackButton(onPressed: cancel), ), body: SafeArea( @@ -136,6 +137,13 @@ class GeoDataAddPage extends StatelessWidget { ), ), ), + const SizedBox(height: 12), + Text( + l.geodataValidatedBeforeAdding, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), if (state.formError != null) ...[ const SizedBox(height: 12), Semantics( diff --git a/lib/pages/advanced/xray/geodata/detail.dart b/lib/pages/advanced/xray/geodata/detail.dart index 5999f5bcd..c72530030 100644 --- a/lib/pages/advanced/xray/geodata/detail.dart +++ b/lib/pages/advanced/xray/geodata/detail.dart @@ -51,6 +51,7 @@ class _GeoDataFilePageState extends State { appBar: PageAppBar.forPage( context, title: Text(file?.fileName ?? l.prototypeRoutingData), + description: l.pageXrayConfigurationDescription, actions: const [AppActivityIndicator(pinging: false)], ), body: SafeArea( @@ -79,89 +80,45 @@ class _GeoDataFilePageState extends State { gutter, 20, gutter, - 12, + 20, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Align( - alignment: AlignmentDirectional.centerStart, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 9, - vertical: 4, - ), - decoration: BoxDecoration( - color: palette.surfaceHover, - borderRadius: BorderRadius.circular( - AppRadii.pill, - ), - ), - child: Text( - l.prototypeReadOnly, - style: AppTypography.geodataReadonly - .copyWith( - color: palette.mutedStrong, - ), + SettingSection( + title: l.serverResourceInformation, + action: Text( + l.prototypeReadOnly, + style: AppTypography.badge.copyWith( + color: palette.mutedForeground, ), ), - ), - const SizedBox(height: 12), - SettingSection( - title: l.prototypeDataSource, icon: LucideIcons.globe2, padding: EdgeInsets.zero, dividerIndent: 0, children: [ - Padding( - padding: const EdgeInsets.all(12), - child: Row( - spacing: 12, - children: [ - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Text( - l.prototypeSourceUrl, - style: AppTypography - .geodataSourceLabel - .copyWith( - color: palette - .mutedForeground, - ), - ), - const SizedBox(height: 6), - SelectableText( - file.row.url, - textDirection: - TextDirection.ltr, - style: AppTypography - .geodataSourceUrl, - ), - ], - ), - ), - _copyButton( - context, - tooltip: l.prototypeCopySourceUrl, - onPressed: () => controller.copy( - context, - file.row.url, - l.prototypeSourceUrlCopied, + SettingRow( + title: l.prototypeSourceUrl, + subtitleWidget: SelectableText( + file.row.url, + textDirection: TextDirection.ltr, + style: AppTypography.supporting + .copyWith( + color: palette.mutedForeground, ), - ), - ], + ), + trailing: _copyButton( + context, + tooltip: l.prototypeCopySourceUrl, + onPressed: () => controller.copy( + context, + file.row.url, + l.prototypeSourceUrlCopied, + ), ), ), SettingRow( title: l.prototypeDataType, - titleStyle: AppTypography.geodataBody, - minHeight: mobile ? 43 : 56, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - ), trailing: Text( file.row.type == 'ip' ? 'GeoIP' @@ -171,11 +128,6 @@ class _GeoDataFilePageState extends State { ), SettingRow( title: l.prototypeCategories, - titleStyle: AppTypography.geodataBody, - minHeight: mobile ? 43 : 56, - contentPadding: EdgeInsets.symmetric( - horizontal: mobile ? 13 : 14, - ), trailing: Text( '${file.index.categoryCount}', style: AppTypography.rowTitle, @@ -183,36 +135,6 @@ class _GeoDataFilePageState extends State { ), ], ), - const SizedBox(height: 16), - Semantics( - label: l.prototypeSearchCategories, - child: ShadInput( - onChanged: controller.searchChanged, - gap: 10, - style: AppTypography.geodataBody, - placeholderStyle: AppTypography - .geodataBody - .copyWith( - color: palette.mutedForeground, - ), - placeholder: Text( - l.prototypeSearchCategories, - ), - leading: Icon( - LucideIcons.search, - size: 18, - color: palette.mutedForeground, - ), - decoration: ShadDecoration( - border: ShadBorder.all( - color: palette.border, - radius: BorderRadius.circular( - AppRadii.control, - ), - ), - ), - ), - ), ], ), ), @@ -234,43 +156,100 @@ class _GeoDataFilePageState extends State { ), sliver: SliverPadding( padding: const EdgeInsets.all(18), - sliver: codes.isEmpty - ? SliverToBoxAdapter( - child: Container( - constraints: BoxConstraints( - minHeight: mobile ? 0 : 72, + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + l.geodataCategoriesAndReferences, + style: AppTypography.sectionTitle + .copyWith( + color: palette.foreground, + ), + ), ), - alignment: mobile - ? AlignmentDirectional.centerStart - : Alignment.center, - padding: const EdgeInsets.all(18), - child: Text( - l.prototypeNoMatchingCategories, - textAlign: mobile - ? TextAlign.start - : TextAlign.center, - style: - (mobile - ? AppTypography - .geodataEmpty - : AppTypography - .geodataTableBody) - .copyWith( - color: palette - .mutedForeground, - ), + const SizedBox(height: 16), + Semantics( + label: l.prototypeSearchCategories, + child: ShadInput( + onChanged: + controller.searchChanged, + gap: 10, + style: AppTypography.geodataBody, + placeholderStyle: AppTypography + .geodataBody + .copyWith( + color: + palette.mutedForeground, + ), + placeholder: Text( + l.prototypeSearchCategories, + ), + leading: Icon( + LucideIcons.search, + size: 18, + color: palette.mutedForeground, + ), + decoration: ShadDecoration( + border: ShadBorder.all( + color: palette.border, + radius: BorderRadius.circular( + AppRadii.control, + ), + ), + ), + ), ), - ), - ) - : GeoDataCategorySliver( - file: file, - codes: codes, - onCopy: (reference) => controller.copy( - context, - reference, - l.prototypeRuleReferenceCopied, - ), + const SizedBox(height: 12), + ], ), + ), + codes.isEmpty + ? SliverToBoxAdapter( + child: Container( + constraints: BoxConstraints( + minHeight: mobile ? 0 : 72, + ), + alignment: mobile + ? AlignmentDirectional + .centerStart + : Alignment.center, + padding: const EdgeInsets.all(18), + child: Text( + l.prototypeNoMatchingCategories, + textAlign: mobile + ? TextAlign.start + : TextAlign.center, + style: + (mobile + ? AppTypography + .geodataEmpty + : AppTypography + .geodataTableBody) + .copyWith( + color: palette + .mutedForeground, + ), + ), + ), + ) + : GeoDataCategorySliver( + file: file, + codes: codes, + onCopy: (reference) => + controller.copy( + context, + reference, + l.prototypeRuleReferenceCopied, + ), + ), + ], + ), ), ), ), diff --git a/lib/pages/advanced/xray/geodata/page.dart b/lib/pages/advanced/xray/geodata/page.dart index f95c4ee11..5dad8e7b8 100644 --- a/lib/pages/advanced/xray/geodata/page.dart +++ b/lib/pages/advanced/xray/geodata/page.dart @@ -82,8 +82,6 @@ class _GeoDataPageState extends State { 28, ), children: [ - _intro(context, state, controller), - const SizedBox(height: 20), SettingSection( title: l.prototypeDefaultRoutingData, padding: EdgeInsets.zero, @@ -129,16 +127,6 @@ class _GeoDataPageState extends State { SettingSection( title: l.prototypeCustomRoutingData, padding: EdgeInsets.zero, - action: OutlinedButton.icon( - style: _headingButtonStyle(context), - onPressed: state.formBusy - ? null - : widget.openAdd == null - ? null - : () => widget.openAdd!(context), - icon: const Icon(LucideIcons.plus, size: 16), - label: Text(l.prototypeAddDataSource), - ), children: [ if (state.custom.isEmpty && state.pending.isEmpty) Padding( @@ -192,8 +180,23 @@ class _GeoDataPageState extends State { '${row.name}.dat: ${state.errors[row.id]}', ), ], + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: _headingButtonStyle(context), + onPressed: + state.formBusy || widget.openAdd == null + ? null + : () => widget.openAdd!(context), + icon: const Icon(LucideIcons.plus, size: 16), + label: Text(l.prototypeAddDataSource), + ), + ), ], ), + const SizedBox(height: 12), + _updateActions(context, state, controller), ], ), ), @@ -204,30 +207,18 @@ class _GeoDataPageState extends State { ), ); - Widget _intro( + Widget _updateActions( BuildContext context, GeoDataPageState state, GeoDataController controller, ) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final actions = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: mobile - ? CrossAxisAlignment.stretch - : CrossAxisAlignment.end, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - FilledButton.icon( - style: mobile - ? FilledButton.styleFrom( - minimumSize: const Size(0, 48), - padding: const EdgeInsets.symmetric(horizontal: 7), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: AppTypography.geodataPrimaryAction, - ) - : FilledButton.styleFrom(minimumSize: const Size(132, 42)), + OutlinedButton.icon( + style: _headingButtonStyle(context), onPressed: !state.canUpdateAll ? null : () => controller.updateAll(context), @@ -241,14 +232,13 @@ class _GeoDataPageState extends State { const SizedBox(height: 5), Text( l.prototypeHttpsOnly, - textAlign: mobile ? TextAlign.center : TextAlign.end, + textAlign: TextAlign.start, style: AppTypography.geodataValue.copyWith( color: palette.mutedForeground, ), ), ], ); - return Align(alignment: AlignmentDirectional.centerEnd, child: actions); } ButtonStyle _headingButtonStyle(BuildContext context) { diff --git a/lib/pages/advanced/xray/geodata/view.dart b/lib/pages/advanced/xray/geodata/view.dart index 650898348..6c80c93ca 100644 --- a/lib/pages/advanced/xray/geodata/view.dart +++ b/lib/pages/advanced/xray/geodata/view.dart @@ -10,6 +10,7 @@ import 'package:intl/intl.dart' show DateFormat; import 'package:onexray/service/advanced/xray/geodata/model.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; /// Pending declarations have no published size, timestamp or category page. class PendingGeoDataRow extends StatelessWidget { @@ -31,34 +32,39 @@ class PendingGeoDataRow extends StatelessWidget { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - return Container( - margin: const EdgeInsets.only(top: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: palette.secondary, - borderRadius: BorderRadius.circular(AppRadii.control), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 8, - children: [ - Text( - '${row.name}.dat', - textDirection: TextDirection.ltr, - style: AppTypography.geodataValue, - ), - Text( - Uri.tryParse(row.url)?.host ?? '', - textDirection: TextDirection.ltr, - style: AppTypography.geodataValue, + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingRow( + title: '${row.name}.dat', + titleTextDirection: TextDirection.ltr, + subtitleWidget: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + Uri.tryParse(row.url)?.host ?? '', + textDirection: TextDirection.ltr, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + Text( + l.geodataPendingDownload, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + ], ), - Text( - l.geodataPendingDownload, - style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, - ), + ), + Padding( + padding: const EdgeInsetsDirectional.only( + start: 12, + end: 12, + bottom: 8, ), - Wrap( + child: Wrap( spacing: 8, runSpacing: 8, children: [ @@ -80,8 +86,8 @@ class PendingGeoDataRow extends StatelessWidget { ), ], ), - ], - ), + ), + ], ); } } @@ -126,27 +132,28 @@ class GeoDataRows extends StatelessWidget { label: file.builtIn ? l.prototypeDefaultRoutingData : l.prototypeCustomRuleDataset(file.row.id), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 8, - children: [ - _name(context, file), - Semantics( - label: l.prototypeSource, - child: Text( - file.sourceHost, - textDirection: TextDirection.ltr, - style: AppTypography.supporting.copyWith( - color: palette.mutedForeground, - ), - ), - ), - Wrap( - spacing: 16, - runSpacing: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SettingRow( + title: file.fileName, + titleTextDirection: TextDirection.ltr, + onTap: () => onOpen(file), + showChevron: true, + subtitleWidget: Wrap( + spacing: 12, + runSpacing: 4, children: [ + Semantics( + label: l.prototypeSource, + child: Text( + file.sourceHost, + textDirection: TextDirection.ltr, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + ), Semantics( label: l.prototypeSize, child: Text( @@ -170,45 +177,24 @@ class GeoDataRows extends StatelessWidget { ), ], ), - if (custom) - Align( + ), + if (custom) + Padding( + padding: const EdgeInsetsDirectional.only( + start: 12, + end: 12, + bottom: 8, + ), + child: Align( alignment: AlignmentDirectional.centerStart, child: _actions(context, file), ), - ], - ), + ), + ], ), ); } - Widget _name(BuildContext context, GeoDataFile file) => TextButton( - style: TextButton.styleFrom( - alignment: AlignmentDirectional.centerStart, - minimumSize: Size( - 0, - AppLayout.touchExtent( - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint - ? 48 - : 42, - platform: Theme.of(context).platform, - ), - ), - padding: EdgeInsets.zero, - textStyle: AppTypography.rowTitle, - foregroundColor: ColorManager.palette(context).foreground, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: () => onOpen(file), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible(child: Text(file.fileName, textDirection: TextDirection.ltr)), - const SizedBox(width: 6), - const Icon(LucideIcons.chevronRightDir, size: 15), - ], - ), - ); - Widget _actions(BuildContext context, GeoDataFile file) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); diff --git a/lib/pages/advanced/xray/log/settings.dart b/lib/pages/advanced/xray/log/settings.dart index 7a6d982ed..c77635780 100644 --- a/lib/pages/advanced/xray/log/settings.dart +++ b/lib/pages/advanced/xray/log/settings.dart @@ -3,9 +3,9 @@ import 'package:material_ui/material_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; import 'package:onexray/pages/shared/alert.dart'; -import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; @@ -41,16 +41,15 @@ class XrayLogSettingsPage extends StatelessWidget { !controller.loading && controller.base != null && !state.systemExtension; - Widget toggle(String title, String field, bool value) => SettingRow( - title: title, - trailing: AppSwitch( - value: value, - enabled: !controller.busy, - onChanged: controller.busy - ? null - : (value) => controller.setLog(field, value), - ), - ); + Widget toggle(String title, String field, bool value, String hint) => + XrayToggleField( + title: title, + description: hint, + value: value, + onChanged: controller.busy + ? null + : (value) => controller.setLog(field, value), + ); return PopScope( canPop: !controller.busy, child: Scaffold( @@ -103,11 +102,14 @@ class XrayLogSettingsPage extends StatelessWidget { l.prototypeRecordXrayLogs, 'enabled', controller.logsEnabled, + l.xrayRecordLogsHint, ), if (controller.logsEnabled) ...[ - SettingRow( - title: l.prototypeErrorLogLevel, - trailing: SettingSelect( + const SizedBox(height: 12), + XrayLabeledField( + key: const ValueKey('xray-log-level-field'), + label: l.prototypeErrorLogLevel, + child: SettingSelect( value: controller.level, textStyle: AppTypography.rowValue, entries: { @@ -121,15 +123,18 @@ class XrayLogSettingsPage extends StatelessWidget { : controller.setLevel, ), ), + const SizedBox(height: 12), toggle( l.prototypeRecordDnsQueries, 'recordDns', controller.recordDns, + l.xrayRecordDnsHint, ), toggle( l.prototypeHideLogIpAddresses, 'maskIp', controller.maskIp, + l.xrayMaskLogIpHint, ), ], ], diff --git a/lib/pages/servers/import/controller.dart b/lib/pages/servers/import/controller.dart index e3b7d6604..5cad8b127 100644 --- a/lib/pages/servers/import/controller.dart +++ b/lib/pages/servers/import/controller.dart @@ -48,6 +48,7 @@ class ServerImportPageState { Set touchedSubscriptionFields = const {}, this.jsonDiagnostic, List subscriptionImports = const [], + Map subscriptionImportModes = const {}, this.committedResult, this.ageExpanded = false, this.hasAgeKeys = false, @@ -56,6 +57,7 @@ class ServerImportPageState { this.panel = SubscriptionPanel.generic, this.mode, }) : subscriptionImports = List.unmodifiable(subscriptionImports), + subscriptionImportModes = Map.unmodifiable(subscriptionImportModes), touchedSubscriptionFields = Set.unmodifiable(touchedSubscriptionFields); final String inputText; @@ -77,6 +79,7 @@ class ServerImportPageState { final Set touchedSubscriptionFields; final JsonDiagnostic? jsonDiagnostic; final List subscriptionImports; + final Map subscriptionImportModes; final ServerImportResult? committedResult; final bool ageExpanded; final bool hasAgeKeys; @@ -96,6 +99,17 @@ class ServerImportPageState { .where((item) => item.result.success) .fold(0, (total, item) => total + item.result.count); + String subscriptionImportSummary( + AppLocalizations l10n, + ServerSubscriptionImport item, + ) => switch (subscriptionImportModes[item.result.subId]) { + SubscriptionMode.providerManaged => l10n.providerConfigurationCount( + item.result.count, + ), + SubscriptionMode.appManaged => l10n.prototypeUsableNodes(item.result.count), + null => l10n.prototypeSubscriptionSaved, + }; + ServerImportPageState copyWith({ String? inputText, String? jsonInput, @@ -116,6 +130,7 @@ class ServerImportPageState { Set? touchedSubscriptionFields, Object? jsonDiagnostic = _unset, List? subscriptionImports, + Map? subscriptionImportModes, Object? committedResult = _unset, bool? ageExpanded, bool? hasAgeKeys, @@ -154,6 +169,8 @@ class ServerImportPageState { ? this.jsonDiagnostic : jsonDiagnostic as JsonDiagnostic?, subscriptionImports: subscriptionImports ?? this.subscriptionImports, + subscriptionImportModes: + subscriptionImportModes ?? this.subscriptionImportModes, committedResult: identical(committedResult, _unset) ? this.committedResult : committedResult as ServerImportResult?, @@ -583,9 +600,29 @@ class ServerImportController extends PageCubit { pending, confirmOrdinary: (name) => _confirmOrdinary(context, name), ); + final modes = Map.of( + state.subscriptionImportModes, + ); for (var index = 0; index < results.length; index++) { if (results[index].result.success) { _completedSubscriptions[pending[index].url] = results[index]; + final id = results[index].result.subId; + modes.remove(id); + if (id > 0) { + try { + final saved = await _loadSubscription(id); + final mode = saved == null + ? null + : SubscriptionMode.fromString(saved.mode); + if (mode != null) { + modes[id] = mode; + } + } catch (_) { + // The write already succeeded. Unavailable display metadata + // uses a neutral success message rather than guessing its type. + } + if (!isPageActive || !context.mounted) return null; + } } } emit( @@ -594,6 +631,7 @@ class ServerImportController extends PageCubit { ..._completedSubscriptions.values, ...results.where((item) => !item.result.success), ], + subscriptionImportModes: modes, ), ); } finally { @@ -629,7 +667,16 @@ class ServerImportController extends PageCubit { if (context.mounted) { ContextAlert.showToast( context, - AppLocalizations.of(context)!.prototypeUsableNodes(result.count), + state.importedSubscriptionCount == 1 + ? state.subscriptionImportSummary( + AppLocalizations.of(context)!, + state.subscriptionImports.firstWhere( + (item) => item.result.success, + ), + ) + : AppLocalizations.of(context)!.prototypeSubscriptionsImported( + state.importedSubscriptionCount, + ), ); } } diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 2b4c2df39..017f7ce0d 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -440,6 +440,14 @@ class ServerImportPreviewPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); final committed = state.committedResult; + final incomplete = committed != null && committed.writeFailureCount > 0; + final completedCount = committed == null + ? 0 + : committed.count + + committed.rawCount + + committed.customCount + + committed.geoDataCount + + preview.assets.length; final itemCount = preview.rows.length + preview.raw.length + @@ -466,6 +474,8 @@ class ServerImportPreviewPage extends StatelessWidget { decoration: BoxDecoration( color: committed == null ? palette.card + : incomplete + ? palette.destructiveSurface : palette.runningSurface, border: committed == null ? Border.all(color: palette.border) @@ -478,10 +488,14 @@ class ServerImportPreviewPage extends StatelessWidget { Icon( committed == null ? LucideIcons.list + : incomplete + ? LucideIcons.triangleAlert : LucideIcons.circleCheck, size: 26, color: committed == null ? palette.mutedStrong + : incomplete + ? palette.destructive : palette.running, ), Expanded( @@ -492,16 +506,23 @@ class ServerImportPreviewPage extends StatelessWidget { Text( committed == null ? l10n.importPreviewPending + : incomplete + ? l10n.importPreviewIncomplete : l10n.prototypeImportedLinks, style: AppTypography.importSummary, ), Text( - l10n.prototypeItemCount(itemCount), + committed == null + ? l10n.prototypeItemCount(itemCount) + : l10n.importPreviewResultSummary( + completedCount, + committed.writeFailureCount, + ), style: AppTypography.importSummaryMeta.copyWith( color: palette.mutedForeground, ), ), - if (committed != null && preview.count > 0) + if (committed != null && committed.count > 0) Text( l10n.prototypeServersAdded, style: AppTypography.importSummaryMeta.copyWith( @@ -551,7 +572,7 @@ class ServerImportPreviewPage extends StatelessWidget { description: committed == null ? l10n.prototypeDataSource : committed.failedGeoData.contains(source) - ? l10n.prototypeCannotReadContent + ? l10n.resultFailed : l10n.prototypeGeodataAdded, failed: committed?.failedGeoData.contains(source) ?? @@ -581,7 +602,9 @@ class ServerImportPreviewPage extends StatelessWidget { Expanded( child: _stat( context, - l10n.prototypeUsableNodes(preview.count), + l10n.prototypeUsableNodes( + committed?.count ?? preview.count, + ), committed: committed != null, ), ), @@ -750,7 +773,7 @@ class _ImportFeedback extends StatelessWidget { for (final item in state.subscriptionImports) Text( item.result.success - ? '${item.name}: ${l10n.prototypeUsableNodes(item.result.count)}' + ? '${item.name}: ${state.subscriptionImportSummary(l10n, item)}' : '${item.name}: ${ServerImportController.subscriptionError(l10n, item.result.status, error: item.result.error)}', style: AppTypography.importHint.copyWith( color: item.result.success diff --git a/test/pages/advanced/local_api/page_test.dart b/test/pages/advanced/local_api/page_test.dart index f5987d325..06bad7a6b 100644 --- a/test/pages/advanced/local_api/page_test.dart +++ b/test/pages/advanced/local_api/page_test.dart @@ -9,6 +9,8 @@ import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/main/url.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/advanced/local_api/settings.dart'; import 'package:onexray/service/settings/language/locale.dart'; @@ -112,6 +114,30 @@ void main() { ); expect(find.text('http://127.0.0.1:18587'), findsOneWidget); expect(find.text('Not listening'), findsOneWidget); + final enable = find.byType(XrayToggleField); + final l = AppLocalizations.of(tester.element(enable))!; + expect( + tester.widget(enable).description, + l.localApiAppRequired, + ); + final title = find.descendant( + of: enable, + matching: find.text(l.localApiEnable), + ); + expect( + tester.getCenter(find.byKey(const ValueKey('local-api-enabled'))).dx, + lessThan(tester.getCenter(title).dx), + ); + final authorization = find.byWidgetPredicate( + (widget) => widget is SettingSection && widget.title == l.localApiToken, + ); + expect( + find.descendant( + of: authorization, + matching: find.text(l.localApiTokenHint), + ), + findsOneWidget, + ); expect(tester.widget(find.byKey(_copy)).onPressed, isNull); expect(api.saveCalls, 0); expect(tester.takeException(), isNull); diff --git a/test/pages/advanced/xray/geodata/add_page_test.dart b/test/pages/advanced/xray/geodata/add_page_test.dart index d32039323..92d697065 100644 --- a/test/pages/advanced/xray/geodata/add_page_test.dart +++ b/test/pages/advanced/xray/geodata/add_page_test.dart @@ -83,6 +83,8 @@ void main() { controller, returned: (value) => returned = value == true, ); + expect(find.text(l.geodataAddDescription), findsOneWidget); + expect(find.text(l.geodataValidatedBeforeAdding), findsOneWidget); await _fill(tester, 'http://example.test/custom.dat'); await tester.tap(find.byKey(const ValueKey('geodata-source-add'))); await tester.pumpAndSettle(); diff --git a/test/pages/advanced/xray/geodata/rows_test.dart b/test/pages/advanced/xray/geodata/rows_test.dart index f10783518..a23a2701d 100644 --- a/test/pages/advanced/xray/geodata/rows_test.dart +++ b/test/pages/advanced/xray/geodata/rows_test.dart @@ -11,6 +11,7 @@ import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/advanced/xray/geodata/view.dart'; import 'package:onexray/pages/advanced/xray/geodata/controller.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/advanced/xray/geodata/model.dart'; import 'package:onexray/service/advanced/xray/geodata/service.dart'; @@ -227,6 +228,14 @@ void main() { (custom, '3.0 MiB'), ]) { final group = find.byKey(ValueKey(file.row.id)); + final resource = find.descendant( + of: group, + matching: find.byWidgetPredicate( + (widget) => widget is SettingRow && widget.title == file.fileName, + ), + ); + expect(resource, findsOneWidget); + expect(tester.widget(resource).onTap, isNotNull); for (final text in [ file.fileName, file.sourceHost, @@ -236,7 +245,7 @@ void main() { .format(file.row.timestamp.toLocal()), ]) { expect( - find.descendant(of: group, matching: find.text(text)), + find.descendant(of: resource, matching: find.text(text)), findsOneWidget, ); } diff --git a/test/pages/advanced/xray/log_test.dart b/test/pages/advanced/xray/log_test.dart index 0e20bd549..946f6d65c 100644 --- a/test/pages/advanced/xray/log_test.dart +++ b/test/pages/advanced/xray/log_test.dart @@ -6,6 +6,7 @@ import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; +import 'package:onexray/pages/advanced/xray/fields.dart'; import 'package:onexray/pages/advanced/xray/log/settings.dart'; import 'package:onexray/pages/advanced/xray/page.dart'; import 'package:onexray/pages/theme/theme.dart'; @@ -203,6 +204,25 @@ void main() { expect(find.text(l.logsUnavailableSystemExtension), findsOneWidget); expect(viewerCalls, 0); } else { + final preference = find.byWidgetPredicate( + (widget) => + widget is XrayToggleField && + widget.title == l.prototypeRecordXrayLogs, + ); + expect(preference, findsOneWidget); + expect( + tester.widget(preference).description, + l.xrayRecordLogsHint, + ); + final field = find.byKey(const ValueKey('xray-log-level-field')); + final select = find.descendant( + of: field, + matching: find.byType(SettingSelect), + ); + expect( + tester.getSize(select).width, + closeTo(tester.getSize(field).width, 1), + ); expect(detail.dirty, isFalse); await tester.tap(find.widgetWithText(FilledButton, l.prototypeSave)); await tester.pumpAndSettle(); diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index 06ff4a9d0..4ac5601c7 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -920,7 +920,15 @@ void main() { ); final controller = ServerImportController( service: service, - loadSubscription: (_) async => null, + loadSubscription: (_) async => SubscriptionData( + id: 7, + name: 'Provider', + url: 'https://provider.example/list', + timestamp: DateTime(2026), + panel: SubscriptionPanel.remnawave.name, + mode: SubscriptionMode.providerManaged.name, + hwidEnabled: false, + ), ); addTearDown(controller.close); controller.text.text = @@ -955,6 +963,14 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(ServerImportPreviewPage), findsOneWidget); expect(find.text('1 subscriptions imported.'), findsOneWidget); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportPreviewPage)), + )!; + expect( + find.text('Provider: ${l.providerConfigurationCount(2)}'), + findsOneWidget, + ); + expect(find.text('Provider: ${l.prototypeUsableNodes(2)}'), findsNothing); expect(find.text('Pending confirmation'), findsOneWidget); expect(find.text('Imported links'), findsNothing); expect(find.byIcon(LucideIcons.circleCheck), findsNothing); @@ -983,6 +999,142 @@ void main() { }, ); + for (final metadata in ['ordinary', 'missing', 'unavailable']) { + testWidgets( + 'mixed import uses the committed subscription type or neutral feedback ($metadata)', + (tester) async { + ServerImportResult? result; + var writes = 0; + final controller = ServerImportController( + service: _ConfigurationImportService( + subscribe: (_) async => const SubscriptionInsertResult( + status: SubscriptionUpdateResult.success, + subId: 7, + count: 2, + ), + write: (_) async { + writes++; + throw StateError('Unconfirmed local content must not be saved'); + }, + ), + loadSubscription: (_) async { + if (metadata == 'unavailable') { + throw StateError('Subscription metadata is unavailable'); + } + if (metadata == 'missing') { + return null; + } + return SubscriptionData( + id: 7, + name: 'Provider', + url: 'https://provider.example/list', + timestamp: DateTime(2026), + panel: SubscriptionPanel.generic.name, + mode: SubscriptionMode.appManaged.name, + hwidEnabled: false, + ); + }, + ); + addTearDown(controller.close); + controller.text.text = + 'https://provider.example/list#Provider\n${_rawLink()}'; + await tester.pumpWidget(_wizard(controller, (value) => result = value)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Paste route')); + await tester.pumpAndSettle(); + await _tapVisible( + tester, + find.widgetWithText(FilledButton, 'Import links'), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportPreviewPage)), + )!; + expect( + find.text( + 'Provider: ${metadata == "ordinary" ? l.prototypeUsableNodes(2) : l.prototypeSubscriptionSaved}', + ), + findsOneWidget, + ); + expect( + find.text('Provider: ${l.providerConfigurationCount(2)}'), + findsNothing, + ); + if (metadata != 'ordinary') { + expect( + find.text('Provider: ${l.prototypeUsableNodes(2)}'), + findsNothing, + ); + } + await _tapVisible(tester, find.text('Cancel')); + await tester.pumpAndSettle(); + expect(result?.count, 2); + expect(result?.subscriptionCount, 1); + expect(writes, 0); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets( + 'multiple subscription imports report sources without calling configurations nodes', + (tester) async { + ServerImportResult? result; + final controller = ServerImportController( + service: ServerImportService( + subscribe: (link) async => SubscriptionInsertResult( + status: SubscriptionUpdateResult.success, + subId: link.name == 'Provider' ? 7 : 8, + count: 2, + ), + ), + loadSubscription: (id) async => SubscriptionData( + id: id, + name: id == 7 ? 'Provider' : 'Ordinary', + url: 'https://provider.example/$id', + timestamp: DateTime(2026), + panel: SubscriptionPanel.generic.name, + mode: + (id == 7 + ? SubscriptionMode.providerManaged + : SubscriptionMode.appManaged) + .name, + hwidEnabled: false, + ), + ); + addTearDown(controller.close); + controller.text.text = 'https://provider.example/configurations#Provider\nhttps://provider.example/nodes#Ordinary'; + await tester.pumpWidget(_wizard(controller, (value) => result = value)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Paste route')); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportFormPage)), + )!; + await _tapVisible( + tester, + find.widgetWithText(FilledButton, 'Import links'), + ); + await tester.pumpAndSettle(); + expect(result?.count, 4); + expect(result?.subscriptionCount, 2); + expect(find.text(l.prototypeSubscriptionsImported(2)), findsOneWidget); + expect(find.text(l.prototypeUsableNodes(4)), findsNothing); + expect( + controller.state.subscriptionImportModes[7], + SubscriptionMode.providerManaged, + ); + expect( + controller.state.subscriptionImportModes[8], + SubscriptionMode.appManaged, + ); + expect(find.byType(ServerImportFormPage), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + for (final editing in [false, true]) { testWidgets( '${editing ? "editing" : "adding"} keeps duplicate URL feedback on its field until the URL changes', @@ -1307,9 +1459,14 @@ void main() { }, ); - for (final exit in ['done', 'system', 'barrier']) { + for (final (nodeCount, exit) in [ + (1, 'done'), + (1, 'system'), + (1, 'barrier'), + (0, 'done'), + ]) { testWidgets( - 'partial local completion returns committed results without rewriting ($exit)', + '${nodeCount == 0 ? "failed" : "partial"} local completion returns committed results without rewriting ($exit)', (tester) async { _mobileViewport(tester); var writes = 0; @@ -1333,7 +1490,8 @@ void main() { addTearDown(controller.close); final preview = ServerImportPreview( [ - outboundCompanion({'tag': 'local', 'protocol': 'freedom'}), + if (nodeCount > 0) + outboundCompanion({'tag': 'local', 'protocol': 'freedom'}), ], geoData: [ const OneXrayGeoDataLink( @@ -1369,9 +1527,22 @@ void main() { await _tapVisible(tester, find.text('Confirm add')); await tester.pumpAndSettle(); expect(find.text('Pending confirmation'), findsNothing); - expect(find.text('Imported links'), findsOneWidget); - expect(find.byIcon(LucideIcons.circleCheck), findsOneWidget); - expect(find.text('Servers added'), findsOneWidget); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportPreviewPage)), + )!; + expect(find.text(l.importPreviewIncomplete), findsOneWidget); + expect( + find.text(l.importPreviewResultSummary(nodeCount, 1)), + findsOneWidget, + ); + expect(find.text('Imported links'), findsNothing); + expect(find.byIcon(LucideIcons.circleCheck), findsNothing); + expect(find.byIcon(LucideIcons.triangleAlert), findsOneWidget); + expect( + find.text('Servers added'), + nodeCount > 0 ? findsOneWidget : findsNothing, + ); + expect(find.text(l.resultFailed), findsOneWidget); expect(find.text('Data source'), findsOneWidget); expect(find.text('Done'), findsOneWidget); expect(controller.state.committedResult?.writeFailureCount, 1); @@ -1386,9 +1557,9 @@ void main() { } await tester.pumpAndSettle(); expect(find.byType(ServerImportPreviewPage), findsNothing); - expect(result?.count, 1); + expect(result?.count, nodeCount); expect(result?.writeFailureCount, 1); - expect(writes, 1); + expect(writes, nodeCount); expect(downloads, 1); expect( Navigator.of(tester.element(find.text('Open'))).canPop(), From 41c2d601c6abfdd1a1970743b008f2518a921fe6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 11:02:37 +0800 Subject: [PATCH 26/60] fix(ui): keep modal actions visible when content expands --- lib/pages/shared/widgets/adaptive_dialog.dart | 3 - .../shared/widgets/adaptive_dialog_test.dart | 183 ++++++++++++++---- 2 files changed, 142 insertions(+), 44 deletions(-) diff --git a/lib/pages/shared/widgets/adaptive_dialog.dart b/lib/pages/shared/widgets/adaptive_dialog.dart index 852588299..1676f0466 100644 --- a/lib/pages/shared/widgets/adaptive_dialog.dart +++ b/lib/pages/shared/widgets/adaptive_dialog.dart @@ -138,8 +138,6 @@ class AppDialog extends StatelessWidget { Widget build(BuildContext context) { final palette = ColorManager.palette(context); final narrow = MediaQuery.sizeOf(context).width < 640; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final closeSize = AppLayout.touchExtent( 40, platform: Theme.of(context).platform, @@ -208,7 +206,6 @@ class AppDialog extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final pinActions = - mobile && constraints.maxHeight >= 380 && MediaQuery.textScalerOf(context).scale(14) <= 28; return Stack( diff --git a/test/pages/shared/widgets/adaptive_dialog_test.dart b/test/pages/shared/widgets/adaptive_dialog_test.dart index 44b81729b..79374ed43 100644 --- a/test/pages/shared/widgets/adaptive_dialog_test.dart +++ b/test/pages/shared/widgets/adaptive_dialog_test.dart @@ -125,50 +125,151 @@ void main() { }); } - testWidgets('short dialog scrolls large action labels without shrinking', ( - tester, - ) async { - final keyboard = ValueNotifier(0.0); - addTearDown(keyboard.dispose); - var calls = 0; - const label = 'Apply the saved configuration and reconnect'; - await _openDialog( - tester, - (context) => ShadTheme( - data: AppTheme.shad(Brightness.light, mobile: true), - child: AppDialog( - title: 'Changes', - body: const SizedBox(height: 1000), - actions: [ - ShadButton( - onPressed: () { - calls++; - Navigator.pop(context); - }, - child: const Text(label), + for (final size in const [ + Size(390, 844), + Size(768, 1024), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'expanded dialog keeps actions visible and content scrollable at $size', + (tester) async { + final keyboard = ValueNotifier(0.0); + addTearDown(keyboard.dispose); + var expanded = false; + await _openDialog( + tester, + (context) => StatefulBuilder( + builder: (context, setState) => AppDialog( + title: 'Share resource', + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextButton( + onPressed: () => setState(() => expanded = !expanded), + child: const Text('Show QR'), + ), + if (expanded) ...[ + const SizedBox( + height: 200, + child: Center(child: Text('QR content')), + ), + const SizedBox(height: 1000), + const Text('End of content'), + ], + ], + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + FilledButton(onPressed: () {}, child: const Text('Share')), + ], ), - ], - ), - ), - keyboard: keyboard, - size: const Size(390, 360), - safeBottom: 0, - textScale: 2, + ), + keyboard: keyboard, + size: size, + safeBottom: 0, + ); + await tester.tap(find.text('Show QR')); + await tester.pumpAndSettle(); + final scroll = find.descendant( + of: find.byType(AppDialog), + matching: find.byType(SingleChildScrollView), + ); + for (final label in ['Cancel', 'Share']) { + expect(find.text(label).hitTestable(), findsOneWidget); + expect( + find.descendant(of: scroll, matching: find.text(label)), + findsNothing, + ); + expect( + tester.getRect(find.text(label)).bottom, + lessThan(size.height), + ); + } + final before = tester.getRect(find.byType(FilledButton)); + final scrollable = tester.state( + find.descendant(of: scroll, matching: find.byType(Scrollable)), + ); + expect(scrollable.position.maxScrollExtent, greaterThan(0)); + await tester.drag(scroll, const Offset(0, -500)); + await tester.pumpAndSettle(); + expect(scrollable.position.pixels, greaterThan(0)); + expect(tester.getRect(find.byType(FilledButton)), before); + expect(find.text('Cancel').hitTestable(), findsOneWidget); + expect(find.text('Share').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.byType(AppDialog), findsNothing); + }, ); - expect(tester.takeException(), isNull); - await tester.ensureVisible(find.text(label)); - await tester.pumpAndSettle(); - expect(find.text(label).hitTestable(), findsOneWidget); - expect( - DefaultTextStyle.of(tester.element(find.text(label))).style.fontSize, - 14, + } + + for (final (size, textScale) in const [ + (Size(390, 360), 2.0), + (Size(375, 667), 3.2), + (Size(1160, 320), 1.0), + ]) { + testWidgets( + 'short or large-type dialog scrolls actions without shrinking at $size / $textScale', + (tester) async { + final keyboard = ValueNotifier(0.0); + addTearDown(keyboard.dispose); + var calls = 0; + const label = 'Apply the saved configuration and reconnect'; + await _openDialog( + tester, + (context) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: AppDialog( + title: 'Changes', + body: const SizedBox(height: 1000), + actions: [ + ShadButton( + onPressed: () { + calls++; + Navigator.pop(context); + }, + child: const Text(label), + ), + ], + ), + ), + keyboard: keyboard, + size: size, + safeBottom: 0, + textScale: textScale, + ); + expect(tester.takeException(), isNull); + expect( + find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.text(label), + ), + findsOneWidget, + ); + await tester.ensureVisible(find.text(label)); + await tester.pumpAndSettle(); + expect(find.text(label).hitTestable(), findsOneWidget); + expect( + DefaultTextStyle.of(tester.element(find.text(label))).style.fontSize, + 14, + ); + expect( + MediaQuery.textScalerOf(tester.element(find.text(label))).scale(14), + closeTo(14 * textScale, .01), + ); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(calls, 1); + expect(find.byType(AppDialog), findsNothing); + expect(tester.takeException(), isNull); + }, ); - await tester.tap(find.text(label)); - await tester.pumpAndSettle(); - expect(calls, 1); - expect(find.byType(AppDialog), findsNothing); - expect(tester.takeException(), isNull); - }); + } testWidgets('wizard hides the covered card and restores its live draft', ( tester, From 9c24f7ead4aba452de2f2e773613d7a0e6e44ea8 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 11:34:37 +0800 Subject: [PATCH 27/60] fix(ui): size ordering cards to their content --- lib/pages/servers/order/page.dart | 87 +++++++++++++------------ test/pages/servers/order_view_test.dart | 85 +++++++++++++++++++++--- 2 files changed, 120 insertions(+), 52 deletions(-) diff --git a/lib/pages/servers/order/page.dart b/lib/pages/servers/order/page.dart index afa734cda..7e948b630 100644 --- a/lib/pages/servers/order/page.dart +++ b/lib/pages/servers/order/page.dart @@ -38,56 +38,57 @@ class ManualOrderPage extends StatelessWidget { ), body: SafeArea( child: ResponsiveContent( - child: Column( - children: [ + child: CustomScrollView( + key: const PageStorageKey('manual-order'), + slivers: [ if (state.failure != null) - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - state.failure is FormatException - ? l.manualOrderChanged - : appFailureMessage(l, state.failure), - style: TextStyle( - color: ColorManager.palette(context) - .destructive, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + state.failure is FormatException + ? l.manualOrderChanged + : appFailureMessage(l, state.failure), + style: TextStyle( + color: ColorManager.palette(context) + .destructive, + ), ), - ), - Align( - alignment: AlignmentDirectional.centerEnd, - child: TextButton( - onPressed: state.busy - ? null - : controller.reload, - child: Text(l.manualOrderReload), + Align( + alignment: AlignmentDirectional.centerEnd, + child: TextButton( + onPressed: state.busy + ? null + : controller.reload, + child: Text(l.manualOrderReload), + ), ), - ), - ], + ], + ), ), ), - Expanded( - child: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 16 : 28, - 16, - mobile ? 16 : 28, - 28, - ), - child: Material( + SliverPadding( + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, + 28, + ), + sliver: DecoratedSliver( + key: const ValueKey('manual-order-card'), + decoration: BoxDecoration( color: ColorManager.palette(context).card, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.card), - side: BorderSide( - color: ColorManager.palette(context).border, - ), + borderRadius: BorderRadius.circular(AppRadii.card), + border: Border.all( + color: ColorManager.palette(context).border, ), - clipBehavior: Clip.antiAlias, - child: ReorderableListView.builder( - key: const PageStorageKey('manual-order'), - padding: const EdgeInsets.all(18), - buildDefaultDragHandles: false, + ), + sliver: SliverPadding( + padding: const EdgeInsets.all(18), + sliver: SliverReorderableList( onReorderItem: controller.reorder, itemCount: state.items.length, itemBuilder: (context, index) { diff --git a/test/pages/servers/order_view_test.dart b/test/pages/servers/order_view_test.dart index 2844dc180..7443341cf 100644 --- a/test/pages/servers/order_view_test.dart +++ b/test/pages/servers/order_view_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/gestures.dart' show PointerDeviceKind; +import 'package:flutter/rendering.dart' show RenderSliver; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,10 +16,11 @@ void main() { WidgetTester tester, ManualOrderParams params, { double width = 390, + double height = 844, Locale locale = const Locale('en'), }) async { tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(width, 844); + tester.view.physicalSize = Size(width, height); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget( @@ -73,7 +75,7 @@ void main() { width: width, ); final controller = tester - .element(find.byType(ReorderableListView)) + .element(find.byType(SliverReorderableList)) .read(); final handle = find.byIcon(LucideIcons.gripVertical).first; final start = tester.getCenter(handle); @@ -127,6 +129,53 @@ void main() { expect(find.byType(ManualOrderPage), findsNothing); }); + for (final title in ['Group order', 'Arrange server order']) { + testWidgets('desktop $title card hugs its two rows', (tester) async { + const items = [ManualOrderItem(1, 'First'), ManualOrderItem(2, 'Second')]; + final saved = >[]; + await open( + tester, + ManualOrderParams( + title: title, + items: items, + reload: () async => items, + save: (_, ordered) async => saved.add(List.of(ordered)), + ), + width: 1160, + height: 720, + ); + final card = tester.renderObject( + find.byKey(const ValueKey('manual-order-card')), + ); + final rowsHeight = + tester.getRect(find.byType(ListTile).last).bottom - + tester.getRect(find.byType(ListTile).first).top; + expect(card.geometry!.scrollExtent, closeTo(rowsHeight + 36, .1)); + final viewportHeight = tester + .getSize(find.byKey(const PageStorageKey('manual-order'))) + .height; + expect(card.geometry!.scrollExtent, lessThan(viewportHeight / 2)); + expect( + tester.getRect(find.text('Save')).top, + greaterThan(tester.getRect(find.byType(ListTile).last).bottom + 28), + ); + await tester.tap(find.byKey(const ValueKey('move-down-1'))); + await tester.pumpAndSettle(); + expect( + tester.getTopLeft(find.text('Second')).dy, + lessThan(tester.getTopLeft(find.text('First')).dy), + ); + expect(card.geometry!.scrollExtent, closeTo(rowsHeight + 36, .1)); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(saved, [ + [2, 1], + ]); + expect(find.byType(ManualOrderPage), findsNothing); + expect(tester.takeException(), isNull); + }); + } + testWidgets('a long list builds lazily and scrolls while dragging', ( tester, ) async { @@ -145,20 +194,38 @@ void main() { tester.getCenter(find.byIcon(LucideIcons.gripVertical).first), ); await tester.pump(const Duration(milliseconds: 600)); - final listBottom = tester.getRect(find.byType(ReorderableListView)).bottom; + final scrollable = find.descendant( + of: find.byKey(const PageStorageKey('manual-order')), + matching: find.byType(Scrollable), + ); + final listBottom = tester + .getRect(find.byKey(const PageStorageKey('manual-order'))) + .bottom; await gesture.moveTo(Offset(36, listBottom - 10)); for (var i = 0; i < 20; i++) { await tester.pump(const Duration(milliseconds: 100)); } - final scroll = tester.state( - find.descendant( - of: find.byType(ReorderableListView), - matching: find.byType(Scrollable), - ), - ); + final scroll = tester.state(scrollable); expect(scroll.position.pixels, greaterThan(100)); await gesture.up(); await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Node 199'), + 500, + scrollable: scrollable, + ); + await tester.pumpAndSettle(); + expect(find.text('Node 199'), findsOneWidget); + expect(find.byType(ListTile).evaluate().length, lessThan(30)); + final controller = tester + .element(find.byType(SliverReorderableList)) + .read(); + await tester.tap(find.byKey(const ValueKey('move-up-199'))); + await tester.pumpAndSettle(); + expect( + controller.state.items.indexWhere((item) => item.id == 199), + items.length - 2, + ); expect(tester.takeException(), isNull); }); From f3689df39722a074b61a1716d4acc72aebde2380 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 12:05:09 +0800 Subject: [PATCH 28/60] fix(a11y): expose setting rows as independent actions --- lib/pages/advanced/xray/fields.dart | 1 + lib/pages/servers/subscription/form_view.dart | 1 + lib/pages/shared/widgets/setting_row.dart | 38 ++- .../widgets/setting_row_semantics_test.dart | 251 ++++++++++++++++++ 4 files changed, 279 insertions(+), 12 deletions(-) create mode 100644 test/pages/shared/widgets/setting_row_semantics_test.dart diff --git a/lib/pages/advanced/xray/fields.dart b/lib/pages/advanced/xray/fields.dart index e691c48a2..f7c2b4fcb 100644 --- a/lib/pages/advanced/xray/fields.dart +++ b/lib/pages/advanced/xray/fields.dart @@ -59,6 +59,7 @@ class XrayToggleField extends StatelessWidget { ), titleMaxLines: 4, enabled: onChanged != null, + buttonSemantics: false, contentPadding: const EdgeInsets.symmetric(vertical: 10), onTap: onChanged == null ? null : () => onChanged!(!value), leading: Semantics( diff --git a/lib/pages/servers/subscription/form_view.dart b/lib/pages/servers/subscription/form_view.dart index 3cd50b14b..35d5f8052 100644 --- a/lib/pages/servers/subscription/form_view.dart +++ b/lib/pages/servers/subscription/form_view.dart @@ -300,6 +300,7 @@ class SubscriptionFormView extends StatelessWidget { messages: [hwidDescription], ), enabled: onHwidChanged != null, + buttonSemantics: false, onTap: onHwidChanged == null ? null : () => onHwidChanged!(!hwidEnabled), diff --git a/lib/pages/shared/widgets/setting_row.dart b/lib/pages/shared/widgets/setting_row.dart index 1ce53335f..d3af6dca9 100644 --- a/lib/pages/shared/widgets/setting_row.dart +++ b/lib/pages/shared/widgets/setting_row.dart @@ -91,6 +91,9 @@ class SettingRow extends StatelessWidget { final Widget? trailing; final VoidCallback? onTap; final bool enabled; + + /// Toggle rows keep their control's role instead of becoming a button. + final bool buttonSemantics; final bool showChevron; final int titleMaxLines; final int valueMaxLines; @@ -114,6 +117,7 @@ class SettingRow extends StatelessWidget { this.trailing, this.onTap, this.enabled = true, + this.buttonSemantics = true, this.showChevron = false, this.titleMaxLines = 2, this.valueMaxLines = 1, @@ -174,7 +178,12 @@ class SettingRow extends StatelessWidget { ], if (trailing != null) ...[ const SizedBox(width: 12), - IconTheme.merge(data: _iconTheme(context), child: trailing!), + IconTheme.merge( + data: _iconTheme(context), + child: onTap != null && buttonSemantics + ? Semantics(container: true, child: trailing!) + : trailing!, + ), ] else if (showChevron) ...[ const SizedBox(width: 12), IconTheme.merge( @@ -186,17 +195,21 @@ class SettingRow extends StatelessWidget { ), ), ); - if (onTap == null || !enabled) { - return content; - } - return Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadii.control), - child: content, - ), - ); + // InkWell supplies the tap action, but not a button role. Keep descendant + // controls as separate semantic nodes; do not merge or exclude them. + final row = onTap == null || !enabled + ? content + : Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadii.control), + child: content, + ), + ); + return onTap != null && buttonSemantics + ? Semantics(container: true, button: true, enabled: enabled, child: row) + : row; } Widget _title(BuildContext context) { @@ -296,6 +309,7 @@ class SwitchSettingRow extends StatelessWidget { subtitle: subtitle, leading: leading, enabled: onChanged != null, + buttonSemantics: false, onTap: onChanged == null ? null : () => onChanged!(!value), trailing: AppSwitch( value: value, diff --git a/test/pages/shared/widgets/setting_row_semantics_test.dart b/test/pages/shared/widgets/setting_row_semantics_test.dart new file mode 100644 index 000000000..1b29929ed --- /dev/null +++ b/test/pages/shared/widgets/setting_row_semantics_test.dart @@ -0,0 +1,251 @@ +import 'dart:ui' show SemanticsAction, Tristate; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/shared/widgets/app_switch.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +Future _mount(WidgetTester tester, Widget child) => tester.pumpWidget( + MaterialApp( + theme: AppTheme.light, + home: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: Scaffold(body: child), + ), + ), +); + +SemanticsFinder _button(String label) => find.semantics.byPredicate((node) { + final data = node.getSemanticsData(); + return data.flagsCollection.isButton && + (data.label.contains(label) || data.tooltip == label); +}); + +void _testWithSemantics( + String description, + Future Function(WidgetTester) body, +) => testWidgets(description, (tester) async { + final semantics = tester.ensureSemantics(); + try { + await body(tester); + } finally { + // The widget tester verifies handles before test package tearDown runs. + semantics.dispose(); + } +}); + +void main() { + _testWithSemantics( + 'action row exposes its role, context and one tap action', + (tester) async { + var calls = 0; + await _mount( + tester, + SettingRow( + title: 'Routing data', + subtitle: 'Two datasets', + value: 'Current', + showChevron: true, + onTap: () => calls++, + ), + ); + final action = _button('Routing data'); + expect(action, findsOneWidget); + final node = tester.getSemantics(find.byType(SettingRow)); + expect( + node, + isSemantics(isButton: true, isEnabled: true, hasTapAction: true), + ); + expect(node.getSemanticsData().label, contains('Two datasets')); + expect(node.getSemanticsData().label, contains('Current')); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(calls, 1); + await tester.tap(find.text('Routing data')); + await tester.pumpAndSettle(); + expect(calls, 2); + expect(tester.takeException(), isNull); + }, + ); + + _testWithSemantics( + 'disabled actions retain their role and read-only rows do not', + (tester) async { + var calls = 0; + await _mount( + tester, + Column( + children: [ + SettingRow( + title: 'Disabled action', + enabled: false, + onTap: () => calls++, + ), + const SettingRow(title: 'Read only', value: 'Current'), + ], + ), + ); + expect(_button('Disabled action'), findsOneWidget); + final node = tester.getSemantics( + find.widgetWithText(SettingRow, 'Disabled action'), + ); + expect( + node, + isSemantics(isButton: true, isEnabled: false, hasTapAction: false), + ); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.tap(find.text('Disabled action'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(calls, 0); + expect(_button('Read only'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + _testWithSemantics( + 'trailing button keeps its own name and independent action', + (tester) async { + var opens = 0; + var more = 0; + await _mount( + tester, + SettingRow( + title: 'Source details', + onTap: () => opens++, + trailing: IconButton( + tooltip: 'More source actions', + onPressed: () => more++, + icon: const Icon(LucideIcons.ellipsis), + ), + ), + ); + final row = _button('Source details'); + final trailing = _button('More source actions'); + expect(row, findsOneWidget); + expect(trailing, findsOneWidget); + final rowNode = tester.getSemantics(find.byType(SettingRow)); + final trailingNode = tester.getSemantics(find.byType(IconButton)); + expect(rowNode.id, isNot(trailingNode.id)); + expect( + trailingNode, + isSemantics( + isButton: true, + tooltip: 'More source actions', + hasTapAction: true, + ), + ); + rowNode.owner!.performAction(rowNode.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(opens, 1); + expect(more, 0); + trailingNode.owner!.performAction(trailingNode.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(opens, 1); + expect(more, 1); + await tester.tap(find.byTooltip('More source actions')); + await tester.pumpAndSettle(); + expect(opens, 1); + expect(more, 2); + expect(tester.takeException(), isNull); + }, + ); + + _testWithSemantics('switch row keeps one toggle without a button role', ( + tester, + ) async { + var value = false; + final changes = []; + await _mount( + tester, + StatefulBuilder( + builder: (context, setState) => SwitchSettingRow( + title: 'Enable feature', + value: value, + onChanged: (next) => setState(() { + value = next; + changes.add(next); + }), + ), + ), + ); + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != Tristate.none, + ); + expect(toggles, findsOneWidget); + expect( + find.semantics.byPredicate( + (node) => node.getSemanticsData().flagsCollection.isButton, + ), + findsNothing, + ); + final node = tester.getSemantics(find.byType(ShadSwitch)); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(changes, [true]); + await tester.tap(find.text('Enable feature')); + await tester.pumpAndSettle(); + expect(changes, [true, false]); + expect(toggles, findsOneWidget); + expect(tester.takeException(), isNull); + }); + + _testWithSemantics( + 'navigation and trailing switch expose independent semantic actions', + (tester) async { + var opens = 0; + var value = false; + final changes = []; + await _mount( + tester, + StatefulBuilder( + builder: (context, setState) => SettingRow( + title: 'LAN details', + onTap: () => opens++, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppSwitch( + value: value, + onChanged: (next) => setState(() { + value = next; + changes.add(next); + }), + ), + const Icon(LucideIcons.chevronRight), + ], + ), + ), + ), + ); + final navigation = _button('LAN details'); + final toggles = find.semantics.byPredicate( + (node) => + node.getSemanticsData().flagsCollection.isToggled != Tristate.none, + ); + expect(navigation, findsOneWidget); + expect(toggles, findsOneWidget); + final navigationNode = tester.getSemantics(find.byType(SettingRow)); + final toggleNode = tester.getSemantics(find.byType(ShadSwitch)); + expect(navigationNode.id, isNot(toggleNode.id)); + expect( + toggleNode, + isSemantics(isButton: false, isToggled: false, hasTapAction: true), + ); + navigationNode.owner!.performAction( + navigationNode.id, + SemanticsAction.tap, + ); + await tester.pumpAndSettle(); + expect(opens, 1); + expect(changes, isEmpty); + toggleNode.owner!.performAction(toggleNode.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(opens, 1); + expect(changes, [true]); + expect(tester.takeException(), isNull); + }, + ); +} From b4553db6b016dfa49d25807db1da31690c31f81c Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 12:20:02 +0800 Subject: [PATCH 29/60] fix(a11y): label the log follow action as a button --- .../advanced/xray/runtime_code_view.dart | 8 +- .../advanced/xray/runtime_viewer_test.dart | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/lib/pages/advanced/xray/runtime_code_view.dart b/lib/pages/advanced/xray/runtime_code_view.dart index 8c5ceb59a..853b3fbf5 100644 --- a/lib/pages/advanced/xray/runtime_code_view.dart +++ b/lib/pages/advanced/xray/runtime_code_view.dart @@ -42,7 +42,7 @@ class RuntimeCodeScaffold extends StatelessWidget { : AppSpacing.advancedDesktopGutter(width); const top = 20.0; final bottom = mobile ? 18.0 : 24.0; - final pill = Material( + final pillContent = Material( color: palette.surfaceHover, borderRadius: BorderRadius.circular(AppRadii.control), clipBehavior: Clip.antiAlias, @@ -77,6 +77,12 @@ class RuntimeCodeScaffold extends StatelessWidget { ), ), ); + final pill = Semantics( + container: true, + button: onStatusPressed != null, + enabled: onStatusPressed == null ? null : true, + child: pillContent, + ); final export = OutlinedButton( onPressed: onExport, style: OutlinedButton.styleFrom( diff --git a/test/pages/advanced/xray/runtime_viewer_test.dart b/test/pages/advanced/xray/runtime_viewer_test.dart index 2cc63aeaf..4c25b20a3 100644 --- a/test/pages/advanced/xray/runtime_viewer_test.dart +++ b/test/pages/advanced/xray/runtime_viewer_test.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:ui' show SemanticsAction; import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -49,6 +50,82 @@ AppLocalizations _strings(WidgetTester tester) => AppLocalizations.of(tester.element(find.byType(RuntimeCodeScaffold)))!; void main() { + testWidgets('paused runtime status exposes one button and activates once', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + _viewport(tester, size: const Size(1032, 768)); + var resumes = 0; + await tester.pumpWidget( + _app( + RuntimeCodeScaffold( + title: 'Access log', + status: 'Resume following', + exportLabel: 'Export', + note: 'Local log', + code: const SizedBox.shrink(), + onStatusPressed: () => resumes++, + ), + mobile: false, + ), + ); + await tester.pumpAndSettle(); + expect( + find.semantics.byPredicate((node) { + final data = node.getSemanticsData(); + return data.label == 'Resume following' && + data.flagsCollection.isButton; + }), + findsOneWidget, + ); + final node = tester.getSemantics(find.text('Resume following')); + expect( + node, + isSemantics(isButton: true, isEnabled: true, hasTapAction: true), + ); + node.owner!.performAction(node.id, SemanticsAction.tap); + await tester.pumpAndSettle(); + expect(resumes, 1); + await tester.tap(find.text('Resume following')); + await tester.pumpAndSettle(); + expect(resumes, 2); + expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } + }); + + testWidgets('live and read-only runtime status has no button or tap action', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + _viewport(tester); + for (final status in ['Following', 'Read only']) { + await tester.pumpWidget( + _app( + RuntimeCodeScaffold( + title: 'Runtime diagnostics', + status: status, + exportLabel: 'Export', + note: 'Read only content', + code: const SizedBox.shrink(), + ), + ), + ); + await tester.pumpAndSettle(); + final node = tester.getSemantics(find.text(status)); + expect(node, isSemantics(isButton: false, hasTapAction: false)); + await tester.tap(find.text(status), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + } + } finally { + semantics.dispose(); + } + }); + testWidgets( 'desktop runtime viewers use the shared content width and top spacing', (tester) async { From a886429ebe8a1c5817a132797751f533f622dfe7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 12:45:18 +0800 Subject: [PATCH 30/60] fix(share): export legacy SOCKS endpoints without changing stored data --- lib/pages/shared/share/controller.dart | 13 +- .../shared/share/standard_outbound_share.dart | 105 ++++++++++++ test/pages/shared/share/controller_test.dart | 57 +++++- .../share/standard_outbound_share_test.dart | 162 ++++++++++++++++++ 4 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 lib/service/shared/share/standard_outbound_share.dart create mode 100644 test/service/shared/share/standard_outbound_share_test.dart diff --git a/lib/pages/shared/share/controller.dart b/lib/pages/shared/share/controller.dart index 15ac0d154..d451db538 100644 --- a/lib/pages/shared/share/controller.dart +++ b/lib/pages/shared/share/controller.dart @@ -7,7 +7,6 @@ import 'package:image/image.dart' as img; import 'package:onexray/core/db/database/constants.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/core/db/database/enum.dart'; -import 'package:onexray/core/pigeon/host_api.dart'; import 'package:onexray/core/tools/file.dart'; import 'package:onexray/core/tools/logger.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; @@ -16,6 +15,7 @@ import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/service/settings/language/service.dart'; import 'package:onexray/service/shared/share/app_link_share_service.dart'; +import 'package:onexray/service/shared/share/standard_outbound_share.dart'; import 'package:onexray/service/servers/outbound/map.dart'; import 'package:onexray/service/servers/outbound/state_db.dart'; import 'package:zxing2/qrcode.dart'; @@ -88,8 +88,9 @@ class ShareController extends PageCubit { Future Function(Map)? originalLinkEncoder, }) : _database = database ?? AppDatabase(), _qrEncoder = qrEncoder ?? _encodeQr, - _originalLinkEncoder = - originalLinkEncoder ?? AppHostApi().convertXrayJsonToShareLinks, + _standardShare = StandardOutboundShareService( + encoder: originalLinkEncoder, + ), super(const SharePageState()) { _initialize(); } @@ -97,7 +98,7 @@ class ShareController extends PageCubit { final SharePageParams params; final AppDatabase _database; final Future Function(String) _qrEncoder; - final Future Function(Map) _originalLinkEncoder; + final StandardOutboundShareService _standardShare; late final _appLinkShareService = OneXrayAppLinkShareService( geoDataLookup: _database.geoDataDao.searchRowByName, ); @@ -145,9 +146,7 @@ class ShareController extends PageCubit { return; } final outbound = readOutboundFromDbData(config); - final url = await _originalLinkEncoder({ - 'outbounds': [outbound], - }); + final url = await _standardShare.encode(outbound); if (url.trim().isEmpty) { _finishLinkError(); return; diff --git a/lib/service/shared/share/standard_outbound_share.dart b/lib/service/shared/share/standard_outbound_share.dart new file mode 100644 index 000000000..1b88b1270 --- /dev/null +++ b/lib/service/shared/share/standard_outbound_share.dart @@ -0,0 +1,105 @@ +import 'package:onexray/core/pigeon/host_api.dart'; +import 'package:onexray/core/tools/json.dart'; + +/// The standard-link projection must never mutate the stored outbound graph. +final class StandardOutboundShareService { + StandardOutboundShareService({ + Future Function(Map)? encoder, + }) : _encoder = encoder ?? AppHostApi().convertXrayJsonToShareLinks; + + final Future Function(Map) _encoder; + + Future encode(Map outbound) async { + final copy = JsonTool.copyMap(outbound); + final socks = copy['protocol'] == 'socks'; + if (socks) { + _prepareSocks(copy); + } + final result = await _encoder({ + 'outbounds': [copy], + }); + if (socks) { + _validateSocksLink(result); + } + return result; + } + + static void _prepareSocks(Map outbound) { + final settings = outbound['settings']; + if (settings is! Map) { + throw _unsupportedSocks; + } + // Xray gives a non-null flat address precedence over legacy servers. + if (settings['address'] == null) { + final servers = settings['servers']; + if (servers is! List || servers.length != 1) { + throw _unsupportedSocks; + } + final server = servers.single; + if (server is! Map) { + throw _unsupportedSocks; + } + final users = server['users']; + if (users != null && (users is! List || users.length > 1)) { + throw _unsupportedSocks; + } + settings + ..remove('servers') + ..remove('user') + ..remove('pass') + ..['address'] = server['address'] + ..['port'] = server['port']; + if (users is List && users.isNotEmpty) { + final user = users.single; + if (user is! Map) { + throw _unsupportedSocks; + } + settings['user'] = user['user'] ?? ''; + settings['pass'] = user['pass'] ?? ''; + } + } + final address = settings['address']; + final port = settings['port']; + if (address is! String || + address.isEmpty || + _invalidHost.hasMatch(address) || + port is! int || + port < 1 || + port > 65535 || + (settings['user'] != null && settings['user'] is! String) || + (settings['pass'] != null && settings['pass'] is! String)) { + throw _unsupportedSocks; + } + } + + static void _validateSocksLink(String text) { + try { + final uri = Uri.parse(text); + final host = Uri.decodeComponent(uri.host); + if (text.trim() != text || + text.contains(RegExp(r'[\r\n]')) || + uri.scheme != 'socks' || + !uri.hasAuthority || + host.isEmpty || + _invalidHost.hasMatch(host) || + !uri.hasPort || + uri.port < 1 || + uri.port > 65535) { + throw _invalidSocksLink; + } + } on FormatException { + // Do not include generated credentials or a server address in diagnostics. + throw _invalidSocksLink; + } + } + + static final _invalidHost = RegExp(r'[\s<>\x00-\x1f\x7f]'); + static const _unsupportedSocks = FormatException( + 'This SOCKS configuration cannot be represented as a standard link. ' + 'Use the OneXray link to preserve the configuration.', + ); + static const _invalidSocksLink = FormatException( + 'The generated SOCKS link has no valid server address or port. ' + 'Use the OneXray link instead.', + ); +} diff --git a/test/pages/shared/share/controller_test.dart b/test/pages/shared/share/controller_test.dart index 2c425718c..a69badc2a 100644 --- a/test/pages/shared/share/controller_test.dart +++ b/test/pages/shared/share/controller_test.dart @@ -215,6 +215,7 @@ void main() { final id = await db.coreConfigDao.insertAssetRow( outboundCompanion(outbound), ); + final stored = (await db.coreConfigDao.searchRow(id))!; final converted = >[]; controller = ShareController( SharePageParams(ShareType.config, id), @@ -233,7 +234,12 @@ void main() { ); expect(converted, [ { - 'outbounds': [outbound], + 'outbounds': [ + { + ...outbound, + 'settings': {'address': 'proxy.example.test', 'port': 1080}, + }, + ], }, ]); controller.selectFormat(ShareLinkFormat.onexray); @@ -250,5 +256,54 @@ void main() { 'socks://proxy.example.test:1080#Ordinary', ); expect(controller.state.linkError, isEmpty); + expect((await db.coreConfigDao.searchRow(id))!, stored); + }); + + test('invalid standard SOCKS output stays unavailable while the original App Link remains usable', () async { + final outbound = { + 'tag': 'Legacy SOCKS', + 'protocol': 'socks', + 'settings': { + 'servers': [ + {'address': '127.0.0.1', 'port': 18080}, + ], + }, + }; + final id = await db.coreConfigDao.insertAssetRow( + outboundCompanion(outbound), + ); + final stored = (await db.coreConfigDao.searchRow(id))!; + final qrLinks = []; + controller = ShareController( + SharePageParams(ShareType.config, id), + database: db, + originalLinkEncoder: (_) async => 'socks://Og==@:0#Legacy', + qrEncoder: (text) async { + qrLinks.add(text); + return Uint8List.fromList([1]); + }, + ); + await controller.stream + .firstWhere((state) => !state.loading) + .timeout(const Duration(seconds: 5)); + expect(controller.state.originalLink, isEmpty); + expect(controller.state.selectedLink, isEmpty); + expect(controller.state.linkError, isNotEmpty); + controller.toggleQr(); + expect(qrLinks, isEmpty); + controller.selectFormat(ShareLinkFormat.onexray); + final link = + OneXrayAppLinkParser.parse(Uri.parse(controller.state.selectedLink))! + as OneXrayConfigLink; + expect(jsonDecode(link.xrayJson), { + 'outbounds': [outbound], + }); + if (controller.state.qrCode == null) { + await controller.stream + .firstWhere((state) => state.qrCode != null) + .timeout(const Duration(seconds: 5)); + } + expect(qrLinks, [controller.state.appLink]); + expect((await db.coreConfigDao.searchRow(id))!, stored); }); } diff --git a/test/service/shared/share/standard_outbound_share_test.dart b/test/service/shared/share/standard_outbound_share_test.dart new file mode 100644 index 000000000..04ce88a16 --- /dev/null +++ b/test/service/shared/share/standard_outbound_share_test.dart @@ -0,0 +1,162 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/service/shared/share/standard_outbound_share.dart'; + +void main() { + for (final authenticated in [false, true]) { + test( + 'legacy SOCKS ${authenticated ? "auth" : "anonymous"} uses a share-only flat copy', + () async { + final source = { + 'tag': 'Legacy SOCKS', + 'protocol': 'socks', + 'settings': { + 'servers': [ + { + 'address': '127.0.0.1', + 'port': 18080, + if (authenticated) + 'users': [ + {'user': 'qa-user', 'pass': 'qa-password'}, + ], + }, + ], + }, + 'streamSettings': { + 'network': 'tcp', + 'security': 'tls', + 'tlsSettings': {'serverName': 'proxy.example.test'}, + }, + }; + final before = jsonEncode(source); + final service = StandardOutboundShareService( + encoder: (json) async { + final outbound = (json['outbounds'] as List).single as Map; + expect(outbound['tag'], 'Legacy SOCKS'); + expect(outbound['streamSettings'], source['streamSettings']); + expect(outbound['settings'], { + 'address': '127.0.0.1', + 'port': 18080, + if (authenticated) ...{'user': 'qa-user', 'pass': 'qa-password'}, + }); + // A native encoder must never receive a reference into the source. + (outbound['streamSettings'] as Map)['network'] = 'ws'; + return authenticated + ? 'socks://cWEtdXNlcjpxYS1wYXNzd29yZA==@127.0.0.1:18080#Legacy' + : 'socks://Og==@127.0.0.1:18080#Legacy'; + }, + ); + final result = await service.encode(source); + expect(Uri.parse(result).host, '127.0.0.1'); + expect(Uri.parse(result).port, 18080); + expect(jsonEncode(source), before); + }, + ); + } + + test( + 'flat SOCKS fields retain Core precedence over legacy servers', + () async { + final source = { + 'protocol': 'socks', + 'settings': { + 'address': 'flat.example.test', + 'port': 1080, + 'user': 'flat-user', + 'pass': 'flat-password', + 'servers': [ + {'address': 'ignored-a.example.test', 'port': 1}, + {'address': 'ignored-b.example.test', 'port': 2}, + ], + }, + }; + final before = jsonEncode(source); + final service = StandardOutboundShareService( + encoder: (json) async { + expect((json['outbounds'] as List).single, source); + return 'socks://flat.example.test:1080'; + }, + ); + expect(await service.encode(source), 'socks://flat.example.test:1080'); + expect(jsonEncode(source), before); + }, + ); + + for (final settings in [ + {'servers': []}, + { + 'servers': [ + {'address': 'a.example.test', 'port': 1080}, + {'address': 'b.example.test', 'port': 1080}, + ], + }, + { + 'servers': [ + { + 'address': 'proxy.example.test', + 'port': 1080, + 'users': [ + {'user': 'first', 'pass': 'first-password'}, + {'user': 'second', 'pass': 'second-password'}, + ], + }, + ], + }, + ]) { + test( + 'ambiguous SOCKS endpoint or credentials never reaches the encoder: $settings', + () async { + final service = StandardOutboundShareService( + encoder: (_) async => fail('Ambiguous standard sharing must stop'), + ); + await expectLater( + service.encode({'protocol': 'socks', 'settings': settings}), + throwsFormatException, + ); + }, + ); + } + + for (final output in [ + 'socks://Og==@:0#Legacy', + 'socks://Og==@%3Cnil%3E:1080', + 'socks://Og==@:1080', + 'socks://127.0.0.1:0', + 'socks://127.0.0.1:65536', + 'socks://127.0.0.1', + 'http://127.0.0.1:1080', + 'socks://127.0.0.1:1080\nsocks://127.0.0.2:1080', + '', + ]) { + test('invalid SOCKS encoder output cannot be shared: $output', () async { + final service = StandardOutboundShareService( + encoder: (_) async => output, + ); + await expectLater( + service.encode({ + 'protocol': 'socks', + 'settings': {'address': '127.0.0.1', 'port': 1080}, + }), + throwsFormatException, + ); + }); + } + + test( + 'SOCKS envelope guard does not reinterpret other standard formats', + () async { + const output = 'vmess://opaque-base64-payload'; + final source = {'protocol': 'vmess', 'settings': {}}; + final service = StandardOutboundShareService( + encoder: (json) async { + expect(json, { + 'outbounds': [source], + }); + return output; + }, + ); + expect(await service.encode(source), output); + }, + ); +} From f772c7db75aa5dc3334b3e58ecfa25545b89badd Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 12:46:43 +0800 Subject: [PATCH 31/60] docs(share): describe the SOCKS standard-link projection --- docs/subscriptions-and-sharing.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/subscriptions-and-sharing.md b/docs/subscriptions-and-sharing.md index 0d215c793..778318154 100644 --- a/docs/subscriptions-and-sharing.md +++ b/docs/subscriptions-and-sharing.md @@ -127,7 +127,10 @@ Age App Link 只携带算法,不带公私钥;接收端生成新密钥对, 不能把它称为完整配置或任意 outbound 无损导入。手写节点 JSON 不经普通表单重建; 完整订阅 JSON 使用上述独立路径,标准格式的接受/拒绝仍由 libXray 决定。 - 名称使用 `tag`;仅键缺失时兼容旧 `name`,无名以协议补齐。`sendThrough` 保留绑定语义,不参与命名。 - KCP seed/header、废弃 `allowInsecure` 不在标准分享内,App 不另校验协议算法或补写规范字段。 +- SOCKS 原始链接在独立导出副本上兼容旧 `settings.servers`:仅转换单服务器、至多一个用户,已有非 null 的 flat 地址按 Core 的优先级处理。 + 生成结果须有有效主机和显式有效端口;歧义配置或无效结果不提供原始链接分享和二维码,仍可使用完整 OneXray 链接。 + 此投影不修改数据库、节点原文、运行配置、备份或 OneXray 链接。 +- KCP seed/header、废弃 `allowInsecure` 不在标准分享内,App 不另校验协议算法或补写规范字段。 - 文件直接使用系统选择器,扫码仅 iOS/Android。失败保留输入,成功 toast 后提交测速,不等待测速完成。 ## App Link From e65249ee1a1091dd4a09962ead2624713817b265 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 15:47:55 +0800 Subject: [PATCH 32/60] docs(license): limit provider exception to dedicated VPN clients --- LICENSE-EXCEPTION | 22 +++++++++++++++++----- README.md | 6 +++++- readme/README.ru.md | 6 +++++- readme/README.zh_CN.md | 6 +++++- 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/LICENSE-EXCEPTION b/LICENSE-EXCEPTION index d00db33e5..dfc286bfb 100644 --- a/LICENSE-EXCEPTION +++ b/LICENSE-EXCEPTION @@ -1,5 +1,5 @@ OneXray VPN Provider Exception -Version 1.0 +Version 1.1 1. Scope @@ -13,11 +13,21 @@ Exception. This Exception applies only to those rights. 2. Eligibility and permission -You may rely on this Exception only if both of the following apply: +You may rely on this Exception only if all of the following apply: a) You are a VPN service provider. - b) You use the Covered Code to create a customized client for your own - VPN service. + b) You use the Covered Code to create a dedicated, customized client + that connects exclusively to your own VPN service. + c) The customized client does not include general-purpose + custom-configuration or subscription features, including the + ability to import or use arbitrary third-party VPN or proxy + configurations or subscription URLs. + +Configurations and subscriptions that you supply solely for access to +your own VPN service are permitted. Branding the client for your service, +distributing it only to your subscribers, or making your service the +default does not satisfy these conditions if the client can also connect +to third-party services. For that purpose, you may use, copy, modify, build and distribute the Covered Code and the resulting customized client under terms of your @@ -28,7 +38,9 @@ under GPLv3, disclose Corresponding Source or supply Installation Information. This permission does not extend to closed-source clients for VPN services -provided by others. It does not restrict anyone's rights under GPLv3. +provided by others, including general-purpose clients that support both +your own service and third-party services. It does not restrict anyone's +rights under GPLv3. Uses outside this Exception remain subject to GPLv3 or another permission from the applicable copyright holders. diff --git a/README.md b/README.md index e46363715..f82ed585f 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,11 @@ Code, translations and documentation contributions are welcome. Start with the [ OneXray is licensed under the [GNU General Public License v3.0](./LICENSE), with the [OneXray VPN Provider Exception](./LICENSE-EXCEPTION). -VPN service providers may build and distribute customized, closed-source clients for their own VPN services without complying with the repository's GPL requirements for the code covered by the exception. Other uses remain subject to the GPL or another applicable permission. +VPN service providers may build and distribute dedicated, customized closed-source clients that connect exclusively to their own VPN services without complying with the repository's GPL requirements for the code covered by the exception. + +To qualify, the client must not include general-purpose custom-configuration or subscription features, including the ability to import or use arbitrary third-party VPN or proxy configurations or subscription URLs. Configurations and subscriptions supplied by the provider solely for access to its own VPN service are permitted. Branding the client for the provider, distributing it only to the provider's subscribers, or making the provider's service the default is not sufficient if the client can also connect to third-party services. + +These conditions apply to eligibility for the exception. Other uses remain subject to the GPL or another applicable permission. Yuan Dev Team guarantees that it will not introduce direct third-party dependencies that require a qualifying customized client as a whole to be GPL-licensed or to disclose its source code under the GPL. Third-party licenses and their component-specific obligations still apply; the exception only covers rights Yuan Dev Team owns or is authorized to license. diff --git a/readme/README.ru.md b/readme/README.ru.md index 76edee3c9..8bd33b060 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -106,7 +106,11 @@ OneXray вам полезен? Поставьте этому репозитор OneXray распространяется под [GNU General Public License v3.0](../LICENSE) с [исключением OneXray для VPN-провайдеров](../LICENSE-EXCEPTION). -Провайдеры VPN могут создавать и распространять специализированные клиенты с закрытым исходным кодом для собственных VPN-сервисов, не выполняя требования GPL этого репозитория в отношении кода, на который распространяется исключение. Для других способов использования действуют GPL или иное применимое разрешение. +Провайдеры VPN могут создавать и распространять специализированные клиенты с закрытым исходным кодом, подключающиеся исключительно к их собственным VPN-сервисам, не выполняя требования GPL этого репозитория в отношении кода, на который распространяется исключение. + +Для применения исключения клиент не должен включать универсальные функции пользовательских конфигураций или подписок, в том числе возможность импортировать или использовать произвольные конфигурации сторонних VPN- или прокси-сервисов либо адреса их подписок. Конфигурации и подписки, предоставляемые провайдером исключительно для доступа к его собственному VPN-сервису, допускаются. Если клиент также позволяет подключаться к сторонним сервисам, одного собственного бренда, распространения только среди подписчиков провайдера или выбора его сервиса по умолчанию недостаточно. + +Эти условия определяют применимость исключения. Для других способов использования действуют GPL или иное применимое разрешение. Yuan Dev Team гарантирует, что не будет добавлять прямые сторонние зависимости, требующие лицензировать соответствующий условиям исключения клиент в целом под GPL или раскрывать его исходный код по GPL. Лицензии сторонних компонентов и относящиеся к ним обязательства сохраняются; исключение охватывает только права, принадлежащие Yuan Dev Team или предоставленные ей для такого лицензирования. diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 51d142f8a..9da8512c0 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -106,7 +106,11 @@ macOS System Extension 版本可通过 [Homebrew](https://formulae.brew.sh/cask/ OneXray 采用 [GNU General Public License v3.0](../LICENSE),并附 [OneXray VPN 服务商豁免条款](../LICENSE-EXCEPTION)。 -VPN 服务提供商为自己的 VPN 服务打造并分发自定义客户端时,可以保持闭源,无需对豁免所覆盖的代码遵循本仓库的 GPL 要求。其他用途仍须遵循 GPL 或另行适用的授权。 +VPN 服务提供商为自己的 VPN 服务打造并分发专用定制客户端,且该客户端只能接入该服务商自有的 VPN 服务时,可以保持闭源,无需对豁免所覆盖的代码遵循本仓库的 GPL 要求。 + +符合豁免条件的客户端不得包含通用自定义配置或通用订阅功能,包括导入或使用任意第三方 VPN、代理配置或订阅地址的能力。服务商提供的、仅用于接入其自有 VPN 服务的配置和订阅可以保留。若客户端仍可接入第三方服务,仅更换品牌、仅向自家订阅用户分发,或将自家服务设为默认,并不足以获得豁免。 + +以上条件仅用于确定是否适用豁免。其他用途仍须遵循 GPL 或另行适用的授权。 Yuan Dev Team 保证不引入要求符合豁免条件的自定义客户端整体按 GPL 授权或按 GPL 公开源码的直接第三方依赖。第三方许可证及其针对相应组件的义务仍然适用;豁免仅覆盖 Yuan Dev Team 拥有或有权授权的权利。 From 3f24b07fa424b987c178ea557f7f3e1b85f853e7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 16:25:16 +0800 Subject: [PATCH 33/60] refactor(ui): use standard shared app bars --- lib/pages/connect/routing/page.dart | 11 +- lib/pages/shared/widgets/page_app_bar.dart | 418 ++------------------- lib/pages/theme/theme.dart | 26 +- test/pages/main/page_app_bar_test.dart | 319 +++++++++------- test/pages/theme/theme_test.dart | 70 ++-- 5 files changed, 260 insertions(+), 584 deletions(-) diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart index 95c98d999..1916bc284 100644 --- a/lib/pages/connect/routing/page.dart +++ b/lib/pages/connect/routing/page.dart @@ -48,11 +48,12 @@ class _RoutingPageState extends State { context, title: Text(l.navigationRouting), description: l.routingPageDescription, - headingAction: ShadButton( - enabled: !state.busy && state.profiles.length < 3, - onPressed: () => controller.edit(context), - leading: const Icon(LucideIcons.plus, size: 18), - child: Text(l.prototypeNewCustomRoute), + headingAction: IconButton( + tooltip: l.prototypeNewCustomRoute, + onPressed: !state.busy && state.profiles.length < 3 + ? () => controller.edit(context) + : null, + icon: const Icon(LucideIcons.plus), ), ), body: SafeArea( diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index 2e71c124a..06955d0e7 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -1,24 +1,13 @@ -import 'dart:math' as math; - -import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:macos_window_utils/widgets/macos_toolbar_passthrough.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/main/desktop_window.dart'; import 'package:go_router/go_router.dart'; import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/theme/color.dart'; -import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/shared/alert.dart'; -import 'package:onexray/service/shared/failure.dart'; -import 'package:onexray/service/shared/event_bus/enum.dart'; -import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -/// Keeps the themed AppBar while forwarding clicks on controls that overlap -/// the native macOS titlebar. Empty titlebar space keeps native window gestures. +/// Uses the standard themed AppBar and preserves native macOS hit forwarding. class PageAppBar extends StatelessWidget implements PreferredSizeWidget { const PageAppBar({ super.key, @@ -28,8 +17,7 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { this.bottom, }) : description = null, headingAction = null, - inlineHeadingAction = false, - _headingHeight = null; + inlineHeadingAction = false; const PageAppBar._({ super.key, @@ -40,9 +28,10 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { this.leading, this.actions, this.bottom, - required this._headingHeight, }); + /// Retains the page API while placing its title and actions in one AppBar. + /// Page introductions no longer add a separate heading below the toolbar. factory PageAppBar.forPage( BuildContext context, { Key? key, @@ -53,110 +42,31 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { Widget? leading, List? actions, PreferredSizeWidget? bottom, - }) { - final viewport = MediaQuery.sizeOf(context); - final mobile = viewport.width <= AppLayout.mobileBreakpoint; - final hasShell = GoRouter.maybeOf(context) != null; - final sidebar = !mobile && hasShell - ? (viewport.width <= AppLayout.compactDesktopBreakpoint - ? AppLayout.compactSidebarWidth - : AppLayout.desktopSidebarWidth) + - 8 - : 0.0; - final width = (viewport.width - sidebar - (mobile ? 32 : 56)).clamp( - 1.0, - double.infinity, - ); - final effectiveDescription = - description ?? _defaultDescription(context, title); - final style = mobile - ? AppTypography.mobilePageTitle - : AppTypography.pageTitle; - double textHeight(String text, TextStyle textStyle) { - final painter = - TextPainter( - text: TextSpan( - text: text, - style: textStyle.copyWith(fontFamily: AppFontFamily.sans), - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - )..layout( - maxWidth: - headingAction != null && (width >= 540 || inlineHeadingAction) - ? (width - 20) / 2 - : width, - ); - final height = painter.height; - painter.dispose(); - return height; - } - - final titleHeight = title is Text && title.data != null - ? textHeight(title.data!, style.merge(title.style)) - : MediaQuery.textScalerOf(context).scale(style.fontSize!) * 1.45; - final blockHeight = - titleHeight + - (effectiveDescription == null || effectiveDescription.isEmpty - ? 0 - : 6 + textHeight(effectiveDescription, AppTypography.rowValue)); - final scaledExtra = - math.max(0, MediaQuery.textScalerOf(context).scale(14) - 14) * 1.45; - final actionHeight = AppLayout.touchExtent( - (mobile ? 48 : 42) + scaledExtra, - platform: Theme.of(context).platform, - ); - final naturalHeadingHeight = - (mobile ? 20.0 : 24.0) + - 8 + - (headingAction == null - ? blockHeight - : width < 540 && !inlineHeadingAction - ? blockHeight + 16 + actionHeight - : math.max(blockHeight, actionHeight)); - final media = MediaQuery.of(context); - final headingBudget = math.max( - 44.0, - viewport.height - - media.padding.vertical - - media.viewInsets.bottom - - 56 - - 180 - - (bottom?.preferredSize.height ?? 0), - ); - final headingHeight = math.min(naturalHeadingHeight, headingBudget); - return PageAppBar._( - key: key, - title: title, - description: effectiveDescription, - headingAction: headingAction, - inlineHeadingAction: inlineHeadingAction, - leading: leading, - actions: actions, - bottom: bottom, - headingHeight: headingHeight, - ); - } + }) => PageAppBar._( + key: key, + title: title, + description: description, + headingAction: headingAction, + inlineHeadingAction: inlineHeadingAction, + leading: leading, + actions: actions, + bottom: bottom, + ); final Widget title; + + /// Kept for source compatibility; repeated page introductions are not drawn. final String? description; final Widget? headingAction; - /// Keeps a compact page action beside a wrapping title on narrow screens. - /// The action is constrained to half the heading so identity remains visible. + /// Kept for source compatibility; all actions now share the standard toolbar. final bool inlineHeadingAction; - final double? _headingHeight; final Widget? leading; final List? actions; final PreferredSizeWidget? bottom; - // Retain AppBar's theme-aware preferred size, including its optional tabs. @override - Size get preferredSize => _headingHeight == null - ? AppBar(bottom: bottom).preferredSize - : Size.fromHeight( - 56 + _headingHeight + (bottom?.preferredSize.height ?? 0), - ); + Size get preferredSize => AppBar(bottom: bottom).preferredSize; @override Widget build(BuildContext context) { @@ -189,9 +99,8 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint) { final match = router.routerDelegate.currentConfiguration.lastOrNull; final settings = route.settings; - // Imperative routes (including Page-backed routes not owned by GoRouter) - // have no GoRouterState association. Its ancestor lookup can loop on a - // nested Navigator, so read the active match without walking ancestors. + // Imperative routes have no GoRouterState association. Read the active + // match without walking ancestors of a nested Navigator. final path = match?.matchedLocation; if (settings is Page && match != null && @@ -208,275 +117,28 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { ); } } - if (_headingHeight != null) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final palette = ColorManager.palette(context); - final routeLeading = - effectiveLeading ?? - ((Scaffold.maybeOf(context)?.hasDrawer ?? false) - ? const DrawerButton() - : (route?.impliesAppBarDismissal ?? false) - ? route!.fullscreenDialog - ? const CloseButton() - : const BackButton() - : null); - final bus = context.read(); - final toolbarActions = [ - ...effectiveActions, - if (bus != null) _PageThemeToggle(bus: bus), - ?mobileSettingsAction, - ]; - Widget passThrough(Widget child) => - forwardClicks ? _AnimatedToolbarPassthrough(child: child) : child; - return AnnotatedRegion( - value: - Theme.of(context).appBarTheme.systemOverlayStyle ?? - SystemUiOverlayStyle.dark, - child: Material( - color: palette.header, - child: SafeArea( - bottom: false, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - height: 56, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: mobile ? 16 : 24), - child: Row( - children: [ - if (routeLeading != null) passThrough(routeLeading), - Expanded(child: _toolbarIdentity(context, mobile)), - for (final action in toolbarActions) - passThrough(action), - ], - ), - ), - ), - SizedBox( - height: _headingHeight, - child: SingleChildScrollView( - key: const ValueKey('page-heading'), - primary: false, - padding: EdgeInsets.fromLTRB( - mobile ? 16 : 28, - mobile ? 20 : 24, - mobile ? 16 : 28, - 8, - ), - child: _pageHeading(context, mobile), - ), - ), - ?bottom, - ], - ), - ), - ), - ); - } - return AppBar( - title: title, - leading: forwardClicks && effectiveLeading != null - ? _AnimatedToolbarPassthrough(child: effectiveLeading) - : effectiveLeading, - actions: forwardClicks - ? [ - ...effectiveActions, - ?mobileSettingsAction, - ].map((child) => _AnimatedToolbarPassthrough(child: child)).toList() - : [...effectiveActions, ?mobileSettingsAction], - bottom: bottom, - ); - } - - Widget _pageHeading(BuildContext context, bool mobile) { - final palette = ColorManager.palette(context); - final identity = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Semantics( - header: true, - child: DefaultTextStyle( - style: - (mobile - ? AppTypography.mobilePageTitle - : AppTypography.pageTitle) - .copyWith( - color: palette.foreground, - fontFamily: AppFontFamily.sans, - ), - child: title, - ), - ), - if (description != null && description!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - description!, - style: AppTypography.rowValue.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ], - ); - if (headingAction == null) return identity; - return LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 540 && !inlineHeadingAction) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [identity, const SizedBox(height: 16), headingAction!], - ); - } - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded(child: identity), - const SizedBox(width: 20), - ConstrainedBox( - constraints: BoxConstraints( - maxWidth: (constraints.maxWidth - 20) / 2, - ), - child: headingAction!, - ), - ], - ); - }, - ); - } - - static String? _defaultDescription(BuildContext context, Widget title) { - if (title is! Text) return null; - final l = AppLocalizations.of(context); - if (l == null) return null; - return switch (title.data) { - final value when value == l.prototypeSettings => - l.settingsOverviewDescription, - final value when value == l.prototypeVpnTunnel => l.pageTunnelDescription, - final value when value == l.prototypeXrayRuntimeDiagnostics => - l.pageXrayDescription, - final value when value == l.navigationRouting => l.pageRoutingDescription, - _ => null, - }; - } - - Widget _toolbarIdentity(BuildContext context, bool mobile) { - final palette = ColorManager.palette(context); - if (mobile) { - return Row( - children: [ - Image.asset( - 'assets/logo.png', - width: 26, - height: 26, + Widget passThrough(Widget child) => + forwardClicks ? _AnimatedToolbarPassthrough(child: child) : child; + final pageTitle = title; + final toolbarTitle = + pageTitle is Text && + pageTitle.data != null && + pageTitle.data!.length > 40 + ? Tooltip( + message: pageTitle.data!, excludeFromSemantics: true, - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'OneXray', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.headerBrand.copyWith( - color: palette.foreground, - ), - ), - ), - ], - ); - } - final l = AppLocalizations.of(context)!; - final path = GoRouter.maybeOf(context) - ?.routerDelegate - .currentConfiguration - .uri - .path; - final destination = path == null - ? null - : AppPrimaryDestination.fromPath(path); - final (icon, label) = switch (destination) { - AppPrimaryDestination.connect => (LucideIcons.power, l.prototypeConnect), - AppPrimaryDestination.servers => (LucideIcons.server, l.prototypeServers), - AppPrimaryDestination.routing => (LucideIcons.route, l.navigationRouting), - AppPrimaryDestination.tunnel => ( - LucideIcons.shieldCheck, - l.prototypeVpnTunnel, - ), - AppPrimaryDestination.xray => (LucideIcons.activity, 'Xray'), - AppPrimaryDestination.settings => ( - LucideIcons.settings2, - l.prototypeSettings, - ), - null => (LucideIcons.panelTop, 'OneXray'), - }; - return Row( - children: [ - Icon(icon, size: 16, color: palette.mutedForeground), - const SizedBox(width: 10), - Text( - l.navigationWorkspace, - style: AppTypography.breadcrumb.copyWith( - color: palette.mutedForeground, - ), - ), - const SizedBox(width: 12), - Text( - '/', - style: AppTypography.breadcrumb.copyWith( - color: palette.mutedForeground, - ), - ), - const SizedBox(width: 12), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.breadcrumbCurrent.copyWith( - color: palette.foreground, - ), - ), - ), + child: pageTitle, + ) + : pageTitle; + return AppBar( + title: toolbarTitle, + leading: effectiveLeading == null ? null : passThrough(effectiveLeading!), + actions: [ + for (final action in effectiveActions) passThrough(action), + if (headingAction != null) passThrough(headingAction!), + if (mobileSettingsAction != null) passThrough(mobileSettingsAction!), ], - ); - } -} - -class _PageThemeToggle extends StatefulWidget { - const _PageThemeToggle({required this.bus}); - final AppEventBus bus; - @override - State<_PageThemeToggle> createState() => _PageThemeToggleState(); -} - -class _PageThemeToggleState extends State<_PageThemeToggle> { - bool _saving = false; - @override - Widget build(BuildContext context) { - final dark = Theme.of(context).brightness == Brightness.dark; - final l = AppLocalizations.of(context)!; - return IconButton( - tooltip: dark ? l.headerSwitchToLight : l.headerSwitchToDark, - icon: Icon(dark ? LucideIcons.sun : LucideIcons.moon, size: 18), - onPressed: _saving - ? null - : () async { - setState(() => _saving = true); - try { - await widget.bus.updateThemeCode( - dark ? ThemeCode.light : ThemeCode.dark, - ); - } catch (error) { - if (context.mounted) { - ContextAlert.showToast(context, appFailureMessage(l, error)); - } - } finally { - if (mounted) setState(() => _saving = false); - } - }, + bottom: bottom, ); } } diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index b1dfad52e..9e910f60f 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -1,7 +1,6 @@ import 'dart:math' as math; import 'package:material_ui/material_ui.dart'; -import 'package:flutter/services.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -31,26 +30,11 @@ abstract final class AppTheme { required Brightness brightness, required bool mobile, }) => AppBarTheme( - backgroundColor: palette.header, - foregroundColor: palette.foreground, - iconTheme: IconThemeData(color: palette.mutedStrong), - surfaceTintColor: Colors.transparent, - centerTitle: false, - elevation: 0, - scrolledUnderElevation: 0, - titleSpacing: mobile ? AppSpacing.mobileHeaderHorizontal : AppSpacing.page, - actionsPadding: EdgeInsetsDirectional.only( - end: mobile ? AppSpacing.mobileHeaderHorizontal : AppSpacing.page, - ), - systemOverlayStyle: SystemUiOverlayStyle( - statusBarColor: palette.header, - statusBarIconBrightness: brightness == Brightness.light - ? Brightness.dark - : Brightness.light, - statusBarBrightness: brightness, - ), - titleTextStyle: AppTypography.pageTitle.copyWith( - fontSize: mobile ? 24 : 26, + // The App uses a compact custom titleLarge elsewhere. Keep that global + // scale unchanged and use Material's standard AppBar typography here. + titleTextStyle: Typography.englishLike2021.titleLarge!.copyWith( + fontFamily: AppFontFamily.sans, + fontFamilyFallback: AppFontFamily.sansFallback, color: palette.foreground, ), ); diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index b7f92e31b..dc99ea108 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:macos_window_utils/window_manipulator.dart'; import 'package:material_ui/material_ui.dart'; @@ -12,6 +13,7 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/advanced/xray/geodata/model.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -24,7 +26,7 @@ void main() { (390.0, 844.0, 2.0, Locale('fa')), ]) { testWidgets( - 'page header preserves title, description and actions at $width/$scale/$locale', + 'standard page bar keeps title and actions at $width/$scale/$locale', (tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = Size(width, height); @@ -62,36 +64,31 @@ void main() { ), ], ), - body: const SizedBox( - key: Key('page-body'), - width: double.infinity, - ), + body: const SizedBox.expand(key: Key('page-body')), ), ), ), ); await tester.pumpAndSettle(); final title = find.text('Настройки подключения'); - final description = find.text( - 'Параметры соединения и данные этого устройства.', - ); expect(title, findsOneWidget); - expect(description, findsOneWidget); expect( - tester.getRect(description).top, - greaterThanOrEqualTo(tester.getRect(title).bottom), + find.text('Параметры соединения и данные этого устройства.'), + findsNothing, ); + expect(find.byKey(const ValueKey('page-heading')), findsNothing); + expect(find.text('OneXray'), findsNothing); + expect(find.byType(Image), findsNothing); + final appBar = find.byType(AppBar); + final bar = tester.widget(appBar); + final context = tester.element(appBar); expect( - tester.getRect(find.byKey(const Key('page-body'))).top, - greaterThanOrEqualTo(tester.getRect(description).bottom), + tester.getSize(appBar).height, + AppBar.preferredHeightFor(context, bar.preferredSize), ); expect( - tester.getRect(title).left, - greaterThanOrEqualTo(width <= 720 ? 16 : 28), - ); - expect( - tester.getRect(title).right, - lessThanOrEqualTo(width - (width <= 720 ? 16 : 28)), + tester.getRect(find.byKey(const Key('page-body'))).top, + tester.getRect(appBar).bottom, ); expect( find.ancestor( @@ -103,7 +100,9 @@ void main() { ), findsOneWidget, ); - await tester.tap(find.byKey(const Key('page-action'))); + final action = find.byKey(const Key('page-action')); + expect(action.hitTestable(), findsOneWidget); + await tester.tap(action); await tester.pumpAndSettle(); expect(actions, 1); expect(tester.takeException(), isNull); @@ -111,74 +110,104 @@ void main() { ); } - for (final inline in [false, true]) { - testWidgets('compact mobile heading action opts into one row: $inline', ( - tester, - ) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(390, 844); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - var actions = 0; - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: true), - locale: const Locale('zh'), + testWidgets('page bar does not inject a theme shortcut when the bus exists', ( + tester, + ) async { + final bus = AppEventBus(); + addTearDown(bus.close); + await tester.pumpWidget( + BlocProvider.value( + value: bus, + child: MaterialApp( + theme: AppTheme.light, + locale: const Locale('en'), localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: const Text('服务器'), - description: '', - inlineHeadingAction: inline, - headingAction: FilledButton.icon( - key: const Key('compact-heading-action'), - onPressed: () => actions++, - icon: const Icon(Icons.add, size: 18), - label: const Text('导入'), + appBar: PageAppBar.forPage(context, title: const Text('Servers')), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of(tester.element(find.byType(AppBar)))!; + expect(find.byTooltip(l.headerSwitchToLight), findsNothing); + expect(find.byTooltip(l.headerSwitchToDark), findsNothing); + expect(tester.widget(find.byType(AppBar)).actions, isEmpty); + expect(tester.takeException(), isNull); + }); + + for (final inline in [false, true]) { + testWidgets( + 'legacy heading action stays in the standard toolbar: $inline', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var actions = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('zh'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('服务器'), + description: '', + inlineHeadingAction: inline, + headingAction: IconButton( + key: const Key('compact-heading-action'), + onPressed: () => actions++, + tooltip: '导入', + icon: const Icon(Icons.add), + ), ), - ), - body: const SizedBox( - key: Key('compact-page-body'), - width: double.infinity, + body: const SizedBox.expand(key: Key('compact-page-body')), ), ), ), - ), - ); - await tester.pumpAndSettle(); - final heading = find.byKey(const ValueKey('page-heading')); - final title = find.descendant(of: heading, matching: find.text('服务器')); - final action = find.byKey(const Key('compact-heading-action')); - final titleBounds = tester.getRect(title); - final actionBounds = tester.getRect(action); - expect(tester.widget(title).maxLines, isNull); - expect(action.hitTestable(), findsOneWidget); - if (inline) { + ); + await tester.pumpAndSettle(); + final title = find.text('服务器'); + final action = find.byKey(const Key('compact-heading-action')); + final titleBounds = tester.getRect(title); + final actionBounds = tester.getRect(action); + expect(action.hitTestable(), findsOneWidget); expect(titleBounds.overlaps(actionBounds), isFalse); expect(titleBounds.center.dy, closeTo(actionBounds.center.dy, .01)); - expect(titleBounds.right, lessThanOrEqualTo(actionBounds.left)); + expect( + tester.getRect(find.byType(AppBar)).contains(actionBounds.center), + isTrue, + ); + expect( + tester.widget(find.byType(AppBar)).actions, + contains(tester.widget(action)), + ); + expect(find.byType(SingleChildScrollView), findsNothing); expect( tester.getRect(find.byKey(const Key('compact-page-body'))).top, - lessThanOrEqualTo(132), + tester.getRect(find.byType(AppBar)).bottom, ); - } else { - expect(actionBounds.top, greaterThanOrEqualTo(titleBounds.bottom + 16)); - } - expect(actionBounds.right, lessThanOrEqualTo(390 - 16)); - await tester.tap(action); - await tester.pumpAndSettle(); - expect(actions, 1); - expect(tester.takeException(), isNull); - }); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }, + ); } for (final locale in [const Locale('ru'), const Locale('fa')]) { testWidgets( - 'inline heading wraps long identity and preserves short viewport actions: $locale', + 'long toolbar identity preserves short viewport actions: $locale', (tester) async { + final semantics = tester.ensureSemantics(); + try { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 360); addTearDown(tester.view.resetPhysicalSize); @@ -207,12 +236,12 @@ void main() { appBar: PageAppBar.forPage( context, title: const Text(titleText), - description: '', inlineHeadingAction: true, - headingAction: TextButton( + headingAction: IconButton( key: const Key('inline-import'), + tooltip: 'Import', + icon: const Icon(Icons.add), onPressed: () => imports++, - child: const Text('Import'), ), ), body: const SizedBox.expand(key: Key('inline-page-body')), @@ -230,52 +259,52 @@ void main() { ), ); await tester.pumpAndSettle(); - final heading = find.byKey(const ValueKey('page-heading')); - final title = find.descendant( - of: heading, - matching: find.text(titleText), - ); - expect(tester.widget(title).maxLines, isNull); + final title = find.text(titleText); + expect(title, findsOneWidget); + expect(tester.widget(title).data, titleText); expect( - tester.widget(title).overflow, - isNot(TextOverflow.ellipsis), + DefaultTextStyle.of(tester.element(title)).overflow, + TextOverflow.ellipsis, ); - final titleBounds = tester.getRect(title); - expect(titleBounds.left, greaterThanOrEqualTo(16)); - expect(titleBounds.right, lessThanOrEqualTo(374)); expect( - tester.getSize(find.byKey(const Key('inline-page-body'))).height, - greaterThanOrEqualTo(80), + find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == titleText, + ), + findsOneWidget, ); - final headingScrollable = find.descendant( - of: heading, - matching: find.byType(Scrollable), + expect( + tester.getSemantics(title), + containsSemantics(label: titleText, isHeader: true), ); expect( - tester - .state(headingScrollable) - .position - .maxScrollExtent, - greaterThan(0), + tester.getSize(find.byKey(const Key('inline-page-body'))).height, + greaterThanOrEqualTo(80), ); final action = find.byKey(const Key('inline-import')); - await tester.ensureVisible(action); - await tester.pumpAndSettle(); expect(action.hitTestable(), findsOneWidget); + expect( + find.byKey(const Key('inline-save')).hitTestable(), + findsOneWidget, + ); await tester.tap(action); await tester.tap(find.byKey(const Key('inline-save'))); await tester.pumpAndSettle(); expect(imports, 1); expect(saves, 1); expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } }, ); } for (final height in [844.0, 360.0]) { testWidgets( - 'long legal resource heading keeps content and footer reachable at 390x$height/2x', + 'long legal resource toolbar keeps content and footer reachable at 390x$height/2x', (tester) async { + final semantics = tester.ensureSemantics(); + try { tester.view.devicePixelRatio = 1; tester.view.physicalSize = Size(390, height); addTearDown(tester.view.resetPhysicalSize); @@ -310,10 +339,11 @@ void main() { title: Text(fileName), description: 'Resource source and update settings.', leading: BackButton(onPressed: () => backs++), - headingAction: TextButton( + headingAction: IconButton( key: const Key('resource-heading-action'), + tooltip: 'Update', + icon: const Icon(Icons.refresh), onPressed: () => headingActions++, - child: const Text('Update'), ), ), body: ListView( @@ -347,42 +377,31 @@ void main() { ), ); await tester.pumpAndSettle(); - final heading = find.byKey(const ValueKey('page-heading')); - final title = find.descendant( - of: heading, - matching: find.text(fileName), - ); + final title = find.text(fileName); expect(title, findsOneWidget); - expect(tester.widget(title).maxLines, isNull); + expect(tester.widget(title).data, fileName); expect( - tester.widget(title).overflow, - isNot(TextOverflow.ellipsis), + find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == fileName, + ), + findsOneWidget, ); - final headingScrollable = find.descendant( - of: heading, - matching: find.byType(Scrollable), + expect( + tester.getSemantics(title), + containsSemantics(label: fileName, isHeader: true), ); expect( - tester - .state(headingScrollable) - .position - .maxScrollExtent, - greaterThan(0), + DefaultTextStyle.of(tester.element(title)).overflow, + TextOverflow.ellipsis, ); final body = find.byKey(const Key('resource-body')); expect(tester.getSize(body).height, greaterThanOrEqualTo(80)); final footer = tester.getRect(find.byType(PageActionBar)); expect(footer.top, greaterThanOrEqualTo(tester.getRect(body).bottom)); expect(footer.bottom, lessThanOrEqualTo(height)); - - await tester.scrollUntilVisible( - find.byKey(const Key('resource-heading-action')), - 120, - scrollable: headingScrollable, - maxScrolls: 60, - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('resource-heading-action'))); + final headingAction = find.byKey(const Key('resource-heading-action')); + expect(headingAction.hitTestable(), findsOneWidget); + await tester.tap(headingAction); await tester.pumpAndSettle(); expect(headingActions, 1); @@ -414,6 +433,9 @@ void main() { expect(backs, 1); expect(controller.text, 'https://example.test/resource.dat'); expect(tester.takeException(), isNull); + } finally { + semantics.dispose(); + } }, ); } @@ -515,14 +537,22 @@ void main() { routes: [ GoRoute( path: primary.rootPath, - builder: (_, _) => - Scaffold(appBar: PageAppBar(title: Text(primary.name))), + builder: (context, _) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(primary.name), + ), + ), routes: [ for (final child in ['settings', 'detail']) GoRoute( path: child, - builder: (_, _) => - Scaffold(appBar: PageAppBar(title: Text(child))), + builder: (context, _) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(child), + ), + ), ), ], ), @@ -570,16 +600,19 @@ void main() { theme: theme.copyWith( appBarTheme: theme.appBarTheme.copyWith(toolbarHeight: 64), ), - home: const DefaultTabController( + home: DefaultTabController( length: 2, - child: Scaffold( - appBar: PageAppBar( - title: Text('Advanced'), - bottom: TabBar( - tabs: [ - Tab(text: 'Tunnel'), - Tab(text: 'Xray'), - ], + child: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('Advanced'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Tunnel'), + Tab(text: 'Xray'), + ], + ), ), ), ), @@ -668,6 +701,7 @@ void main() { final navigator = GlobalKey(); const rootAction = Key('root-action'); const detailAction = Key('detail-action'); + const headingAction = Key('heading-action'); await tester.pumpWidget( MaterialApp( navigatorKey: navigator, @@ -706,6 +740,11 @@ void main() { appBar: PageAppBar.forPage( context, title: const Text('Detail'), + headingAction: IconButton( + key: headingAction, + onPressed: () {}, + icon: const Icon(Icons.refresh), + ), actions: [ IconButton( key: detailAction, @@ -730,7 +769,11 @@ void main() { rectangles.values, contains(tester.getRect(find.byKey(detailAction))), ); - expect(rectangles, hasLength(2)); + expect( + rectangles.values, + contains(tester.getRect(find.byKey(headingAction))), + ); + expect(rectangles, hasLength(3)); navigator.currentState!.pop(); await tester.pump(); diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index c0ac6e13b..a90646ab9 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -25,33 +25,29 @@ void main() { expect(material.iconTheme.color, palette.foreground); expect(material.colorScheme.error, palette.destructive); expect(material.colorScheme.outline, palette.border); - expect(material.appBarTheme.backgroundColor, palette.header); - expect(material.appBarTheme.foregroundColor, palette.foreground); + expect(material.appBarTheme.backgroundColor, isNull); + expect(material.appBarTheme.foregroundColor, isNull); expect(material.appBarTheme.toolbarHeight, isNull); - expect(material.appBarTheme.centerTitle, isFalse); + expect(material.appBarTheme.centerTitle, isNull); + expect(material.appBarTheme.titleTextStyle?.fontSize, 22); + expect(material.appBarTheme.titleTextStyle?.fontWeight, FontWeight.w400); expect( - material.appBarTheme.titleTextStyle, - AppTypography.pageTitle.copyWith( - fontSize: 26, - color: palette.foreground, - ), + material.appBarTheme.titleTextStyle?.fontFamily, + AppFontFamily.sans, ); - expect(material.appBarTheme.titleSpacing, AppSpacing.page); expect( - material.appBarTheme.actionsPadding, - const EdgeInsetsDirectional.only(end: AppSpacing.page), + material.appBarTheme.titleTextStyle?.fontFamilyFallback, + AppFontFamily.sansFallback, ); - expect(material.appBarTheme.iconTheme?.color, palette.mutedStrong); - expect(material.appBarTheme.iconTheme?.size, isNull); + expect(material.appBarTheme.titleTextStyle?.color, palette.foreground); + expect(material.appBarTheme.titleSpacing, isNull); + expect(material.appBarTheme.actionsPadding, isNull); + expect(material.appBarTheme.elevation, isNull); + expect(material.appBarTheme.scrolledUnderElevation, isNull); + expect(material.appBarTheme.surfaceTintColor, isNull); + expect(material.appBarTheme.iconTheme, isNull); expect(material.appBarTheme.actionsIconTheme, isNull); - expect( - material.appBarTheme.systemOverlayStyle?.statusBarColor, - palette.header, - ); - expect( - material.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, - Brightness.dark, - ); + expect(material.appBarTheme.systemOverlayStyle, isNull); expect(material.navigationRailTheme.backgroundColor, palette.sidebar); expect(material.navigationBarTheme.height, isNull); expect(material.tabBarTheme.labelStyle?.fontSize, 14); @@ -87,10 +83,8 @@ void main() { material.navigationBarTheme.backgroundColor, palette.card.withValues(alpha: 0.96), ); - expect( - material.appBarTheme.systemOverlayStyle?.statusBarIconBrightness, - Brightness.light, - ); + expect(material.appBarTheme.systemOverlayStyle, isNull); + expect(material.appBarTheme.titleTextStyle?.color, palette.foreground); expect(shad.background, palette.background); expect(shad.card, palette.card); @@ -115,6 +109,7 @@ void main() { expect(material.textTheme.bodyMedium?.fontSize, 14); expect(material.textTheme.bodySmall?.fontSize, 13); expect(material.textTheme.labelSmall?.fontSize, 13); + expect(material.textTheme.titleLarge?.fontSize, 16); expect(shad.textTheme.family, AppFontFamily.sans); expect(shad.textTheme.p.fontSize, 14); expect(shad.textTheme.small.fontSize, 14); @@ -224,28 +219,19 @@ void main() { }, ); - test('mobile theme uses native navigation sizes and shared gutters', () { + test('mobile theme keeps standard AppBar and native navigation sizing', () { final material = AppTheme.material(Brightness.light, mobile: true); final shad = AppTheme.shad(Brightness.light, mobile: true); + expect(material.appBarTheme.titleTextStyle?.fontSize, 22); + expect(material.appBarTheme.titleTextStyle?.fontWeight, FontWeight.w400); expect( - material.appBarTheme.titleTextStyle, - AppTypography.pageTitle.copyWith( - fontSize: 24, - color: AppPalette.light.foreground, - ), + material.appBarTheme.titleTextStyle?.fontFamily, + AppFontFamily.sans, ); - expect(material.appBarTheme.centerTitle, isFalse); + expect(material.appBarTheme.centerTitle, isNull); expect(material.appBarTheme.toolbarHeight, isNull); - expect( - material.appBarTheme.titleSpacing, - AppSpacing.mobileHeaderHorizontal, - ); - expect( - material.appBarTheme.actionsPadding, - const EdgeInsetsDirectional.only( - end: AppSpacing.mobileHeaderHorizontal, - ), - ); + expect(material.appBarTheme.titleSpacing, isNull); + expect(material.appBarTheme.actionsPadding, isNull); final navigation = material.navigationBarTheme; expect(navigation.height, isNull); expect(navigation.indicatorColor, Colors.transparent); From 05ba291fb049ac45b89008442ae2b24657ae6692 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 16:26:36 +0800 Subject: [PATCH 34/60] refactor(setup): present initialization as full pages --- lib/pages/launch/setup/privacy.dart | 97 ++--- lib/pages/launch/setup/selectors.dart | 353 ++++++++---------- lib/pages/launch/setup/view.dart | 99 +++-- lib/pages/launch/setup/widgets.dart | 175 +-------- test/pages/launch/setup_page_layout_test.dart | 230 ++++++++++++ test/pages/launch/setup_view_test.dart | 49 ++- 6 files changed, 504 insertions(+), 499 deletions(-) create mode 100644 test/pages/launch/setup_page_layout_test.dart diff --git a/lib/pages/launch/setup/privacy.dart b/lib/pages/launch/setup/privacy.dart index 280157d2f..66ac85c6d 100644 --- a/lib/pages/launch/setup/privacy.dart +++ b/lib/pages/launch/setup/privacy.dart @@ -6,9 +6,9 @@ import 'package:onexray/pages/launch/setup/widgets.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/shared/doc/helper.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -70,82 +70,37 @@ class SetupPrivacyView extends StatelessWidget { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final detailStyle = - (mobile ? AppTypography.setupPoint : AppTypography.setupDesktopPoint) - .copyWith(color: palette.mutedStrong); + final detailStyle = AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ); return Scaffold( - appBar: mobile - ? null - : PageAppBar( - leading: IconButton( - tooltip: l.prototypeBack, - onPressed: onBack, - icon: const Icon(LucideIcons.arrowLeftDir), - ), - title: Text(l.prototypePrivacyPolicy), - ), + appBar: PageAppBar( + leading: IconButton( + tooltip: l.prototypeBack, + onPressed: onBack, + icon: const Icon(LucideIcons.arrowLeftDir), + ), + title: Text(l.prototypePrivacyPolicy), + ), body: SafeArea( + top: false, bottom: false, child: SetupBody( - top: mobile ? 56 : 24, children: [ - if (mobile) ...[ - Text(l.prototypePrivacyPolicy, style: AppTypography.setupTitle), - const SizedBox(height: 12), - ], - Text( - l.prototypeNoDataUpload, - style: AppTypography.setupSubtitle.copyWith( - color: palette.mutedStrong, - ), - ), - const SizedBox(height: 36), + Text(l.prototypeNoDataUpload, style: AppTypography.rowValue), + const SizedBox(height: 24), + Text(l.prototypeConfiguredSourcesNotice, style: detailStyle), + const SizedBox(height: 24), + Text(l.prototypeRegionPrivacyNotice, style: detailStyle), + const SizedBox(height: 24), Align( alignment: AlignmentDirectional.centerStart, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: mobile ? 760 : 460), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.prototypeConfiguredSourcesNotice, - style: detailStyle, - ), - const SizedBox(height: 24), - Text(l.prototypeRegionPrivacyNotice, style: detailStyle), - const SizedBox(height: 24), - TextButton( - onPressed: busy ? null : onOpenPolicy, - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size(0, 38), - alignment: AlignmentDirectional.topStart, - foregroundColor: palette.mutedStrong, - textStyle: detailStyle.copyWith( - decoration: TextDecoration.underline, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: ButtonProgress( - busy: busy, - child: Text(l.prototypeReadFullPrivacyPolicy), - ), - ), - const SizedBox(width: 12), - Icon( - LucideIcons.chevronRightDir, - size: 16, - color: palette.primary, - ), - ], - ), - ), - ], + child: TextButton.icon( + onPressed: busy ? null : onOpenPolicy, + icon: const Icon(LucideIcons.externalLink, size: 18), + label: ButtonProgress( + busy: busy, + child: Text(l.prototypeReadFullPrivacyPolicy), ), ), ), @@ -153,7 +108,7 @@ class SetupPrivacyView extends StatelessWidget { ], ), ), - bottomNavigationBar: SetupFooter( + bottomNavigationBar: PageActionBar( children: [ SetupActionButton(label: l.prototypeBack, onPressed: onBack), ], diff --git a/lib/pages/launch/setup/selectors.dart b/lib/pages/launch/setup/selectors.dart index 33f6d5c62..ba167f79d 100644 --- a/lib/pages/launch/setup/selectors.dart +++ b/lib/pages/launch/setup/selectors.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -92,222 +94,167 @@ class _SetupSelector extends StatelessWidget { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: mobile - ? null - : PageAppBar( - leading: IconButton( - tooltip: l10n.prototypeBack, - onPressed: () => controller.cancel(context), - icon: const Icon(LucideIcons.arrowLeftDir), - ), - title: Text(title), - ), + appBar: PageAppBar( + leading: IconButton( + tooltip: l10n.prototypeBack, + onPressed: () => controller.cancel(context), + icon: const Icon(LucideIcons.arrowLeftDir), + ), + title: Text(title), + ), body: SafeArea( + top: false, bottom: false, - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppLayout.setupContentMaxWidth, - ), - child: CustomScrollView( - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - slivers: [ - SliverPadding( - padding: EdgeInsets.fromLTRB( - mobile ? 24 : 0, - mobile ? 56 : 24, - mobile ? 24 : 0, - 28, - ), - sliver: SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (mobile) ...[ - Row( - children: [ - TextButton( - onPressed: () => - controller.cancel(context), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 8, - ), - minimumSize: const Size(0, 38), - tapTargetSize: - MaterialTapTargetSize.shrinkWrap, - textStyle: AppTypography.control, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - LucideIcons.arrowLeftDir, - size: 18, - ), - const SizedBox(width: 6), - Text(l10n.prototypeBack), - ], - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: AppTypography.setupChildTitle, - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - description, - style: AppTypography.setupSelectorDetail - .copyWith( - color: palette.mutedForeground, - ), - ), - const SizedBox(height: 22), - ], - if (state.choices.isEmpty) - Text( - l10n.prototypeTemporarilyUnavailable, - style: AppTypography.setupSelectorDetail - .copyWith( - color: palette.mutedForeground, - ), - ), - ], + child: ResponsiveContent( + child: CustomScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB( + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 16, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 24, + ), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + description, + style: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), ), ), + ), + if (state.choices.isEmpty) + SliverToBoxAdapter( + child: Text( + l10n.prototypeTemporarilyUnavailable, + style: AppTypography.supporting.copyWith( + color: palette.mutedForeground, + ), + ), + ) + else SliverList.builder( itemCount: state.choices.length, - itemBuilder: (context, index) { - final choice = state.choices[index]; - final selected = state.selected == choice.id; - final radius = BorderRadius.vertical( - top: index == 0 - ? const Radius.circular(AppRadii.card) - : Radius.zero, - bottom: index == state.choices.length - 1 - ? const Radius.circular(AppRadii.card) - : Radius.zero, - ); - return Semantics( - selected: selected, - inMutuallyExclusiveGroup: true, - child: Material( - color: selected - ? palette.selectedSurface - : palette.card, - borderRadius: radius, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => - controller.select(context, choice.id), - child: Container( - constraints: const BoxConstraints( - minHeight: 68, - ), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: radius, - border: Border( - left: BorderSide( - color: palette.border, - ), - right: BorderSide( - color: palette.border, - ), - bottom: BorderSide( - color: palette.border, - ), - top: index == 0 - ? BorderSide( - color: palette.border, - ) - : BorderSide.none, - ), - ), - child: Row( - children: [ - ShadRadioGroup( - initialValue: state.selected, - onChanged: (value) { - if (value != null && - value != state.selected) { - controller.select( - context, - value, - ); - } - }, - items: [ - ShadRadio( - value: choice.id, - size: 16, - circleSize: 10, - radioPadding: EdgeInsets.zero, - decoration: ShadDecoration( - border: ShadBorder.all( - color: selected - ? palette.primary - : palette.borderStrong, - width: 1, - ), - ), - ), - ], - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - Text( - choice.label, - style: AppTypography - .setupSelectorTitle, - ), - if (choice - .description - .isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - choice.description, - style: AppTypography - .setupSelectorDetail - .copyWith( - color: palette - .mutedForeground, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ), - ); - }, + itemBuilder: (context, index) => + _choice(context, index, state, controller), ), - ], - ), + ], ), - ], - ), + ), + ], ), ), ), + bottomNavigationBar: PageActionBar( + children: [ + OutlinedButton( + onPressed: () => controller.cancel(context), + child: Text(l10n.prototypeBack), + ), + ], + ), ); }, ), ); + + Widget _choice( + BuildContext context, + int index, + _ChoiceState state, + _ChoiceController controller, + ) { + final palette = ColorManager.palette(context); + final choice = state.choices[index]; + final isSelected = state.selected == choice.id; + final radius = BorderRadius.vertical( + top: index == 0 ? const Radius.circular(AppRadii.card) : Radius.zero, + bottom: index == state.choices.length - 1 + ? const Radius.circular(AppRadii.card) + : Radius.zero, + ); + return Semantics( + selected: isSelected, + inMutuallyExclusiveGroup: true, + child: Material( + color: isSelected ? palette.selectedSurface : palette.card, + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => controller.select(context, choice.id), + child: Container( + constraints: const BoxConstraints(minHeight: 68), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: radius, + border: Border( + left: BorderSide(color: palette.border), + right: BorderSide(color: palette.border), + bottom: BorderSide(color: palette.border), + top: index == 0 + ? BorderSide(color: palette.border) + : BorderSide.none, + ), + ), + child: Row( + children: [ + ShadRadioGroup( + initialValue: state.selected, + onChanged: (value) { + if (value != null && value != state.selected) { + controller.select(context, value); + } + }, + items: [ + ShadRadio( + value: choice.id, + size: 16, + circleSize: 10, + radioPadding: EdgeInsets.zero, + decoration: ShadDecoration( + border: ShadBorder.all( + color: isSelected + ? palette.primary + : palette.borderStrong, + width: 1, + ), + ), + ), + ], + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + choice.label, + style: AppTypography.setupSelectorTitle, + ), + if (choice.description.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + choice.description, + style: AppTypography.setupSelectorDetail.copyWith( + color: palette.mutedForeground, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ); + } } diff --git a/lib/pages/launch/setup/view.dart b/lib/pages/launch/setup/view.dart index c7220c546..2858b0a8c 100644 --- a/lib/pages/launch/setup/view.dart +++ b/lib/pages/launch/setup/view.dart @@ -4,10 +4,11 @@ import 'package:onexray/pages/launch/setup/controller.dart'; import 'package:onexray/pages/launch/setup/selectors.dart'; import 'package:onexray/pages/launch/setup/widgets.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/launch/setup.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -30,75 +31,61 @@ class SetupView extends StatelessWidget { @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final welcome = state.step == SetupStep.welcome; return PopScope( canPop: false, child: Scaffold( + appBar: PageAppBar( + title: Text( + welcome ? l.prototypeWelcome : l.prototypeGetReadyToConnect, + ), + leading: welcome + ? null + : IconButton( + tooltip: l.prototypeBack, + onPressed: _action(SetupAction.back), + icon: const Icon(LucideIcons.arrowLeftDir), + ), + ), body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: (constraints.maxHeight - 48).clamp( - 0, - double.infinity, + top: false, + bottom: false, + child: SetupBody( + children: [ + if (welcome) ...[ + Text( + l.prototypeWelcomeSubtitle, + style: AppTypography.rowValue.copyWith( + color: ColorManager.palette(context).mutedForeground, ), ), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: SettingSection( - title: welcome - ? l.prototypeWelcome - : l.prototypeGetReadyToConnect, - description: welcome ? l.prototypeWelcomeSubtitle : null, - padding: EdgeInsets.zero, - children: [ - if (welcome) ...[ - Text( - l.prototypeNoDataCollection, - style: AppTypography.rowValue, - ), - const SizedBox(height: 16), - Text( - l.prototypeBringOwnServers, - style: AppTypography.rowValue, - ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: _action(SetupAction.privacy), - child: Text(l.prototypePrivacyPolicy), - ), - ] else - ..._configuration(context, mobile), - ..._feedback(context), - if (state.step != SetupStep.complete) ...[ - const SizedBox(height: 24), - if (welcome) - ..._actions(l) - else - OverflowBar( - spacing: 10, - overflowSpacing: 10, - children: _actions(l), - ), - ], - ], - ), - ), + const SizedBox(height: 24), + Text( + l.prototypeNoDataCollection, + style: AppTypography.rowValue, ), - ), - ), + const SizedBox(height: 16), + Text(l.prototypeBringOwnServers, style: AppTypography.rowValue), + const SizedBox(height: 24), + SettingRow( + title: l.prototypePrivacyPolicy, + leading: const Icon(LucideIcons.shieldCheck), + onTap: _action(SetupAction.privacy), + ), + ] else + ..._configuration(context), + ..._feedback(context), + ], ), ), + bottomNavigationBar: state.step == SetupStep.complete + ? null + : PageActionBar(children: _actions(l)), ), ); } - List _configuration(BuildContext context, bool mobile) { + List _configuration(BuildContext context) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); return [ diff --git a/lib/pages/launch/setup/widgets.dart b/lib/pages/launch/setup/widgets.dart index fbb39f08b..1b6183388 100644 --- a/lib/pages/launch/setup/widgets.dart +++ b/lib/pages/launch/setup/widgets.dart @@ -2,13 +2,12 @@ import 'package:material_ui/material_ui.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; -/// Small setup forms fill the viewport but still scroll on a short screen or -/// with a larger system text size. Long selector lists use slivers instead. +/// Setup uses the same top-aligned, single-column content flow as other pages. class SetupBody extends StatelessWidget { - const SetupBody({super.key, required this.children, this.top = 48}); + const SetupBody({super.key, required this.children, this.top = 16}); final List children; final double top; @@ -17,162 +16,20 @@ class SetupBody extends StatelessWidget { Widget build(BuildContext context) { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - return LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight( - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: mobile ? 620 : AppLayout.setupContentMaxWidth + 48, - ), - child: Padding( - padding: EdgeInsets.fromLTRB(24, top, 24, mobile ? 28 : 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ), + return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: ResponsiveContent( + child: Padding( + padding: EdgeInsets.fromLTRB( + mobile ? AppSpacing.mobilePage : AppSpacing.page, + top, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + 24, ), - ), - ), - ); - } -} - -class SetupDesktopBody extends StatelessWidget { - const SetupDesktopBody({ - super.key, - required this.progress, - required this.children, - this.bodyTop = 48, - }); - - final Widget progress; - final List children; - final double bodyTop; - - @override - Widget build(BuildContext context) => SingleChildScrollView( - child: Padding( - padding: const EdgeInsetsDirectional.only( - start: AppSpacing.setupDesktopHorizontal, - top: AppSpacing.setupDesktopTop, - end: AppSpacing.setupDesktopHorizontal, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('OneXray', style: AppTypography.setupBrand), - const SizedBox(height: 10), - Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppLayout.setupProgressMaxWidth, - ), - child: progress, - ), - ), - _content(), - ], - ), - ), - ); - - Widget _content() => Align( - alignment: AlignmentDirectional.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppLayout.setupContentMaxWidth, - ), - child: Padding( - padding: EdgeInsets.only(top: bodyTop, bottom: 36), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: children, - ), - ), - ), - ); -} - -class SetupFooter extends StatelessWidget { - const SetupFooter({super.key, required this.children, this.note}); - - final List children; - final String? note; - - @override - Widget build(BuildContext context) { - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - if (mobile) { - return PageActionBar( - horizontalPadding: 24, - verticalPadding: 16, - spacing: 14, - children: children, - ); - } - final palette = ColorManager.palette(context); - return Material( - color: palette.card, - child: DecoratedBox( - decoration: BoxDecoration( - border: Border(top: BorderSide(color: palette.border)), - ), - child: SafeArea( - top: false, - child: Center( - heightFactor: 1, - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: AppLayout.setupFooterMaxWidth, - minHeight: AppLayout.setupFooterMinHeight, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.setupDesktopHorizontal, - vertical: 18, - ), - child: children.length == 1 - ? Row( - children: [ - const Spacer(), - SizedBox( - width: AppLayout.setupFooterButtonWidth, - child: children.single, - ), - ], - ) - : Row( - children: [ - SizedBox( - width: AppLayout.setupFooterButtonWidth, - child: children.first, - ), - Expanded( - child: note == null - ? const SizedBox.shrink() - : Text( - note!, - textAlign: TextAlign.center, - style: AppTypography.setupStatus.copyWith( - color: palette.mutedForeground, - ), - ), - ), - SizedBox( - width: AppLayout.setupFooterButtonWidth, - child: children.last, - ), - ], - ), - ), - ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, ), ), ), diff --git a/test/pages/launch/setup_page_layout_test.dart b/test/pages/launch/setup_page_layout_test.dart new file mode 100644 index 000000000..0e44e146c --- /dev/null +++ b/test/pages/launch/setup_page_layout_test.dart @@ -0,0 +1,230 @@ +import 'package:material_ui/material_ui.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/launch/setup/page.dart'; +import 'package:onexray/pages/launch/setup/selectors.dart'; +import 'package:onexray/pages/launch/setup/widgets.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/advanced/tunnel/interface.dart'; +import 'package:onexray/service/launch/setup.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +Widget _host( + Widget child, { + Locale locale = const Locale('en'), + double scale = 1, + double keyboard = 0, +}) => MaterialApp( + theme: AppTheme.light, + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(scale), + viewInsets: EdgeInsets.only(bottom: keyboard), + ), + child: ShadTheme(data: AppTheme.shad(Brightness.light), child: child!), + ), + home: child, +); + +void _size(WidgetTester tester, Size size) { + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); +} + +Finder _footerText(String text) => + find.descendant(of: find.byType(PageActionBar), matching: find.text(text)); + +void main() { + for (final scenario in const [ + (size: Size(390, 667), scale: 1.5, keyboard: 200.0, locale: Locale('ru')), + (size: Size(768, 600), scale: 2.0, keyboard: 0.0, locale: Locale('fa')), + (size: Size(1160, 720), scale: 1.0, keyboard: 0.0, locale: Locale('en')), + ]) { + for (final step in [SetupStep.welcome, SetupStep.configuration]) { + testWidgets('setup $step keeps page actions fixed in $scenario', ( + tester, + ) async { + _size(tester, scenario.size); + final actions = []; + await tester.pumpWidget( + _host( + SetupView( + state: SetupPageState( + step: step, + busy: false, + localReady: true, + interfaceName: 'Ethernet', + regions: const ['RU'], + ), + requiresInterface: true, + onAction: actions.add, + ), + locale: scenario.locale, + scale: scenario.scale, + keyboard: scenario.keyboard, + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of(tester.element(find.byType(SetupView)))!; + final label = step == SetupStep.welcome + ? l.prototypeAgreeAndContinue + : l.prototypeGoToHome; + final footerAction = _footerText(label); + final footerRect = tester.getRect(find.byType(PageActionBar)); + expect(find.byType(PageAppBar), findsOneWidget); + expect(find.byType(ShadCard), findsNothing); + expect(footerAction.hitTestable(), findsOneWidget); + expect( + tester.getBottomRight(footerAction).dy, + lessThanOrEqualTo(scenario.size.height - scenario.keyboard), + ); + expect( + find.ancestor(of: footerAction, matching: find.byType(Scrollable)), + findsNothing, + ); + await tester.drag( + find.descendant( + of: find.byType(SetupBody), + matching: find.byType(SingleChildScrollView), + ), + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(PageActionBar)), footerRect); + await tester.tap(footerAction); + expect(actions, [ + step == SetupStep.welcome + ? SetupAction.acceptPrivacy + : SetupAction.finish, + ]); + expect(tester.takeException(), isNull); + }); + } + } + + testWidgets('privacy keeps its return action outside the scrolling text', ( + tester, + ) async { + _size(tester, const Size(390, 600)); + var returned = 0; + var opened = 0; + await tester.pumpWidget( + _host( + SetupPrivacyView( + failureText: 'The privacy policy could not be opened.', + onBack: () => returned++, + onOpenPolicy: () => opened++, + ), + scale: 2, + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(PageAppBar), findsOneWidget); + expect(find.byType(ShadCard), findsNothing); + expect(_footerText('Back').hitTestable(), findsOneWidget); + await tester.ensureVisible(find.text('Read the full privacy policy')); + await tester.tap(find.text('Read the full privacy policy')); + expect(opened, 1); + await tester.ensureVisible( + find.text('The privacy policy could not be opened.'), + ); + expect(_footerText('Back').hitTestable(), findsOneWidget); + await tester.tap(_footerText('Back')); + expect(returned, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'interface page cancels without a result and returns a chosen name', + (tester) async { + _size(tester, const Size(768, 600)); + String? result; + var returned = false; + final router = GoRouter( + initialLocation: '/setup', + routes: [ + GoRoute( + path: '/setup', + builder: (context, _) => Scaffold( + body: TextButton( + onPressed: () async { + result = await context.push( + '/setup/interface', + extra: SetupInterfaceParams([ + for (var i = 0; i < 30; i++) + OutboundInterfaceOption('Ethernet $i', const [ + '192.0.2.1', + ], i == 0), + ], 'Ethernet 0'), + ); + returned = true; + }, + child: const Text('Open interface'), + ), + ), + ), + GoRoute( + path: '/setup/interface', + builder: (_, state) => SetupInterfacePage( + params: state.extra! as SetupInterfaceParams, + ), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(1.5)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: child!, + ), + ), + ), + ); + await tester.tap(find.text('Open interface')); + await tester.pumpAndSettle(); + expect(find.byType(PageAppBar), findsOneWidget); + expect(_footerText('Back').hitTestable(), findsOneWidget); + await tester.tap(_footerText('Back')); + await tester.pumpAndSettle(); + expect(returned, isTrue); + expect(result, isNull); + expect(router.routerDelegate.currentConfiguration.uri.path, '/setup'); + returned = false; + await tester.tap(find.text('Open interface')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Ethernet 29'), + 300, + scrollable: find.descendant( + of: find.byType(SetupInterfacePage), + matching: find.byType(Scrollable), + ), + ); + expect(_footerText('Back').hitTestable(), findsOneWidget); + await tester.tap(find.text('Ethernet 29')); + await tester.pumpAndSettle(); + expect(returned, isTrue); + expect(result, 'Ethernet 29'); + expect(router.routerDelegate.currentConfiguration.uri.path, '/setup'); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/pages/launch/setup_view_test.dart b/test/pages/launch/setup_view_test.dart index 054b397bb..6ca0a1d95 100644 --- a/test/pages/launch/setup_view_test.dart +++ b/test/pages/launch/setup_view_test.dart @@ -3,7 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/launch/setup/page.dart'; +import 'package:onexray/pages/launch/setup/widgets.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; +import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/launch/setup.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -58,21 +61,44 @@ void main() { ); await tester.pumpAndSettle(); final l = AppLocalizations.of(tester.element(find.byType(SetupView)))!; - final card = find.byType(ShadCard); - expect(card, findsOneWidget); + expect(find.byType(ShadCard), findsNothing); + expect(find.byType(PageAppBar), findsOneWidget); + expect( + find.descendant( + of: find.byType(PageAppBar), + matching: find.text(l.prototypeWelcome), + ), + findsOneWidget, + ); for (final text in [ - l.prototypeWelcome, + l.prototypeWelcomeSubtitle, l.prototypeNoDataCollection, l.prototypeBringOwnServers, - l.prototypeAgreeAndContinue, ]) { expect( - find.descendant(of: card, matching: find.text(text)), + find.descendant( + of: find.byType(SetupBody), + matching: find.text(text), + ), findsOneWidget, ); } - expect(tester.getRect(card).left, greaterThanOrEqualTo(24)); - expect(tester.getRect(card).right, lessThanOrEqualTo(366)); + expect( + tester.getTopLeft(find.text(l.prototypeWelcomeSubtitle)).dy, + closeTo(tester.getBottomLeft(find.byType(PageAppBar)).dy + 16, 1), + ); + final continueButton = find.descendant( + of: find.byType(PageActionBar), + matching: find.widgetWithText( + FilledButton, + l.prototypeAgreeAndContinue, + ), + ); + expect(continueButton, findsOneWidget); + expect( + find.ancestor(of: continueButton, matching: find.byType(Scrollable)), + findsNothing, + ); await tester.ensureVisible(find.text(l.prototypePrivacyPolicy)); await tester.tap(find.text(l.prototypePrivacyPolicy)); await tester.tap(find.text(l.prototypeAgreeAndContinue)); @@ -109,16 +135,19 @@ void main() { expect(find.text(l.prototypeSetUpVpn), findsNothing); expect(find.text(l.prototypeAddServers), findsNothing); expect(find.text(l.prototypeVpnPermission), findsNothing); - expect(find.byType(ShadCard), findsOneWidget); + expect(find.byType(ShadCard), findsNothing); expect( find.descendant( - of: find.byType(ShadCard), + of: find.byType(PageActionBar), matching: find.text(l.prototypeGoToHome), ), findsOneWidget, ); await tester.drag( - find.byType(SingleChildScrollView), + find.descendant( + of: find.byType(SetupBody), + matching: find.byType(SingleChildScrollView), + ), const Offset(0, -300), ); await tester.pumpAndSettle(); From 12ac17f1051078f510169981084be8bf8188781f Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 16:27:52 +0800 Subject: [PATCH 35/60] fix(servers): restore manual node ordering entry --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fa.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_Hant.arb | 1 + lib/pages/servers/controller.dart | 35 +++ lib/pages/servers/manual_group_dialogs.dart | 37 +++ lib/pages/servers/order/controller.dart | 2 + lib/pages/servers/order/page.dart | 19 ++ lib/pages/servers/page.dart | 34 +-- lib/pages/servers/sort.dart | 33 +++ lib/pages/servers/view.dart | 9 +- test/pages/servers/manual_groups_test.dart | 268 +++++++++++++++++++- test/pages/servers/order_view_test.dart | 37 +++ test/pages/servers/view_test.dart | 89 ++++++- 15 files changed, 540 insertions(+), 28 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bd0c28e94..cb8261b57 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3168,6 +3168,7 @@ "manualOrderChanged": "The list changed. Reload it before arranging again.", "manualOrderReload": "Reload order", "manualOrderClearSearch": "Clear search to arrange.", + "manualOrderDisplayHint": "Saved positions are shown with Default sorting. Sorting by name or latency is unchanged.", "subscriptionAdvancedOff": "HWID and Age are off", "subscriptionAdvancedConfigured": "Configured: {options}", "@subscriptionAdvancedConfigured": { diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3a4a7e86c..eb099ff19 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -987,6 +987,7 @@ "manualOrderChanged": "فهرست تغییر کرد. پیش از مرتب‌سازی دوباره بارگیری کنید.", "manualOrderReload": "بارگیری دوباره", "manualOrderClearSearch": "برای مرتب‌سازی، جستجو را پاک کنید.", + "manualOrderDisplayHint": "ترتیب ذخیره‌شده در مرتب‌سازی پیش‌فرض نمایش داده می‌شود. تنظیم مرتب‌سازی بر اساس نام یا تأخیر تغییر نمی‌کند.", "subscriptionAdvancedOff": "HWID و Age غیرفعال هستند", "subscriptionAdvancedConfigured": "پیکربندی‌شده: {options}", "@subscriptionAdvancedConfigured": { diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3a34c321d..5bcbff03d 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -987,6 +987,7 @@ "manualOrderChanged": "Список изменился. Загрузите его снова перед сортировкой.", "manualOrderReload": "Загрузить заново", "manualOrderClearSearch": "Очистите поиск для изменения порядка.", + "manualOrderDisplayHint": "Сохранённый порядок отображается при сортировке по умолчанию. Сортировка по имени или задержке не изменяется.", "subscriptionAdvancedOff": "HWID и Age отключены", "subscriptionAdvancedConfigured": "Настроено: {options}", "@subscriptionAdvancedConfigured": { diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 151778c8f..80a3ecd5c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -987,6 +987,7 @@ "manualOrderChanged": "列表已变化,请重新载入后整理。", "manualOrderReload": "重新载入", "manualOrderClearSearch": "清空搜索后可整理顺序。", + "manualOrderDisplayHint": "保存的顺序会在“默认”排序下显示;按名称或延迟排序的设置保持不变。", "subscriptionAdvancedOff": "未开启 HWID 和 Age", "subscriptionAdvancedConfigured": "已配置:{options}", "@subscriptionAdvancedConfigured": { diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 8b9e9e2f4..1f0a2bc25 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -987,6 +987,7 @@ "manualOrderChanged": "清單已變更,請重新載入後整理。", "manualOrderReload": "重新載入", "manualOrderClearSearch": "清空搜尋後可整理順序。", + "manualOrderDisplayHint": "儲存的順序會在「預設」排序下顯示;依名稱或延遲排序的設定維持不變。", "subscriptionAdvancedOff": "未開啟 HWID 和 Age", "subscriptionAdvancedConfigured": "已設定:{options}", "@subscriptionAdvancedConfigured": { diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index 7202f5de4..807c3ffb3 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -858,6 +858,40 @@ class ServersController extends PageCubit ); } + /// Each saved order belongs to one complete manual group, never a filter. + Future openManualServerOrder(BuildContext context) async { + if (query.trim().isNotEmpty) return; + final l = AppLocalizations.of(context)!; + final scopes = groups( + l, + filter: false, + ).where((group) => group.manual).toList(); + if (!scopes.any( + (group) => group.rows.length >= 2 && !manualGroupBusy(group), + )) { + return; + } + final nonempty = scopes.where((group) => group.rows.isNotEmpty).toList(); + final id = nonempty.length == 1 + ? nonempty.single.id + : await showAppDialog( + context, + (_) => ManualServerOrderScopeDialog( + groups: scopes, + busyGroupIds: scopes + .where(manualGroupBusy) + .map((group) => group.id) + .toSet(), + ), + ); + if (id == null || !context.mounted || !isPageActive) return; + final group = groups( + l, + filter: false, + ).where((group) => group.id == id).firstOrNull; + if (group != null) await openServerOrder(context, group); + } + Future openServerOrder(BuildContext context, ServerGroup group) async { if (!group.manual || query.trim().isNotEmpty || @@ -886,6 +920,7 @@ class ServersController extends PageCubit extra: ManualOrderParams( title: '${AppLocalizations.of(context)!.manualServerOrder} · ${group.name}', + description: AppLocalizations.of(context)!.manualOrderDisplayHint, items: items(catalog), reload: load, save: (expected, ordered) => manualGroupService.reorderServers( diff --git a/lib/pages/servers/manual_group_dialogs.dart b/lib/pages/servers/manual_group_dialogs.dart index 3787cbd31..3197f99bd 100644 --- a/lib/pages/servers/manual_group_dialogs.dart +++ b/lib/pages/servers/manual_group_dialogs.dart @@ -2,7 +2,9 @@ import 'package:material_ui/material_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/servers/catalog.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/color.dart'; /// Distinguishes a chosen virtual group from dismissing the dialog. @@ -13,6 +15,41 @@ class ManualGroupTarget { enum ManualGroupAction { rename, dissolve, delete } +class ManualServerOrderScopeDialog extends StatelessWidget { + const ManualServerOrderScopeDialog({ + super.key, + required this.groups, + required this.busyGroupIds, + }); + + final List groups; + final Set busyGroupIds; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + return AppDialog( + title: l.manualServerOrder, + subtitle: l.manualOrderDisplayHint, + body: Column( + children: [ + for (final group in groups) + SettingRow( + key: ValueKey('manual-order-scope-${group.id}'), + title: group.name, + subtitle: l.prototypeServerCount(group.rows.length), + leading: const Icon(LucideIcons.folder, size: 20), + enabled: + group.rows.length >= 2 && !busyGroupIds.contains(group.id), + showChevron: true, + onTap: () => Navigator.of(context).pop(group.id), + ), + ], + ), + ); + } +} + class ManualGroupNameDialog extends StatefulWidget { const ManualGroupNameDialog({super.key, this.initialName}); final String? initialName; diff --git a/lib/pages/servers/order/controller.dart b/lib/pages/servers/order/controller.dart index 291b235f8..ff24e6f0e 100644 --- a/lib/pages/servers/order/controller.dart +++ b/lib/pages/servers/order/controller.dart @@ -12,8 +12,10 @@ class ManualOrderParams { required this.items, required this.save, required this.reload, + this.description, }); final String title; + final String? description; final List items; final Future Function(List expectedIds, List orderedIds) save; final Future> Function() reload; diff --git a/lib/pages/servers/order/page.dart b/lib/pages/servers/order/page.dart index 7e948b630..58e3edddd 100644 --- a/lib/pages/servers/order/page.dart +++ b/lib/pages/servers/order/page.dart @@ -9,6 +9,7 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/shared/failure.dart'; @@ -41,6 +42,24 @@ class ManualOrderPage extends StatelessWidget { child: CustomScrollView( key: const PageStorageKey('manual-order'), slivers: [ + if (params.description != null) + SliverPadding( + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, + 0, + ), + sliver: SliverToBoxAdapter( + child: Text( + params.description!, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context) + .mutedForeground, + ), + ), + ), + ), if (state.failure != null) SliverToBoxAdapter( child: Padding( diff --git a/lib/pages/servers/page.dart b/lib/pages/servers/page.dart index a2843a63b..40bdf96c5 100644 --- a/lib/pages/servers/page.dart +++ b/lib/pages/servers/page.dart @@ -53,27 +53,21 @@ class _ServersPageState extends State { appBar: PageAppBar.forPage( context, title: Text(l.prototypeServers), - description: mobile ? '' : l.serversPageDescription, - inlineHeadingAction: mobile, - headingAction: Wrap( - spacing: 10, - runSpacing: 10, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (!mobile) - IconButton( - tooltip: l.manualGroupNew, - onPressed: () => controller.createManualGroup(context), - icon: const Icon(LucideIcons.folderPlus, size: 20), - ), - FilledButton.icon( - key: const ValueKey('servers-add'), - onPressed: () => controller.addServers(context), - icon: const Icon(LucideIcons.plus, size: 18), - label: Text(mobile ? l.prototypeAdd : l.prototypeAddServer), + description: l.serversPageDescription, + actions: [ + if (!mobile) + IconButton( + tooltip: l.manualGroupNew, + onPressed: () => controller.createManualGroup(context), + icon: const Icon(LucideIcons.folderPlus, size: 20), ), - ], - ), + IconButton( + key: const ValueKey('servers-add'), + tooltip: l.prototypeAddServer, + onPressed: () => controller.addServers(context), + icon: const Icon(LucideIcons.plus, size: 20), + ), + ], ), body: SafeArea( child: ServerLoadState( diff --git a/lib/pages/servers/sort.dart b/lib/pages/servers/sort.dart index 68b99f2c6..a1736e7bd 100644 --- a/lib/pages/servers/sort.dart +++ b/lib/pages/servers/sort.dart @@ -83,3 +83,36 @@ class ManualGroupOrderButton extends StatelessWidget { ); } } + +class ManualServerOrderButton extends StatelessWidget { + const ManualServerOrderButton({super.key, required this.controller}); + + final ServersController controller; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context)!; + final searching = controller.query.trim().isNotEmpty; + final enabled = + !searching && + controller + .groups(l, filter: false) + .any( + (group) => + group.manual && + group.rows.length >= 2 && + !controller.manualGroupBusy(group), + ); + return Tooltip( + message: searching ? l.manualOrderClearSearch : l.manualOrderDisplayHint, + child: OutlinedButton.icon( + key: const ValueKey('manual-server-order'), + onPressed: enabled + ? () => controller.openManualServerOrder(context) + : null, + icon: const Icon(LucideIcons.listOrdered, size: 17), + label: Text(l.manualServerOrder), + ), + ); + } +} diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index 8e8ac7585..1f9f59a8c 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -227,6 +227,11 @@ class _ServerBrowserState extends State { controller.query.trim().isNotEmpty || validSource != -1 || (!complete && favoritesOnly); + final showManualTools = + !complete && + controller.query.trim().isEmpty && + !favoritesOnly && + validSource <= 0; final palette = ColorManager.palette(context); return CustomScrollView( controller: widget.scroll, @@ -281,6 +286,8 @@ class _ServerBrowserState extends State { ), if (!complete) ...[ ServerSortButton(controller: controller), + if (showManualTools) + ManualServerOrderButton(controller: controller), IconButton( tooltip: l.prototypeFavorites, isSelected: favoritesOnly, @@ -318,7 +325,7 @@ class _ServerBrowserState extends State { ), ], ), - if (!complete && !filtered) ...[ + if (showManualTools) ...[ SizedBox(height: gap), SettingRow( title: l.manualGroupsTitle, diff --git a/test/pages/servers/manual_groups_test.dart b/test/pages/servers/manual_groups_test.dart index 929456204..de5108058 100644 --- a/test/pages/servers/manual_groups_test.dart +++ b/test/pages/servers/manual_groups_test.dart @@ -9,6 +9,8 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/manual_group_dialogs.dart'; +import 'package:onexray/pages/servers/order/controller.dart'; +import 'package:onexray/pages/servers/order/page.dart'; import 'package:onexray/pages/servers/page.dart'; import 'package:onexray/pages/servers/sources.dart'; import 'package:onexray/pages/servers/view.dart'; @@ -17,6 +19,7 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/outbound/state_db.dart'; +import 'package:onexray/service/servers/sort.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; // ignore: depend_on_referenced_packages @@ -111,6 +114,13 @@ void main() { addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); router = serverTestRouter( + extraRoutes: [ + GoRoute( + path: 'manual-server-order', + builder: (_, state) => + ManualOrderPage(params: state.extra! as ManualOrderParams), + ), + ], home: (_) => fullPage ? ServersPage(controller: controller) : Builder( @@ -158,6 +168,254 @@ void main() { await settle(tester); } + testWidgets( + 'the root arranges all ungrouped nodes without changing display sorting', + (tester) async { + final ids = await databaseOperation( + tester, + () async => [ + await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': 'Alpha', 'protocol': 'socks'}), + ), + await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': 'Zulu', 'protocol': 'socks'}), + ), + ], + ); + await settleUntil(tester, () => controller.servers.length == 2); + await pump(tester); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + final selection = controller.configuration.toJson(); + await tester.tap(find.byTooltip(l.serverSortTitle)); + await settle(tester); + await tester.tap(find.text(l.serverSortName).last); + await settleUntil( + tester, + () => + controller.catalog.sortOrder == ServerSortOrder.name && + !controller.state.sortSaving, + ); + await settle(tester); + + Future openOrder() async { + final entry = find.byKey(const ValueKey('manual-server-order')); + await tester.ensureVisible(entry); + await settle(tester); + await tester.tap(entry); + await settle(tester); + expect(find.byType(ManualServerOrderScopeDialog), findsNothing); + expect(find.byType(ManualOrderPage), findsOneWidget); + expect(find.text(l.manualOrderDisplayHint), findsOneWidget); + expect( + tester + .widget(find.byType(ManualOrderPage)) + .params + .items + .map((item) => item.id), + ids, + ); + } + + await openOrder(); + await tester.tap(find.byKey(ValueKey('move-up-${ids.last}'))); + await settle(tester); + await tester.tap(find.text(l.prototypeCancel)); + await settle(tester); + final afterCancel = await databaseOperation( + tester, + () => db.coreConfigDao.allActiveServerRows(), + ); + expect(afterCancel.map((row) => row.manualSortOrder), [0, 1]); + + await openOrder(); + await tester.tap(find.byKey(ValueKey('move-up-${ids.last}'))); + await settle(tester); + await tester.tap(find.text(l.prototypeSave)); + await settleUntil( + tester, + () => + controller.servers + .firstWhere((row) => row.id == ids.last) + .manualSortOrder == + 0, + ); + await settle(tester); + expect(find.byType(ManualOrderPage), findsNothing); + expect(controller.catalog.sortOrder, ServerSortOrder.name); + expect(AppEventBus.instance.state.serverSort, ServerSortOrder.name); + expect(controller.servers.map((row) => row.id), ids); + expect(controller.configuration.toJson(), selection); + + await tester.tap(find.byTooltip(l.serverSortTitle)); + await settle(tester); + await tester.tap(find.text(l.serverSortDefault).last); + await settleUntil( + tester, + () => + controller.catalog.sortOrder == ServerSortOrder.defaultOrder && + !controller.state.sortSaving, + ); + expect(controller.servers.map((row) => row.id), ids.reversed); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'multiple manual scopes require a group and only save that complete group', + (tester) async { + final fixture = await databaseOperation(tester, () async { + final home = await controller.manualGroupService.create('Home'); + final singleton = await controller.manualGroupService.create('Single'); + final empty = await controller.manualGroupService.create('Empty'); + final source = await db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: 'Subscription', + url: 'https://example.test/subscription', + timestamp: DateTime(2026), + ), + ); + final subscription = await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': 'Subscription node', 'protocol': 'socks'}) + .copyWith(subId: Value(source)), + ); + final raw = await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': 'Personal Raw', 'protocol': 'socks'}) + .copyWith(type: const Value('raw')), + ); + Future add(String name, int? group) async { + final id = await db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': name, 'protocol': 'socks'}), + ); + if (group != null) { + await controller.manualGroupService.move({ + id, + }, targetGroupId: group); + } + return id; + } + + return ( + home: home, + singleton: singleton, + empty: empty, + ungrouped: [ + await add('Ungrouped A', null), + await add('Ungrouped B', null), + ], + grouped: [await add('Home A', home), await add('Home B', home)], + single: await add('Single A', singleton), + untouched: [ + (await db.coreConfigDao.searchRow(subscription))!, + (await db.coreConfigDao.searchRow(raw))!, + ], + ); + }); + await settleUntil( + tester, + () => + controller.servers.length == 6 && + controller.rawConfigurations.length == 1 && + controller.catalog.manualGroups.length == 3, + ); + await pump(tester); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + Future chooseScope() async { + final entry = find.byKey(const ValueKey('manual-server-order')); + await tester.ensureVisible(entry); + await settle(tester); + await tester.tap(entry); + await settle(tester); + expect(find.byType(ManualServerOrderScopeDialog), findsOneWidget); + expect(find.byType(ManualOrderPage), findsNothing); + } + + await chooseScope(); + expect(find.text('Subscription'), findsNothing); + expect(find.text('Personal Raw'), findsNothing); + final ungrouped = find.byKey( + const ValueKey('manual-order-scope-manual:ungrouped'), + ); + final home = find.byKey( + ValueKey('manual-order-scope-manual:${fixture.home}'), + ); + expect(tester.widget(ungrouped).enabled, isTrue); + expect(tester.widget(home).enabled, isTrue); + expect( + tester + .widget( + find.byKey( + ValueKey('manual-order-scope-manual:${fixture.singleton}'), + ), + ) + .enabled, + isFalse, + ); + expect( + tester + .widget( + find.byKey( + ValueKey('manual-order-scope-manual:${fixture.empty}'), + ), + ) + .enabled, + isFalse, + ); + await tester.tap(home); + await settle(tester); + expect( + tester + .widget(find.byType(ManualOrderPage)) + .params + .items + .map((item) => item.id), + fixture.grouped, + ); + await tester.tap(find.byKey(ValueKey('move-up-${fixture.grouped.last}'))); + await settle(tester); + await tester.tap(find.text(l.prototypeSave)); + await settleUntil( + tester, + () => + controller.servers + .firstWhere((row) => row.id == fixture.grouped.last) + .manualSortOrder == + 0, + ); + await settle(tester); + final rows = await databaseOperation( + tester, + () => db.coreConfigDao.allActiveServerRows(), + ); + final byId = {for (final row in rows) row.id: row}; + expect(byId[fixture.grouped.first]!.manualSortOrder, 1); + expect(byId[fixture.grouped.last]!.manualSortOrder, 0); + expect(byId[fixture.ungrouped.first]!.manualSortOrder, 0); + expect(byId[fixture.ungrouped.last]!.manualSortOrder, 1); + expect(byId[fixture.single]!.manualSortOrder, 0); + final untouched = await databaseOperation( + tester, + () async => [ + for (final row in fixture.untouched) + (await db.coreConfigDao.searchRow(row.id))!, + ], + ); + expect(untouched, fixture.untouched); + expect( + rows + .where((row) => fixture.grouped.contains(row.id)) + .map((row) => row.manualGroupId), + everyElement(fixture.home), + ); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'mobile subscriptions stay reachable independently of a long group list', (tester) async { @@ -243,7 +501,8 @@ void main() { await settle(tester); expect(find.byType(ServerManualGroupsPage), findsOneWidget); expect(find.text('Empty folder'), findsOneWidget); - expect(find.text(l.manualGroupsDescription), findsOneWidget); + expect(find.widgetWithText(AppBar, l.manualGroupsTitle), findsOneWidget); + expect(find.text(l.manualGroupsDescription), findsNothing); expect(find.text(l.manualGroupsUngroupedFirst), findsOneWidget); expect( find.byWidgetPredicate( @@ -423,11 +682,8 @@ void main() { final l = AppLocalizations.of(tester.element(find.byType(ServersPage)))!; final add = find.byKey(const ValueKey('servers-add')); expect(add.hitTestable(), findsOneWidget); - expect(tester.widget(add).onPressed, isNotNull); - expect( - find.descendant(of: add, matching: find.text(l.prototypeAdd)), - findsOneWidget, - ); + expect(tester.widget(add).onPressed, isNotNull); + expect(tester.widget(add).tooltip, l.prototypeAddServer); expect(find.byTooltip(l.manualGroupNew), findsNothing); expect( tester.getSize(find.text(l.prototypeServers)).width, diff --git a/test/pages/servers/order_view_test.dart b/test/pages/servers/order_view_test.dart index 7443341cf..01ca522cd 100644 --- a/test/pages/servers/order_view_test.dart +++ b/test/pages/servers/order_view_test.dart @@ -105,6 +105,43 @@ void main() { }); } + testWidgets( + 'node arranging explains display order without changing its draft', + (tester) async { + const items = [ManualOrderItem(1, 'First'), ManualOrderItem(2, 'Second')]; + const hint = + 'Saved positions are shown by Default; Name and Latency stay unchanged.'; + var saves = 0; + await open( + tester, + ManualOrderParams( + title: 'Arrange node order', + description: hint, + items: items, + reload: () async => items, + save: (_, _) async { + saves++; + }, + ), + ); + expect(find.text(hint), findsOneWidget); + expect( + tester + .element(find.byType(SliverReorderableList)) + .read() + .state + .items + .map((item) => item.id), + [1, 2], + ); + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(saves, 0); + expect(find.byType(ManualOrderPage), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Cancel discards accessible position changes', (tester) async { final saved = >[]; const items = [ManualOrderItem(1, 'First'), ManualOrderItem(2, 'Second')]; diff --git a/test/pages/servers/view_test.dart b/test/pages/servers/view_test.dart index 3249f42e1..ae3043e39 100644 --- a/test/pages/servers/view_test.dart +++ b/test/pages/servers/view_test.dart @@ -36,7 +36,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc // ignore: depend_on_referenced_packages import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shadcn_ui/shadcn_ui.dart' - show ShadTheme, ShadToaster, ShadToast; + show ShadTheme, ShadToaster, ShadToast, ShadOption; import 'fixtures/server_test_router.dart'; @@ -313,6 +313,93 @@ void main() { }, ); + for (final width in [390.0, 1032.0, 1160.0]) { + testWidgets( + 'manual ordering is reachable only for whole scopes at $width', + (tester) async { + controller.sources = [ + SubscriptionData( + panel: 'generic', + mode: 'appManaged', + id: 8, + name: 'Ordinary subscription', + url: 'https://example.test/ordinary', + hwidEnabled: false, + timestamp: DateTime(2026), + ), + ]; + controller.servers = [ + _server(1, favorite: true), + _server(2), + _server(3).copyWith(subId: 8), + ]; + await pumpBrowser(tester, width); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + final entry = find.byKey(const ValueKey('manual-server-order')); + final groups = find.text(l.manualGroupsTitle); + final selection = controller.configuration.toJson(); + final ids = controller.servers.map((row) => row.id).toList(); + expect(entry, findsOneWidget); + expect(tester.widget(entry).onPressed, isNotNull); + expect(groups, findsOneWidget); + + Future source(int current, int next) async { + final filter = find.byKey( + ValueKey(('server-source-filter', current)), + ); + await tester.ensureVisible(filter); + await tester.pumpAndSettle(); + await tester.tap(filter); + await tester.pumpAndSettle(); + await tester.tap( + find.byWidgetPredicate( + (widget) => widget is ShadOption && widget.value == next, + ), + ); + await tester.pumpAndSettle(); + } + + await source(-1, 0); + expect(entry, findsOneWidget); + expect(groups, findsOneWidget); + expect(tester.widget(entry).onPressed, isNotNull); + + await tester.enterText(find.byType(TextField).first, 'Node 1'); + await tester.pumpAndSettle(); + expect(entry, findsNothing); + expect(groups, findsNothing); + await tester.tap(find.byKey(const ValueKey('server-search-clear'))); + await tester.pumpAndSettle(); + expect(entry, findsOneWidget); + + await tester.tap(find.byTooltip(l.prototypeFavorites)); + await tester.pumpAndSettle(); + expect(entry, findsNothing); + expect(groups, findsNothing); + await tester.tap(find.byTooltip(l.prototypeFavorites)); + await tester.pumpAndSettle(); + expect(entry, findsOneWidget); + + await source(0, 8); + expect(entry, findsNothing); + expect(groups, findsNothing); + await source(8, -1); + expect(entry, findsOneWidget); + await tester.tap(find.text(l.serverCompleteConfigurationsTab).first); + await tester.pumpAndSettle(); + expect(entry, findsNothing); + expect(groups, findsNothing); + expect(controller.servers.map((row) => row.id), ids); + expect(controller.configuration.toJson(), selection); + expect(controller.catalog.sortOrder, ServerSortOrder.defaultOrder); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( 'empty search can be cleared without changing selection or order', (tester) async { From 7cf7f52c5a7ae561edd826e2ace9ef5709e66a5c Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 16:34:27 +0800 Subject: [PATCH 36/60] fix(ui): preserve contextual help with standard app bars --- docs/app.md | 19 +- docs/validation.md | 10 + lib/pages/advanced/xray/lan_proxy/page.dart | 7 +- lib/pages/connect/routing/custom/page.dart | 8 +- lib/pages/servers/editor/page.dart | 11 +- lib/pages/servers/import/page.dart | 20 +- lib/pages/shared/widgets/page_app_bar.dart | 4 +- test/pages/advanced/xray/dialing_test.dart | 3 +- .../advanced/xray/geodata/add_page_test.dart | 6 +- test/pages/advanced/xray/lan_proxy_test.dart | 7 + .../pages/advanced/xray/settings_ui_test.dart | 11 +- .../routing/custom_page_parity_test.dart | 10 +- test/pages/main/page_app_bar_test.dart | 405 +++++++++--------- test/pages/servers/editor_dismissal_test.dart | 11 + test/pages/servers/import_test.dart | 15 +- test/pages/settings/backup_test.dart | 8 +- 16 files changed, 328 insertions(+), 227 deletions(-) diff --git a/docs/app.md b/docs/app.md index 3cce3c340..2b2204270 100644 --- a/docs/app.md +++ b/docs/app.md @@ -31,10 +31,10 @@ 颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。 App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。 -业务页使用公共顶部工具栏及页标题区:手机显示品牌,宽屏显示当前工作区路径; -页标题为手机24、宽屏26,副文14。主要页面动作在标题旁,窄屏自然下排; -手机服务器主页使用紧凑动作与标题同行,保留长标题和辅助字号的可达性。 -长资源名和大字号下标题区可独立滚动,保留正文和固定操作区的可用空间,不缩小字号或截断资源身份。 +页面通过共享 `PageAppBar` 使用标准 Flutter `AppBar`:页面标题、返回及页面操作位于同一工具栏, +样式由公共主题管理,不另设工具栏下方的固定页标题区。AppBar 不固定展示 logo、App 名称或工作区路径, +不提供主题快捷按钮;主题在设置中选择。手机服务器主页使用紧凑图标动作, +长标题和系统大字号下仍保留返回、设置及主要动作的可达性,不通过缩小文字适配。 字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 @@ -163,8 +163,11 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 搜索非空时提供清除按钮,零匹配使用通用结果提示并提供清除搜索操作,不改写排序或收藏偏好。 订阅标签位于列表上方,来源管理不依赖滚动到节点末尾;空手动组不影响来源入口可用。 自定义分组和组内节点分别进入整理页面,手机长按手柄、桌面拖动手柄,也可用上下移动按钮。 -整理使用独立草稿,保存后提交,取消或返回不写入;搜索期间不可整理,不支持跨组拖放。 +主节点列表在搜索为空、未启用收藏筛选且来源为全部来源或手动添加时提供“整理节点顺序”; +只有一个非空手动组时直接进入该组,多个非空组先选择完整分组范围,空组、单节点组及忙碌组不可整理。 +订阅来源筛选不提供手动整理。整理使用独立草稿,保存后提交,取消或返回不写入;不支持跨组拖放。 整理页使用手动顺序,退出保留原显示排序偏好;默认排序展示保存的位置。成员变化时需重新加载草稿,测速和收藏更新不打断整理。 +名称或延迟排序不阻止进入整理,也不因保存位置而改写为默认排序;整理始终针对完整组,不将筛选结果写为组顺序。 显示排序不改变连接候选规则,未测或测速失败不禁用节点与最终出口选择;具体比较与刷新边界见 [数据管理](data-management.md#数据显示顺序)及 [普通配置编译](xray-configuration.md#普通配置编译)。 普通节点显示 type=outbound、协议/传输/安全和延迟标签;成功延迟 ≤500 ms 为快、≤1000 ms 为慢,其余为可用。 @@ -282,6 +285,12 @@ Windows/Linux 网卡不默认选第一张,必须存在;其他平台隐藏。 “进入首页”将地区与网卡一起保存,成功才写完成标记;失败保留草稿。 已完成标记只决定入口,不跳过已进入 Setup 的必要检查。 +Welcome、准备配置、隐私说明和选择子页均为完整页面,使用标准 AppBar、顶部开始的单列正文与固定底部操作, +正文在短屏或大字号下独立滚动,不以居中卡片或弹窗承载初始化主流程。 +Setup 根页不直接退出;配置页的返回回到 Welcome,不撤销已保存的隐私同意。 +隐私页返回不代表同意,地区选择取消不改父稿,网卡选择只先返回父稿;错误保留在当前页并提供适用的重试。 +App 页面不替代系统 VPN、局域网或系统扩展授权面板。 + 隐私/Setup 和冷启动快捷操作抑制本次偏好自动连接,并显示所需界面,不改写偏好。 自动连接只执行一次,经同一连接协调器处理;初始化、托盘刷新或自动连接失败不被隐藏启动遮蔽。 diff --git a/docs/validation.md b/docs/validation.md index e61562321..5dba102df 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -41,6 +41,13 @@ 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 +- 页面标题、返回及页面操作位于共享标准 Flutter AppBar,不固定展示 logo、App 名称或工作区路径,也不在下方添加重复页标题区。 + AppBar 没有主题快捷按钮,主题仍可由设置修改;检查长标题、窄宽度和大字号下返回与主要操作可达。 +- Welcome、准备配置、隐私和选择子页检查完整页面布局:正文上对齐单列滚动,底部动作独立固定, + 短屏、键盘、大字号及 768 宽 RTL 下不以居中卡片或弹窗承载主流程,操作不随正文滚走。 + 隐私需明确同意,配置返回不撤销同意;地区建议不直接入库,跳过、清空和子页取消分别保持原草稿语义。 + Windows/Linux 必选有效网卡,保存失败保留地区与网卡草稿,成功保存后才写完成标记。 + 本地准备失败可在页内重试;Setup 不导入节点、不测速、不启动 VPN,系统授权面板仍按平台验证。 - iOS 44×44 pt、Android 48×48 dp 的实际点击区域分别检查,宽横屏仍使用对应平台下限。 点击小图标外围应触发正确动作且只执行一次;关闭、返回、确认及禁用行为不变,键盘和安全区不遮挡动作。 入口数量选择器覆盖三项边缘命中、选中/禁用、端点圆角及 RTL,桌面维持紧凑尺寸。 @@ -77,6 +84,9 @@ - 搜索清除和零结果重置恢复列表,保留排序、收藏偏好;移动端空列表、空手动组和长列表均可进入来源管理。 服务器节点/完整配置/订阅三类计数和类型准确;打开资源或来源详情仅浏览,显式“使用”才提交连接选择。 节点测速错误初始折叠,展开后可选择、复制原始诊断,后续成功移除错误,折叠不删除诊断。 + 主节点列表在无搜索、无收藏筛选且来源为全部来源或手动添加时可整理位置;订阅过滤下不提供手动整理。 + 单个非空手动组直接进入,多个非空组先选择完整范围;空组、单节点组和忙碌组不可选为整理范围。 + 名称与延迟排序下均可进入整理,草稿按手动位置加载;保存只调整所选完整组,取消或返回不写入,原显示排序偏好保持不变。 - 独立路由首页分别验证使用、编辑和删除,常规/高级共用配额;一项坏资产不阻断其他模式和资源管理。 完整图运行时保存普通路由偏好不覆盖当前图或触发无效重连。Smart 地区验证单选替换、重复选择幂等和显式清空, 候选仅取已安装索引;历史 List 不迁移,选择页投影首项,打开/取消不裁剪原值。 diff --git a/lib/pages/advanced/xray/lan_proxy/page.dart b/lib/pages/advanced/xray/lan_proxy/page.dart index f0efc5fe3..0fbcdfa51 100644 --- a/lib/pages/advanced/xray/lan_proxy/page.dart +++ b/lib/pages/advanced/xray/lan_proxy/page.dart @@ -34,7 +34,6 @@ class LanProxyPage extends StatelessWidget { : AppSpacing.advancedDesktopGutter(width); return PolicyDetailScaffold( title: l.lanProxyTitle, - description: l.lanProxyNextStartNotice, help: [ l.lanProxyProtocolHint, l.lanProxyRawJsonNotice, @@ -61,6 +60,12 @@ class LanProxyPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 20, children: [ + Text( + l.lanProxyNextStartNotice, + style: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, + ), + ), SettingSection( title: l.lanProxyTitle, icon: LucideIcons.network, diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index 4f723b5f5..8d2fca074 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -79,7 +79,6 @@ class _CustomRoutingEditorPageState extends State { ? l.routingNormalCreateTitle : l.routingNormalEditTitle, ), - description: l.routingNormalDescription, leading: BackButton(onPressed: () => controller.cancel(context)), actions: [ PageHelpButton( @@ -111,6 +110,13 @@ class _CustomRoutingEditorPageState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 20, children: [ + Text( + l.routingNormalDescription, + style: AppTypography.rowValue.copyWith( + color: ColorManager.palette(context) + .mutedForeground, + ), + ), _identity(context, mobile, state), CustomRoutingSections( rules: _rules(context, mobile, state), diff --git a/lib/pages/servers/editor/page.dart b/lib/pages/servers/editor/page.dart index 30f88e002..7b51a17c7 100644 --- a/lib/pages/servers/editor/page.dart +++ b/lib/pages/servers/editor/page.dart @@ -36,13 +36,22 @@ class ServerEditorPage extends StatelessWidget { }, child: ServerFormPage( title: l.prototypeEditServer, - subtitle: state.name, onClose: () => controller.closePage(context), body: ServerFormSection( title: l.configurationContentTitle, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + if (state.name case final name? + when name.trim().isNotEmpty) ...[ + Text( + name, + style: AppTypography.rowTitle.copyWith( + color: p.foreground, + ), + ), + const SizedBox(height: 12), + ], Text( l.prototypeXrayOutboundJson, style: AppTypography.fieldLabel.copyWith( diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 017f7ce0d..f4120b5af 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -181,9 +181,6 @@ class ServerImportFormPage extends StatelessWidget { : manual ? l10n.prototypeAddManually : l10n.prototypePasteLink, - subtitle: controller.editingSubscription - ? l10n.prototypeChangesApplyToFutureUpdates - : null, onBack: onBack, onClose: onClose ?? () => controller.closeFlow(context), body: Column( @@ -197,7 +194,22 @@ class ServerImportFormPage extends StatelessWidget { mobile ? 16 : 28, 0, ), - child: _subscription(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (controller.editingSubscription) ...[ + Text( + l10n.prototypeChangesApplyToFutureUpdates, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context) + .mutedForeground, + ), + ), + const SizedBox(height: 16), + ], + _subscription(context), + ], + ), ) else if (manual) _manual(context) diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index 06955d0e7..feadedf6f 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -132,11 +132,11 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { : pageTitle; return AppBar( title: toolbarTitle, - leading: effectiveLeading == null ? null : passThrough(effectiveLeading!), + leading: effectiveLeading == null ? null : passThrough(effectiveLeading), actions: [ for (final action in effectiveActions) passThrough(action), if (headingAction != null) passThrough(headingAction!), - if (mobileSettingsAction != null) passThrough(mobileSettingsAction!), + if (mobileSettingsAction != null) passThrough(mobileSettingsAction), ], bottom: bottom, ); diff --git a/test/pages/advanced/xray/dialing_test.dart b/test/pages/advanced/xray/dialing_test.dart index e4bc9fed0..76fec2ba4 100644 --- a/test/pages/advanced/xray/dialing_test.dart +++ b/test/pages/advanced/xray/dialing_test.dart @@ -89,7 +89,8 @@ void main() { ); expect(selectRect.left, closeTo(fieldRect.left, .5)); expect(selectRect.right, closeTo(fieldRect.right, .5)); - expect(find.text(l.pageXrayConfigurationDescription), findsOneWidget); + expect(find.widgetWithText(AppBar, l.serverDialingTitle), findsOneWidget); + expect(find.text(l.pageXrayConfigurationDescription), findsNothing); expect(find.byType(ShadSwitch), findsOneWidget); expect( tester.getSemantics(find.byType(ShadSwitch)).label, diff --git a/test/pages/advanced/xray/geodata/add_page_test.dart b/test/pages/advanced/xray/geodata/add_page_test.dart index 92d697065..5ba596e2f 100644 --- a/test/pages/advanced/xray/geodata/add_page_test.dart +++ b/test/pages/advanced/xray/geodata/add_page_test.dart @@ -83,7 +83,11 @@ void main() { controller, returned: (value) => returned = value == true, ); - expect(find.text(l.geodataAddDescription), findsOneWidget); + expect( + find.widgetWithText(AppBar, l.prototypeAddDataSource), + findsOneWidget, + ); + expect(find.text(l.geodataAddDescription), findsNothing); expect(find.text(l.geodataValidatedBeforeAdding), findsOneWidget); await _fill(tester, 'http://example.test/custom.dat'); await tester.tap(find.byKey(const ValueKey('geodata-source-add'))); diff --git a/test/pages/advanced/xray/lan_proxy_test.dart b/test/pages/advanced/xray/lan_proxy_test.dart index 9e23bdec6..7b1b2e9cf 100644 --- a/test/pages/advanced/xray/lan_proxy_test.dart +++ b/test/pages/advanced/xray/lan_proxy_test.dart @@ -81,6 +81,13 @@ void main() { await tester.pumpAndSettle(); expect(find.text(l.lanProxySecurityHint), findsOneWidget); expect(find.text(l.lanProxyNextStartNotice), findsOneWidget); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text(l.lanProxyNextStartNotice), + ), + findsNothing, + ); expect(find.text(l.prototypeSave), findsOneWidget); expect(find.text(l.prototypeSaveAndReconnect), findsNothing); expect(find.text('0.0.0.0'), findsOneWidget); diff --git a/test/pages/advanced/xray/settings_ui_test.dart b/test/pages/advanced/xray/settings_ui_test.dart index 031825e7d..5edeb4d50 100644 --- a/test/pages/advanced/xray/settings_ui_test.dart +++ b/test/pages/advanced/xray/settings_ui_test.dart @@ -286,7 +286,16 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(PageActionBar)), )!; - expect(find.text(l.pageXrayConfigurationDescription), findsOneWidget); + expect( + find.widgetWithText( + AppBar, + page is PingPage + ? l.xrayLatencyTitle + : l.prototypeAutomaticUpdates, + ), + findsOneWidget, + ); + expect(find.text(l.pageXrayConfigurationDescription), findsNothing); for (final choice in choices) { final field = find.byKey(ValueKey('$choice-field')); final control = find.byKey( diff --git a/test/pages/connect/routing/custom_page_parity_test.dart b/test/pages/connect/routing/custom_page_parity_test.dart index f9c6cb0e6..60340eba5 100644 --- a/test/pages/connect/routing/custom_page_parity_test.dart +++ b/test/pages/connect/routing/custom_page_parity_test.dart @@ -68,7 +68,15 @@ void main() { )!; final header = tester.widget(find.byType(PageAppBar)); expect((header.title as Text).data, l.routingNormalCreateTitle); - expect(header.description, l.routingNormalDescription); + expect(header.description, isNull); + expect(find.text(l.routingNormalDescription), findsOneWidget); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text(l.routingNormalDescription), + ), + findsNothing, + ); final identity = find.byKey(const ValueKey('custom-identity')); final rules = find.byKey(const ValueKey('custom-rule-list')); diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index dc99ea108..8c6ce1141 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -208,90 +208,91 @@ void main() { (tester) async { final semantics = tester.ensureSemantics(); try { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(390, 360); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - var imports = 0; - var saves = 0; - const titleText = 'Настройки подключения — Tokyo Singapore — настройки'; - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: true), - locale: locale, - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: const TextScaler.linear(2)), - child: Directionality( - textDirection: locale.languageCode == 'fa' - ? TextDirection.rtl - : TextDirection.ltr, - child: child!, - ), - ), - home: Builder( - builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: const Text(titleText), - inlineHeadingAction: true, - headingAction: IconButton( - key: const Key('inline-import'), - tooltip: 'Import', - icon: const Icon(Icons.add), - onPressed: () => imports++, - ), + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 360); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var imports = 0; + var saves = 0; + const titleText = + 'Настройки подключения — Tokyo Singapore — настройки'; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: locale, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), + child: Directionality( + textDirection: locale.languageCode == 'fa' + ? TextDirection.rtl + : TextDirection.ltr, + child: child!, ), - body: const SizedBox.expand(key: Key('inline-page-body')), - bottomNavigationBar: PageActionBar( - children: [ - FilledButton( - key: const Key('inline-save'), - onPressed: () => saves++, - child: const Text('Save'), + ), + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text(titleText), + inlineHeadingAction: true, + headingAction: IconButton( + key: const Key('inline-import'), + tooltip: 'Import', + icon: const Icon(Icons.add), + onPressed: () => imports++, ), - ], + ), + body: const SizedBox.expand(key: Key('inline-page-body')), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton( + key: const Key('inline-save'), + onPressed: () => saves++, + child: const Text('Save'), + ), + ], + ), ), ), ), - ), - ); - await tester.pumpAndSettle(); - final title = find.text(titleText); - expect(title, findsOneWidget); - expect(tester.widget(title).data, titleText); - expect( - DefaultTextStyle.of(tester.element(title)).overflow, - TextOverflow.ellipsis, - ); - expect( - find.byWidgetPredicate( - (widget) => widget is Tooltip && widget.message == titleText, - ), - findsOneWidget, - ); - expect( - tester.getSemantics(title), - containsSemantics(label: titleText, isHeader: true), - ); - expect( - tester.getSize(find.byKey(const Key('inline-page-body'))).height, - greaterThanOrEqualTo(80), - ); - final action = find.byKey(const Key('inline-import')); - expect(action.hitTestable(), findsOneWidget); - expect( - find.byKey(const Key('inline-save')).hitTestable(), - findsOneWidget, - ); - await tester.tap(action); - await tester.tap(find.byKey(const Key('inline-save'))); - await tester.pumpAndSettle(); - expect(imports, 1); - expect(saves, 1); - expect(tester.takeException(), isNull); + ); + await tester.pumpAndSettle(); + final title = find.text(titleText); + expect(title, findsOneWidget); + expect(tester.widget(title).data, titleText); + expect( + DefaultTextStyle.of(tester.element(title)).overflow, + TextOverflow.ellipsis, + ); + expect( + find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == titleText, + ), + findsOneWidget, + ); + expect( + tester.getSemantics(title), + isSemantics(label: titleText, isHeader: true), + ); + expect( + tester.getSize(find.byKey(const Key('inline-page-body'))).height, + greaterThanOrEqualTo(80), + ); + final action = find.byKey(const Key('inline-import')); + expect(action.hitTestable(), findsOneWidget); + expect( + find.byKey(const Key('inline-save')).hitTestable(), + findsOneWidget, + ); + await tester.tap(action); + await tester.tap(find.byKey(const Key('inline-save'))); + await tester.pumpAndSettle(); + expect(imports, 1); + expect(saves, 1); + expect(tester.takeException(), isNull); } finally { semantics.dispose(); } @@ -305,134 +306,136 @@ void main() { (tester) async { final semantics = tester.ensureSemantics(); try { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(390, height); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - final fileName = '${List.filled(176, 'a').join()}.dat'; - expect(fileName.length, 180); - expect(GeoDataInput.canonicalFileName(fileName), fileName); - final controller = TextEditingController(); - addTearDown(controller.dispose); - var headingActions = 0; - var bodyActions = 0; - var saves = 0; - var backs = 0; - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: true), - locale: const Locale('en'), - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context) - .copyWith(textScaler: const TextScaler.linear(2)), - child: ShadTheme( - data: AppTheme.shad(Brightness.light, mobile: true), - child: child!, - ), - ), - home: Builder( - builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(fileName), - description: 'Resource source and update settings.', - leading: BackButton(onPressed: () => backs++), - headingAction: IconButton( - key: const Key('resource-heading-action'), - tooltip: 'Update', - icon: const Icon(Icons.refresh), - onPressed: () => headingActions++, - ), - ), - body: ListView( - key: const Key('resource-body'), - padding: const EdgeInsets.all(16), - children: [ - TextField( - key: const Key('resource-source'), - controller: controller, - decoration: const InputDecoration(labelText: 'Source'), - ), - const SizedBox(height: 160), - TextButton( - key: const Key('resource-body-action'), - onPressed: () => bodyActions++, - child: const Text('Details'), - ), - ], + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = Size(390, height); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final fileName = '${List.filled(176, 'a').join()}.dat'; + expect(fileName.length, 180); + expect(GeoDataInput.canonicalFileName(fileName), fileName); + final controller = TextEditingController(); + addTearDown(controller.dispose); + var headingActions = 0; + var bodyActions = 0; + var saves = 0; + var backs = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: const TextScaler.linear(2)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: child!, ), - bottomNavigationBar: PageActionBar( - children: [ - FilledButton( - key: const Key('resource-save'), - onPressed: () => saves++, - child: const Text('Save'), + ), + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: Text(fileName), + description: 'Resource source and update settings.', + leading: BackButton(onPressed: () => backs++), + headingAction: IconButton( + key: const Key('resource-heading-action'), + tooltip: 'Update', + icon: const Icon(Icons.refresh), + onPressed: () => headingActions++, ), - ], + ), + body: ListView( + key: const Key('resource-body'), + padding: const EdgeInsets.all(16), + children: [ + TextField( + key: const Key('resource-source'), + controller: controller, + decoration: const InputDecoration(labelText: 'Source'), + ), + const SizedBox(height: 160), + TextButton( + key: const Key('resource-body-action'), + onPressed: () => bodyActions++, + child: const Text('Details'), + ), + ], + ), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton( + key: const Key('resource-save'), + onPressed: () => saves++, + child: const Text('Save'), + ), + ], + ), ), ), ), - ), - ); - await tester.pumpAndSettle(); - final title = find.text(fileName); - expect(title, findsOneWidget); - expect(tester.widget(title).data, fileName); - expect( - find.byWidgetPredicate( - (widget) => widget is Tooltip && widget.message == fileName, - ), - findsOneWidget, - ); - expect( - tester.getSemantics(title), - containsSemantics(label: fileName, isHeader: true), - ); - expect( - DefaultTextStyle.of(tester.element(title)).overflow, - TextOverflow.ellipsis, - ); - final body = find.byKey(const Key('resource-body')); - expect(tester.getSize(body).height, greaterThanOrEqualTo(80)); - final footer = tester.getRect(find.byType(PageActionBar)); - expect(footer.top, greaterThanOrEqualTo(tester.getRect(body).bottom)); - expect(footer.bottom, lessThanOrEqualTo(height)); - final headingAction = find.byKey(const Key('resource-heading-action')); - expect(headingAction.hitTestable(), findsOneWidget); - await tester.tap(headingAction); - await tester.pumpAndSettle(); - expect(headingActions, 1); + ); + await tester.pumpAndSettle(); + final title = find.text(fileName); + expect(title, findsOneWidget); + expect(tester.widget(title).data, fileName); + expect( + find.byWidgetPredicate( + (widget) => widget is Tooltip && widget.message == fileName, + ), + findsOneWidget, + ); + expect( + tester.getSemantics(title), + isSemantics(label: fileName, isHeader: true), + ); + expect( + DefaultTextStyle.of(tester.element(title)).overflow, + TextOverflow.ellipsis, + ); + final body = find.byKey(const Key('resource-body')); + expect(tester.getSize(body).height, greaterThanOrEqualTo(80)); + final footer = tester.getRect(find.byType(PageActionBar)); + expect(footer.top, greaterThanOrEqualTo(tester.getRect(body).bottom)); + expect(footer.bottom, lessThanOrEqualTo(height)); + final headingAction = find.byKey( + const Key('resource-heading-action'), + ); + expect(headingAction.hitTestable(), findsOneWidget); + await tester.tap(headingAction); + await tester.pumpAndSettle(); + expect(headingActions, 1); - final source = find.byKey(const Key('resource-source')); - await tester.ensureVisible(source); - await tester.pumpAndSettle(); - await tester.enterText(source, 'https://example.test/resource.dat'); - await tester.pumpAndSettle(); - final bodyScrollable = find.descendant( - of: body, - matching: find.byWidgetPredicate( - (widget) => - widget is Scrollable && - widget.axisDirection == AxisDirection.down, - ), - ); - await tester.scrollUntilVisible( - find.byKey(const Key('resource-body-action')), - 100, - scrollable: bodyScrollable, - ); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const Key('resource-body-action'))); - await tester.tap(find.byKey(const Key('resource-save'))); - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - expect(bodyActions, 1); - expect(saves, 1); - expect(backs, 1); - expect(controller.text, 'https://example.test/resource.dat'); - expect(tester.takeException(), isNull); + final source = find.byKey(const Key('resource-source')); + await tester.ensureVisible(source); + await tester.pumpAndSettle(); + await tester.enterText(source, 'https://example.test/resource.dat'); + await tester.pumpAndSettle(); + final bodyScrollable = find.descendant( + of: body, + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + widget.axisDirection == AxisDirection.down, + ), + ); + await tester.scrollUntilVisible( + find.byKey(const Key('resource-body-action')), + 100, + scrollable: bodyScrollable, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('resource-body-action'))); + await tester.tap(find.byKey(const Key('resource-save'))); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(bodyActions, 1); + expect(saves, 1); + expect(backs, 1); + expect(controller.text, 'https://example.test/resource.dat'); + expect(tester.takeException(), isNull); } finally { semantics.dispose(); } diff --git a/test/pages/servers/editor_dismissal_test.dart b/test/pages/servers/editor_dismissal_test.dart index ac29f38c5..77a1ede6a 100644 --- a/test/pages/servers/editor_dismissal_test.dart +++ b/test/pages/servers/editor_dismissal_test.dart @@ -138,6 +138,17 @@ void main() { final editor = find.byType(OutboundJsonEditor); final card = find.ancestor(of: editor, matching: find.byType(ShadCard)); expect(card, findsOneWidget); + expect( + find.descendant(of: card, matching: find.text(_original.name)), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text(_original.name), + ), + findsNothing, + ); expect( find.descendant( of: card, diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index 4ac5601c7..2b9296942 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -50,7 +50,11 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(ServersImportPage)), )!; - expect(find.text(l.serverImportPageDescription), findsOneWidget); + expect( + find.widgetWithText(AppBar, l.prototypeAddServersToOneXray), + findsOneWidget, + ); + expect(find.text(l.serverImportPageDescription), findsNothing); expect(find.byType(ShadCard), findsOneWidget); final card = find.byType(ShadCard); for (final action in [ @@ -1373,6 +1377,15 @@ void main() { find.text('Changes apply to future updates, not the running connection.'), findsOneWidget, ); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text( + 'Changes apply to future updates, not the running connection.', + ), + ), + findsNothing, + ); expect(controller.secretKey.text, 'secret'); expect(controller.publicKey.text, 'public'); expect(controller.userAgent.text, 'Example/old'); diff --git a/test/pages/settings/backup_test.dart b/test/pages/settings/backup_test.dart index 438fa2cd8..7fd0669eb 100644 --- a/test/pages/settings/backup_test.dart +++ b/test/pages/settings/backup_test.dart @@ -408,13 +408,7 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(PageActionBar)), )!; - expect( - find.descendant( - of: find.byKey(const ValueKey('page-heading')), - matching: find.text(l.backupTitle), - ), - findsOneWidget, - ); + expect(find.widgetWithText(AppBar, l.backupTitle), findsOneWidget); expect(find.text(l.backupAutomatic), findsOneWidget); expect(storage.writes, 0); final sections = find.byType(SettingSection); From 48f56db0c9a8f4a90f2dc007aaa41ad66572dca6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 17:06:46 +0800 Subject: [PATCH 37/60] fix(theme): align wide page chrome with the page background --- lib/pages/shared/widgets/page_action_bar.dart | 4 +- lib/pages/theme/theme.dart | 28 ++----------- .../shared/widgets/page_action_bar_test.dart | 14 +++++++ test/pages/theme/theme_test.dart | 39 +++++++------------ 4 files changed, 35 insertions(+), 50 deletions(-) diff --git a/lib/pages/shared/widgets/page_action_bar.dart b/lib/pages/shared/widgets/page_action_bar.dart index e067f04a0..17bb41bb7 100644 --- a/lib/pages/shared/widgets/page_action_bar.dart +++ b/lib/pages/shared/widgets/page_action_bar.dart @@ -37,7 +37,9 @@ class PageActionBar extends StatelessWidget { return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: Material( - color: Theme.of(context).colorScheme.surface, + color: mobile + ? Theme.of(context).colorScheme.surface + : Theme.of(context).scaffoldBackgroundColor, child: DecoratedBox( decoration: BoxDecoration( border: Border( diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index 9e910f60f..31bb59225 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -30,6 +30,10 @@ abstract final class AppTheme { required Brightness brightness, required bool mobile, }) => AppBarTheme( + backgroundColor: mobile ? null : palette.background, + surfaceTintColor: mobile ? null : Colors.transparent, + elevation: mobile ? null : 0, + scrolledUnderElevation: mobile ? null : 0, // The App uses a compact custom titleLarge elsewhere. Keep that global // scale unchanged and use Material's standard AppBar typography here. titleTextStyle: Typography.englishLike2021.titleLarge!.copyWith( @@ -746,30 +750,6 @@ abstract final class AppTheme { palette.accent.withValues(alpha: 0.72), ), ), - navigationBarTheme: NavigationBarThemeData( - backgroundColor: palette.card.withValues(alpha: 0.96), - elevation: 0, - surfaceTintColor: Colors.transparent, - indicatorColor: Colors.transparent, - overlayColor: const WidgetStatePropertyAll(Colors.transparent), - iconTheme: WidgetStateProperty.resolveWith( - (states) => IconThemeData( - color: states.contains(WidgetState.selected) - ? palette.sidebarPrimary - : palette.mutedStrong, - ), - ), - labelTextStyle: WidgetStateProperty.resolveWith( - (states) => TextStyle( - color: states.contains(WidgetState.selected) - ? palette.primary - : palette.mutedStrong, - fontWeight: states.contains(WidgetState.selected) - ? FontWeight.w600 - : FontWeight.w400, - ), - ), - ), navigationRailTheme: NavigationRailThemeData( backgroundColor: palette.sidebar, indicatorColor: palette.selectedSurface, diff --git a/test/pages/shared/widgets/page_action_bar_test.dart b/test/pages/shared/widgets/page_action_bar_test.dart index 28f2a26bf..816dd4c3a 100644 --- a/test/pages/shared/widgets/page_action_bar_test.dart +++ b/test/pages/shared/widgets/page_action_bar_test.dart @@ -171,6 +171,20 @@ void main() { await tester.pumpAndSettle(); final cancel = tester.getRect(find.byKey(_cancel)); final save = tester.getRect(find.byKey(_save)); + final footerSurface = tester.widget( + find + .descendant( + of: find.byType(PageActionBar), + matching: find.byType(Material), + ) + .first, + ); + expect( + footerSurface.color, + Theme.of(tester.element(find.byType(PageActionBar))) + .scaffoldBackgroundColor, + ); + expect(footerSurface.borderRadius, isNull); for (final key in [_cancel, _save]) { final bounds = tester.getRect(find.byKey(key)); final label = tester.getRect( diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index a90646ab9..b95d856e0 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -25,7 +25,7 @@ void main() { expect(material.iconTheme.color, palette.foreground); expect(material.colorScheme.error, palette.destructive); expect(material.colorScheme.outline, palette.border); - expect(material.appBarTheme.backgroundColor, isNull); + expect(material.appBarTheme.backgroundColor, palette.background); expect(material.appBarTheme.foregroundColor, isNull); expect(material.appBarTheme.toolbarHeight, isNull); expect(material.appBarTheme.centerTitle, isNull); @@ -42,9 +42,9 @@ void main() { expect(material.appBarTheme.titleTextStyle?.color, palette.foreground); expect(material.appBarTheme.titleSpacing, isNull); expect(material.appBarTheme.actionsPadding, isNull); - expect(material.appBarTheme.elevation, isNull); - expect(material.appBarTheme.scrolledUnderElevation, isNull); - expect(material.appBarTheme.surfaceTintColor, isNull); + expect(material.appBarTheme.elevation, 0); + expect(material.appBarTheme.scrolledUnderElevation, 0); + expect(material.appBarTheme.surfaceTintColor, Colors.transparent); expect(material.appBarTheme.iconTheme, isNull); expect(material.appBarTheme.actionsIconTheme, isNull); expect(material.appBarTheme.systemOverlayStyle, isNull); @@ -79,10 +79,11 @@ void main() { expect(material.colorScheme.surface, palette.card); expect(material.iconTheme.color, palette.foreground); expect(material.dividerTheme.color, palette.border); - expect( - material.navigationBarTheme.backgroundColor, - palette.card.withValues(alpha: 0.96), - ); + expect(material.navigationBarTheme, const NavigationBarThemeData()); + expect(material.appBarTheme.backgroundColor, palette.background); + expect(material.appBarTheme.surfaceTintColor, Colors.transparent); + expect(material.appBarTheme.elevation, 0); + expect(material.appBarTheme.scrolledUnderElevation, 0); expect(material.appBarTheme.systemOverlayStyle, isNull); expect(material.appBarTheme.titleTextStyle?.color, palette.foreground); @@ -232,23 +233,11 @@ void main() { expect(material.appBarTheme.toolbarHeight, isNull); expect(material.appBarTheme.titleSpacing, isNull); expect(material.appBarTheme.actionsPadding, isNull); - final navigation = material.navigationBarTheme; - expect(navigation.height, isNull); - expect(navigation.indicatorColor, Colors.transparent); - expect(navigation.iconTheme!.resolve({})!.size, isNull); - expect( - navigation.iconTheme!.resolve({})!.color, - AppPalette.light.mutedStrong, - ); - expect(navigation.labelTextStyle!.resolve({})!.fontSize, isNull); - expect( - navigation.labelTextStyle!.resolve({})!.fontWeight, - FontWeight.w400, - ); - expect( - navigation.labelTextStyle!.resolve({WidgetState.selected})!.fontWeight, - FontWeight.w600, - ); + expect(material.appBarTheme.backgroundColor, isNull); + expect(material.appBarTheme.surfaceTintColor, isNull); + expect(material.appBarTheme.elevation, isNull); + expect(material.appBarTheme.scrolledUnderElevation, isNull); + expect(material.navigationBarTheme, const NavigationBarThemeData()); expect( material.filledButtonTheme.style!.minimumSize!.resolve({}), Size.square(AppLayout.touchExtent(AppLayout.mobileButtonMinHeight)), From b90908a28c060b282bcdaf6fb891a58637f9df43 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 17:12:40 +0800 Subject: [PATCH 38/60] refactor(navigation): use the standard mobile bar and remove wide page framing --- docs/app.md | 10 +- docs/validation.md | 6 + lib/pages/main/adaptive_shell.dart | 112 +++-------------- test/pages/main/adaptive_shell_test.dart | 154 ++++++++++++++--------- test/pages/main/page_app_bar_test.dart | 115 +++++++++++++---- 5 files changed, 215 insertions(+), 182 deletions(-) diff --git a/docs/app.md b/docs/app.md index 2b2204270..e792449ff 100644 --- a/docs/app.md +++ b/docs/app.md @@ -6,7 +6,8 @@ ## 导航与交互 - 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。 - 手机根页使用内缩的圆角卡片底栏,包含前五项入口,选中项为蓝底白字;设置由顶部按钮进入。 + 手机根页使用 Flutter 默认 Material 3 `NavigationBar` 与 `NavigationDestination`,包含前五项入口;设置由顶部按钮进入。 + 底栏不另包圆角悬浮卡片,不自定义高度、指示器、标签或图标样式,选中状态沿用 Material 3 默认表现。 设置页和二级页面隐藏底部根导航。平板和桌面宽布局保留带文字的左侧导航, 连接、服务器和路由归入“工作区”,VPN 隧道和 Xray 归入“系统与内核”,设置位于侧栏底部; 不再提供“高级”的二级 Tab 容器。侧栏选中项使用无边框卡片底色与强调色文字。 @@ -18,6 +19,7 @@ 服务器、规则和资源列表进入独立全宽详情;同一会话的并列统计值与紧凑动作不构成第二内容面。 - 宽布局先避让横向安全区,再分配侧栏内容宽度。侧栏高度不足时可滚动访问全部入口与更新提醒, 品牌保持单行;横竖屏切换不重置当前页面或各 Tab 的导航栈。 + 业务区直接填充侧栏旁的剩余空间,不附加整区圆角裁切、装饰外框或外层留白;页面自身内容边距仍由页面管理。 - 完整页面的共享 `PageActionBar` 固定在底部,保留安全区、键盘避让和动作换行;弹窗保留自己的操作区。 宽屏按钮按文字自然取宽,macOS 最低42、iPad最低44;手机动作等宽且最低48。 手机草稿页显示取消与保存或完成,已有高级模板的删除独立一行,沿用原有退出及删除确认。 @@ -35,7 +37,9 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 样式由公共主题管理,不另设工具栏下方的固定页标题区。AppBar 不固定展示 logo、App 名称或工作区路径, 不提供主题快捷按钮;主题在设置中选择。手机服务器主页使用紧凑图标动作, 长标题和系统大字号下仍保留返回、设置及主要动作的可达性,不通过缩小文字适配。 -字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 +平板与桌面宽布局的共享 AppBar、`PageActionBar` 背景均与 `Scaffold` 背景一致,滚动时不额外着色或增加抬升效果; +手机 AppBar 沿用 Material 默认表现。业务卡片和弹窗继续使用各自的背景、边框与圆角。 +字号为整数,正文和一般辅助文字不小于 12;侧栏分组名使用共享的 11px 导航角色,手机底栏标签遵循 Material 3 默认排版。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 @@ -47,7 +51,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 `ResponsiveContent` 只限制内容宽度,不叠加页面内边距;公共按钮保留系统文字缩放。 `SectionView` 的标题、默认位置的说明与正文共用同一张圆角卡片,不在标题与正文间绘制通栏分隔线; `descriptionBelow` 指定的说明仍位于卡片外。`SettingSection` 默认不绘制行间分隔线,需要时显式启用 `separated`。 -导航和卡片共用现有字体家族与文字角色;导航辅助标签使用专属角色,不改写正文等已有角色。 +侧栏和卡片共用现有字体家族与文字角色;侧栏辅助标签使用专属角色,不改写正文等已有角色。 共享触控下限按平台应用:iOS 为 44×44 pt,Android 为 48×48 dp,不依赖窗口宽度。 共享开关保留紧凑轨道,以实际点击层补足触控下限;轨道及外围点击均只回调一次,桌面不扩大轨道。 弹窗关闭、返回及设置动作可保留较小的视觉图标,通过实际点击区域满足下限;横屏不切换为桌面触控尺寸。 diff --git a/docs/validation.md b/docs/validation.md index 5dba102df..9f9e41071 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -39,10 +39,16 @@ - 代表性手机、平板和桌面窗口分别核对连接/服务器/路由/隧道/Xray/设置六个目的地。 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。 + 手机底栏核对默认 Material 3 `NavigationBar` / `NavigationDestination` 的标签、选中语义和分支切换, + 不添加悬浮圆角卡片,也不设置底栏高度、指示器或标签/图标样式;分支栈与详情隐藏规则保持不变。 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 + 平板与桌面宽布局业务区直接填满侧栏旁的剩余空间,不保留整区圆角裁切、装饰外框或外层留白; + 页面内容边距、单栏、横向安全区、macOS 标题栏和 RTL 原生按钮避让仍须正确。 - 页面标题、返回及页面操作位于共享标准 Flutter AppBar,不固定展示 logo、App 名称或工作区路径,也不在下方添加重复页标题区。 AppBar 没有主题快捷按钮,主题仍可由设置修改;检查长标题、窄宽度和大字号下返回与主要操作可达。 + 平板与桌面宽布局在明暗主题下分别检查 AppBar、`PageActionBar` 与 `Scaffold` 背景一致, + 滚动前后没有额外表面着色或抬升效果;手机 AppBar 和根导航保持 Material 默认表现,业务卡片与弹窗样式保持原合同。 - Welcome、准备配置、隐私和选择子页检查完整页面布局:正文上对齐单列滚动,底部动作独立固定, 短屏、键盘、大字号及 768 宽 RTL 下不以居中卡片或弹窗承载主流程,操作不随正文滚走。 隐私需明确同意,配置返回不撤销同意;地区建议不直接入库,跳过、清空和子页取消分别保持原草稿语义。 diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index 0f4c038ce..695820ff3 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -266,7 +266,9 @@ class _AdaptiveMainShellState extends State { } Widget _bottomNavigationScaffold(BuildContext context) { - final palette = ColorManager.palette(context); + final destinations = AppPrimaryDestination.values + .where((primary) => primary != AppPrimaryDestination.settings) + .toList(growable: false); return Scaffold( body: navigationShell, bottomNavigationBar: @@ -282,94 +284,20 @@ class _AdaptiveMainShellState extends State { ? null : navigationShell.currentIndex == AppPrimaryDestination.settings.index ? null - : SafeArea( - top: false, - child: Container( - key: const ValueKey('primary-mobile-navigation'), - margin: const EdgeInsets.fromLTRB(12, 0, 12, 10), - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: palette.card, - borderRadius: BorderRadius.circular(22), - ), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final primary in AppPrimaryDestination.values.where( - (p) => p != AppPrimaryDestination.settings, - )) - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: _mobileDestination(context, primary), - ), - ), - ], + : NavigationBar( + key: const ValueKey('primary-mobile-navigation'), + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) => + context.goPrimary(navigationShell, destinations[index]), + destinations: [ + for (final primary in destinations) + NavigationDestination( + key: ValueKey('primary-navigation-${primary.name}'), + icon: Icon(_icon(primary)), + label: _label(context, primary), ), - ), - ), - ), - ); - } - - Widget _mobileDestination( - BuildContext context, - AppPrimaryDestination primary, - ) { - final palette = ColorManager.palette(context); - final selected = primary.index == navigationShell.currentIndex; - final color = selected - ? palette.primaryForeground - : palette.mutedForeground; - return Semantics( - key: ValueKey('primary-navigation-${primary.name}'), - label: _label(context, primary), - selected: selected, - button: true, - child: Material( - color: selected ? palette.primarySolid : Colors.transparent, - borderRadius: BorderRadius.circular(AppRadii.control), - child: InkWell( - onTap: () => context.goPrimary(navigationShell, primary), - borderRadius: BorderRadius.circular(AppRadii.control), - hoverColor: selected ? palette.primarySolid : palette.secondary, - child: ExcludeSemantics( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent( - 56, - platform: Theme.of(context).platform, - ), - minWidth: AppLayout.touchExtent( - 0, - platform: Theme.of(context).platform, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(_icon(primary), size: 19, color: color), - const SizedBox(height: 5), - Text( - _label(context, primary), - textAlign: TextAlign.center, - style: - (selected - ? AppTypography.selectedMobileNavigationLabel - : AppTypography.mobileNavigationLabel) - .copyWith(color: color), - ), - ], - ), - ), + ], ), - ), - ), - ), ); } @@ -409,13 +337,9 @@ class _AdaptiveMainShellState extends State { ), ), Expanded( - child: Container( - margin: const EdgeInsets.fromLTRB(0, 8, 8, 8), - decoration: BoxDecoration( - color: palette.background, - borderRadius: BorderRadius.circular(24), - ), - clipBehavior: Clip.antiAlias, + child: ColoredBox( + key: const ValueKey('primary-content'), + color: palette.background, child: nativeSidebar && Directionality.of(context) == TextDirection.ltr diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index bbc2c1a98..609649f7f 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -13,7 +13,6 @@ import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/main/url.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/pages/theme/color.dart'; -import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; @@ -218,6 +217,16 @@ void main() { tester.getSize(desktopNavigation).width, AppLayout.desktopSidebarWidth, ); + final content = find.byKey(const ValueKey('primary-content')); + expect(tester.widget(content).color, palette.background); + expect( + tester.getRect(content), + Rect.fromLTRB(AppLayout.desktopSidebarWidth, 0, 1200, 800), + ); + expect( + find.ancestor(of: content, matching: find.byType(ClipRRect)), + findsNothing, + ); final desktopConnect = find.byKey( const ValueKey('primary-navigation-connect'), ); @@ -296,44 +305,43 @@ void main() { const ValueKey('primary-navigation-connect'), ); expect(navigation, findsOneWidget); - final navigationCard = tester.widget(navigation); - expect(navigationCard.margin, const EdgeInsets.fromLTRB(12, 0, 12, 10)); - expect(navigationCard.padding, const EdgeInsets.all(6)); - final decoration = navigationCard.decoration! as BoxDecoration; - expect(decoration.color, palette.card); - expect(decoration.borderRadius, BorderRadius.circular(22)); + final navigationBar = tester.widget(navigation); + expect(navigationBar.selectedIndex, AppPrimaryDestination.connect.index); + expect(navigationBar.destinations, hasLength(5)); + expect(navigationBar.height, isNull); + expect(navigationBar.backgroundColor, isNull); + expect(navigationBar.elevation, isNull); + expect(navigationBar.indicatorColor, isNull); + expect(navigationBar.indicatorShape, isNull); + expect(navigationBar.labelTextStyle, isNull); + expect(navigationBar.labelBehavior, isNull); + expect(navigationBar.labelPadding, isNull); + expect(navigationBar.overlayColor, isNull); expect( - find.descendant(of: navigation, matching: find.byType(InkWell)), + find.descendant( + of: navigation, + matching: find.byType(NavigationDestination), + ), findsNWidgets(5), ); expect( - find.byKey(const ValueKey('primary-navigation-settings')), + find.ancestor(of: navigation, matching: find.byType(SafeArea)), findsNothing, ); + expect(tester.getRect(navigation).left, 0); + expect(tester.getRect(navigation).right, 720); expect( - tester.widget(connectDestination).properties.label, - 'Connect', - ); - expect( - tester.widget(connectDestination).properties.selected, - isTrue, - ); - expect(tester.getSize(connectDestination).height, 56); - final selectedMobileSurface = tester.widget( - find - .descendant(of: connectDestination, matching: find.byType(Material)) - .first, - ); - expect(selectedMobileSurface.color, palette.primarySolid); - final mobileLabel = tester.widget( - find.descendant(of: connectDestination, matching: find.byType(Text)), + find.byKey(const ValueKey('primary-navigation-settings')), + findsNothing, ); - expect(mobileLabel.style!.fontSize, 11); - expect(mobileLabel.style!.color, palette.primaryForeground); expect( - mobileLabel.style!.fontWeight, - AppTypography.selectedMobileNavigationLabel.fontWeight, + tester.widget(connectDestination).label, + 'Connect', ); + final mobileIcon = + tester.widget(connectDestination).icon as Icon; + expect(mobileIcon.size, isNull); + expect(mobileIcon.color, isNull); expect(find.byType(Badge), findsNothing); router.go('/settings'); await tester.pumpAndSettle(); @@ -429,7 +437,7 @@ void main() { (2.0, Locale('fa'), Brightness.dark), ]) { testWidgets( - 'inset mobile navigation stays reachable on $platform in $locale at $scale', + 'standard mobile navigation stays reachable on $platform in $locale at $scale', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -463,10 +471,16 @@ void main() { final navigation = find.byKey( const ValueKey('primary-mobile-navigation'), ); - final palette = ColorManager.palette(tester.element(navigation)); final rect = tester.getRect(navigation); - expect(rect.bottom, lessThanOrEqualTo(360 - padding.bottom)); + expect(rect.left, 0); + expect(rect.right, 390); + expect(rect.bottom, 360); expect(rect.top, greaterThanOrEqualTo(padding.top)); + final navigationBar = tester.widget(navigation); + expect(navigationBar.height, isNull); + expect(navigationBar.indicatorColor, isNull); + expect(navigationBar.indicatorShape, isNull); + expect(navigationBar.labelTextStyle, isNull); expect( Directionality.of(tester.element(navigation)), locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, @@ -474,6 +488,7 @@ void main() { final destinations = AppPrimaryDestination.values.where( (primary) => primary != AppPrimaryDestination.settings, ); + final labelRects = []; for (final primary in destinations) { final destination = find.byKey( ValueKey('primary-navigation-${primary.name}'), @@ -485,7 +500,12 @@ void main() { AppLayout.touchExtent(0, platform: platform), ), ); - expect(size.height, greaterThanOrEqualTo(56)); + expect( + size.height, + greaterThanOrEqualTo( + AppLayout.touchExtent(0, platform: platform), + ), + ); expect( size.height, closeTo( @@ -500,23 +520,40 @@ void main() { final labelRect = tester.getRect( find.descendant(of: destination, matching: find.byType(Text)), ); + labelRects.add(labelRect); final iconRect = tester.getRect( find.descendant(of: destination, matching: find.byType(Icon)), ); final destinationRect = tester.getRect(destination); expect( - size.height, - greaterThanOrEqualTo( - iconRect.height + 5 + labelRect.height + 12 - .01, - ), + destinationRect.bottom, + lessThanOrEqualTo(360 - padding.bottom), ); - expect(labelRect.left, greaterThanOrEqualTo(destinationRect.left)); - expect(labelRect.right, lessThanOrEqualTo(destinationRect.right)); + // Material's destination delegate centers icon + wrapped label as + // a group, which may paint above its fixed destination slot. Keep + // that default behavior, but require visible, nonoverlapping labels + // within the whole bar and icons within the unobstructed viewport. + expect(iconRect.top, greaterThanOrEqualTo(padding.top)); + expect(iconRect.bottom, lessThanOrEqualTo(rect.bottom)); + expect(labelRect.left, greaterThanOrEqualTo(rect.left)); + expect(labelRect.right, lessThanOrEqualTo(rect.right)); + expect(labelRect.top, greaterThanOrEqualTo(rect.top)); + expect(labelRect.bottom, lessThanOrEqualTo(rect.bottom)); + final selectionSemantics = find + .ancestor( + of: destination, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.selected != null, + ), + ) + .first; + final semantics = tester.getSemantics(selectionSemantics); expect( - labelRect.bottom, - lessThanOrEqualTo(destinationRect.bottom - 6 + .01), + semantics.getSemanticsData().label, + contains(tester.widget(destination).label), ); - final semantics = tester.getSemantics(destination); expect( semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue, @@ -529,15 +566,13 @@ void main() { ); expect(find.text('${primary.name}-content'), findsOneWidget); expect( - tester.widget(destination).properties.selected, + tester.widget(selectionSemantics).properties.selected, isTrue, ); - final surface = tester.widget( - find - .descendant(of: destination, matching: find.byType(Material)) - .first, + expect( + tester.widget(navigation).selectedIndex, + primary.index, ); - expect(surface.color, palette.primarySolid); await tester.tap(destination); await tester.pumpAndSettle(); expect( @@ -546,13 +581,13 @@ void main() { ); expect(tester.takeException(), isNull); } - final destinationHeight = tester - .getSize(find.byKey(const ValueKey('primary-navigation-connect'))) - .height; - expect( - destinationHeight, - scale > 1 ? greaterThan(56) : greaterThanOrEqualTo(56), - ); + for (var index = 1; index < labelRects.length; index++) { + expect( + labelRects[index - 1].overlaps(labelRects[index]), + isFalse, + reason: 'Adjacent default navigation labels must remain separate', + ); + } if (locale.languageCode == 'fa') { expect( tester @@ -1058,12 +1093,11 @@ void main() { } else { expect( tester - .widget( - find.byKey(ValueKey('primary-navigation-${primary.name}')), + .widget( + find.byKey(const ValueKey('primary-mobile-navigation')), ) - .properties - .selected, - isTrue, + .selectedIndex, + primary.index, ); } expect(tester.takeException(), isNull); diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index 8c6ce1141..0510694b6 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -597,37 +597,102 @@ void main() { }); testWidgets('page bar preserves themed sizing and tabs', (tester) async { - final theme = AppTheme.light; - await tester.pumpWidget( - MaterialApp( - theme: theme.copyWith( - appBarTheme: theme.appBarTheme.copyWith(toolbarHeight: 64), - ), - home: DefaultTabController( - length: 2, - child: Builder( - builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: const Text('Advanced'), - bottom: const TabBar( - tabs: [ - Tab(text: 'Tunnel'), - Tab(text: 'Xray'), + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1160, 720); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + for (final brightness in Brightness.values) { + final theme = AppTheme.material(brightness); + await tester.pumpWidget( + MaterialApp( + key: ValueKey(brightness), + theme: theme.copyWith( + appBarTheme: theme.appBarTheme.copyWith(toolbarHeight: 64), + ), + home: DefaultTabController( + length: 2, + child: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar.forPage( + context, + title: const Text('Advanced'), + bottom: const TabBar( + tabs: [ + Tab(text: 'Tunnel'), + Tab(text: 'Xray'), + ], + ), + ), + body: ListView.builder( + key: const ValueKey('page-bar-scroll-body'), + itemCount: 30, + itemExtent: 64, + itemBuilder: (_, index) => Text('Setting $index'), + ), + bottomNavigationBar: PageActionBar( + children: [ + OutlinedButton( + onPressed: () {}, + child: const Text('Cancel'), + ), + FilledButton(onPressed: () {}, child: const Text('Save')), ], ), ), ), ), ), - ), - ); - final tabs = tester.widget(find.byType(TabBar)); - expect( - tester.getSize(find.byType(AppBar)).height, - 64 + tabs.preferredSize.height, - ); - expect(tester.widget(find.byType(AppBar)).backgroundColor, isNull); + ); + await tester.pumpAndSettle(); + final tabs = tester.widget(find.byType(TabBar)); + expect( + tester.getSize(find.byType(AppBar)).height, + 64 + tabs.preferredSize.height, + ); + final list = find.byKey(const ValueKey('page-bar-scroll-body')); + for (final scrolled in [false, true]) { + if (scrolled) { + await tester.drag(list, const Offset(0, -400)); + await tester.pumpAndSettle(); + final scrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)), + ); + expect(scrollable.position.pixels, greaterThan(0)); + } + final appBar = tester.widget( + find + .descendant( + of: find.byType(AppBar), + matching: find.byType(Material), + ) + .first, + ); + final footer = tester.widget( + find + .descendant( + of: find.byType(PageActionBar), + matching: find.byType(Material), + ) + .first, + ); + expect( + appBar.color, + theme.scaffoldBackgroundColor, + reason: '$brightness AppBar, scrolled=$scrolled', + ); + expect(appBar.elevation, 0); + expect( + footer.color, + theme.scaffoldBackgroundColor, + reason: '$brightness footer, scrolled=$scrolled', + ); + expect( + tester.widget(find.byType(AppBar)).backgroundColor, + isNull, + ); + expect(tester.takeException(), isNull); + } + } }); testWidgets( From c5321082e9da948287ae3126bafd442d27284851 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 17:20:26 +0800 Subject: [PATCH 39/60] fix(navigation): use the requested BottomNavigationBar widget --- docs/app.md | 6 +- docs/validation.md | 4 +- lib/pages/main/adaptive_shell.dart | 17 ++-- test/pages/main/adaptive_shell_test.dart | 101 +++++++++-------------- test/pages/theme/theme_test.dart | 4 + 5 files changed, 58 insertions(+), 74 deletions(-) diff --git a/docs/app.md b/docs/app.md index e792449ff..18e9bc9ce 100644 --- a/docs/app.md +++ b/docs/app.md @@ -6,8 +6,8 @@ ## 导航与交互 - 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。 - 手机根页使用 Flutter 默认 Material 3 `NavigationBar` 与 `NavigationDestination`,包含前五项入口;设置由顶部按钮进入。 - 底栏不另包圆角悬浮卡片,不自定义高度、指示器、标签或图标样式,选中状态沿用 Material 3 默认表现。 + 手机根页使用 Flutter `BottomNavigationBar` 与 `BottomNavigationBarItem`,前五项入口采用内置 `BottomNavigationBarType.fixed` 模式;设置由顶部按钮进入。 + 底栏不另包圆角悬浮卡片,不额外限定高度或定制颜色、标签和图标样式,选中状态沿用组件默认表现。 设置页和二级页面隐藏底部根导航。平板和桌面宽布局保留带文字的左侧导航, 连接、服务器和路由归入“工作区”,VPN 隧道和 Xray 归入“系统与内核”,设置位于侧栏底部; 不再提供“高级”的二级 Tab 容器。侧栏选中项使用无边框卡片底色与强调色文字。 @@ -39,7 +39,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 长标题和系统大字号下仍保留返回、设置及主要动作的可达性,不通过缩小文字适配。 平板与桌面宽布局的共享 AppBar、`PageActionBar` 背景均与 `Scaffold` 背景一致,滚动时不额外着色或增加抬升效果; 手机 AppBar 沿用 Material 默认表现。业务卡片和弹窗继续使用各自的背景、边框与圆角。 -字号为整数,正文和一般辅助文字不小于 12;侧栏分组名使用共享的 11px 导航角色,手机底栏标签遵循 Material 3 默认排版。 +字号为整数,正文和一般辅助文字不小于 12;侧栏分组名使用共享的 11px 导航角色,手机底栏标签遵循 `BottomNavigationBar` 默认排版。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 diff --git a/docs/validation.md b/docs/validation.md index 9f9e41071..2b9b184b7 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -39,8 +39,8 @@ - 代表性手机、平板和桌面窗口分别核对连接/服务器/路由/隧道/Xray/设置六个目的地。 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。 - 手机底栏核对默认 Material 3 `NavigationBar` / `NavigationDestination` 的标签、选中语义和分支切换, - 不添加悬浮圆角卡片,也不设置底栏高度、指示器或标签/图标样式;分支栈与详情隐藏规则保持不变。 + 手机底栏核对 `BottomNavigationBar` / `BottomNavigationBarItem` 五项内置 fixed 模式的标签、选中语义和分支切换, + 不添加悬浮圆角卡片,不额外限定高度或定制颜色、标签和图标样式;分支栈与详情隐藏规则保持不变。 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 平板与桌面宽布局业务区直接填满侧栏旁的剩余空间,不保留整区圆角裁切、装饰外框或外层留白; diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index 695820ff3..fc7db05a0 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -284,16 +284,19 @@ class _AdaptiveMainShellState extends State { ? null : navigationShell.currentIndex == AppPrimaryDestination.settings.index ? null - : NavigationBar( + : BottomNavigationBar( key: const ValueKey('primary-mobile-navigation'), - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: (index) => + type: BottomNavigationBarType.fixed, + currentIndex: navigationShell.currentIndex, + onTap: (index) => context.goPrimary(navigationShell, destinations[index]), - destinations: [ + items: [ for (final primary in destinations) - NavigationDestination( - key: ValueKey('primary-navigation-${primary.name}'), - icon: Icon(_icon(primary)), + BottomNavigationBarItem( + icon: Icon( + _icon(primary), + key: ValueKey('primary-navigation-${primary.name}'), + ), label: _label(context, primary), ), ], diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index 609649f7f..840b6f5bb 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -301,29 +301,22 @@ void main() { await tester.pumpAndSettle(); expect(desktopNavigation, findsNothing); final navigation = find.byKey(const ValueKey('primary-mobile-navigation')); - final connectDestination = find.byKey( - const ValueKey('primary-navigation-connect'), - ); expect(navigation, findsOneWidget); - final navigationBar = tester.widget(navigation); - expect(navigationBar.selectedIndex, AppPrimaryDestination.connect.index); - expect(navigationBar.destinations, hasLength(5)); - expect(navigationBar.height, isNull); + final navigationBar = tester.widget(navigation); + expect(navigationBar.currentIndex, AppPrimaryDestination.connect.index); + expect(navigationBar.type, BottomNavigationBarType.fixed); + expect(navigationBar.items, hasLength(5)); expect(navigationBar.backgroundColor, isNull); expect(navigationBar.elevation, isNull); - expect(navigationBar.indicatorColor, isNull); - expect(navigationBar.indicatorShape, isNull); - expect(navigationBar.labelTextStyle, isNull); - expect(navigationBar.labelBehavior, isNull); - expect(navigationBar.labelPadding, isNull); - expect(navigationBar.overlayColor, isNull); - expect( - find.descendant( - of: navigation, - matching: find.byType(NavigationDestination), - ), - findsNWidgets(5), - ); + expect(navigationBar.selectedItemColor, isNull); + expect(navigationBar.unselectedItemColor, isNull); + expect(navigationBar.selectedIconTheme, isNull); + expect(navigationBar.unselectedIconTheme, isNull); + expect(navigationBar.selectedLabelStyle, isNull); + expect(navigationBar.unselectedLabelStyle, isNull); + expect(navigationBar.showSelectedLabels, isNull); + expect(navigationBar.showUnselectedLabels, isNull); + expect(find.byType(NavigationBar), findsNothing); expect( find.ancestor(of: navigation, matching: find.byType(SafeArea)), findsNothing, @@ -334,12 +327,8 @@ void main() { find.byKey(const ValueKey('primary-navigation-settings')), findsNothing, ); - expect( - tester.widget(connectDestination).label, - 'Connect', - ); - final mobileIcon = - tester.widget(connectDestination).icon as Icon; + expect(navigationBar.items.first.label, 'Connect'); + final mobileIcon = navigationBar.items.first.icon as Icon; expect(mobileIcon.size, isNull); expect(mobileIcon.color, isNull); expect(find.byType(Badge), findsNothing); @@ -437,7 +426,7 @@ void main() { (2.0, Locale('fa'), Brightness.dark), ]) { testWidgets( - 'standard mobile navigation stays reachable on $platform in $locale at $scale', + 'fixed BottomNavigationBar stays reachable on $platform in $locale at $scale', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -476,11 +465,13 @@ void main() { expect(rect.right, 390); expect(rect.bottom, 360); expect(rect.top, greaterThanOrEqualTo(padding.top)); - final navigationBar = tester.widget(navigation); - expect(navigationBar.height, isNull); - expect(navigationBar.indicatorColor, isNull); - expect(navigationBar.indicatorShape, isNull); - expect(navigationBar.labelTextStyle, isNull); + final navigationBar = tester.widget(navigation); + expect(navigationBar.type, BottomNavigationBarType.fixed); + expect(navigationBar.items, hasLength(5)); + expect(navigationBar.selectedItemColor, isNull); + expect(navigationBar.unselectedItemColor, isNull); + expect(navigationBar.selectedLabelStyle, isNull); + expect(navigationBar.unselectedLabelStyle, isNull); expect( Directionality.of(tester.element(navigation)), locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, @@ -490,9 +481,14 @@ void main() { ); final labelRects = []; for (final primary in destinations) { - final destination = find.byKey( - ValueKey('primary-navigation-${primary.name}'), + final item = navigationBar.items[primary.index]; + final label = find.descendant( + of: navigation, + matching: find.text(item.label!), ); + final destination = find + .ancestor(of: label, matching: find.byType(InkResponse)) + .first; final size = tester.getSize(destination); expect( size.width, @@ -506,20 +502,8 @@ void main() { AppLayout.touchExtent(0, platform: platform), ), ); - expect( - size.height, - closeTo( - tester - .getSize( - find.byKey(const ValueKey('primary-navigation-connect')), - ) - .height, - .01, - ), - ); - final labelRect = tester.getRect( - find.descendant(of: destination, matching: find.byType(Text)), - ); + expect(size.width, closeTo(rect.width / 5, .01)); + final labelRect = tester.getRect(label); labelRects.add(labelRect); final iconRect = tester.getRect( find.descendant(of: destination, matching: find.byType(Icon)), @@ -529,19 +513,15 @@ void main() { destinationRect.bottom, lessThanOrEqualTo(360 - padding.bottom), ); - // Material's destination delegate centers icon + wrapped label as - // a group, which may paint above its fixed destination slot. Keep - // that default behavior, but require visible, nonoverlapping labels - // within the whole bar and icons within the unobstructed viewport. - expect(iconRect.top, greaterThanOrEqualTo(padding.top)); - expect(iconRect.bottom, lessThanOrEqualTo(rect.bottom)); + expect(iconRect.top, greaterThanOrEqualTo(destinationRect.top)); + expect(iconRect.bottom, lessThanOrEqualTo(destinationRect.bottom)); expect(labelRect.left, greaterThanOrEqualTo(rect.left)); expect(labelRect.right, lessThanOrEqualTo(rect.right)); expect(labelRect.top, greaterThanOrEqualTo(rect.top)); expect(labelRect.bottom, lessThanOrEqualTo(rect.bottom)); final selectionSemantics = find .ancestor( - of: destination, + of: label, matching: find.byWidgetPredicate( (widget) => widget is Semantics && @@ -550,10 +530,7 @@ void main() { ) .first; final semantics = tester.getSemantics(selectionSemantics); - expect( - semantics.getSemanticsData().label, - contains(tester.widget(destination).label), - ); + expect(semantics.getSemanticsData().label, contains(item.label!)); expect( semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue, @@ -570,7 +547,7 @@ void main() { isTrue, ); expect( - tester.widget(navigation).selectedIndex, + tester.widget(navigation).currentIndex, primary.index, ); await tester.tap(destination); @@ -1093,10 +1070,10 @@ void main() { } else { expect( tester - .widget( + .widget( find.byKey(const ValueKey('primary-mobile-navigation')), ) - .selectedIndex, + .currentIndex, primary.index, ); } diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index b95d856e0..bb0e400e4 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -238,6 +238,10 @@ void main() { expect(material.appBarTheme.elevation, isNull); expect(material.appBarTheme.scrolledUnderElevation, isNull); expect(material.navigationBarTheme, const NavigationBarThemeData()); + expect( + material.bottomNavigationBarTheme, + const BottomNavigationBarThemeData(), + ); expect( material.filledButtonTheme.style!.minimumSize!.resolve({}), Size.square(AppLayout.touchExtent(AppLayout.mobileButtonMinHeight)), From ce999ceda28e6ff4d8a87e94ac1306eadfbf0fe7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 17:34:28 +0800 Subject: [PATCH 40/60] revert(navigation): restore the custom mobile bar and keep wide surface fixes --- docs/app.md | 7 +- docs/validation.md | 5 +- lib/pages/main/adaptive_shell.dart | 105 ++++++++++++--- lib/pages/theme/theme.dart | 24 ++++ test/pages/main/adaptive_shell_test.dart | 163 +++++++++++------------ test/pages/theme/theme_test.dart | 23 +++- 6 files changed, 216 insertions(+), 111 deletions(-) diff --git a/docs/app.md b/docs/app.md index 18e9bc9ce..a832e238f 100644 --- a/docs/app.md +++ b/docs/app.md @@ -6,8 +6,7 @@ ## 导航与交互 - 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。 - 手机根页使用 Flutter `BottomNavigationBar` 与 `BottomNavigationBarItem`,前五项入口采用内置 `BottomNavigationBarType.fixed` 模式;设置由顶部按钮进入。 - 底栏不另包圆角悬浮卡片,不额外限定高度或定制颜色、标签和图标样式,选中状态沿用组件默认表现。 + 手机根页使用内缩的圆角卡片底栏,包含前五项入口,选中项为蓝底白字;设置由顶部按钮进入。 设置页和二级页面隐藏底部根导航。平板和桌面宽布局保留带文字的左侧导航, 连接、服务器和路由归入“工作区”,VPN 隧道和 Xray 归入“系统与内核”,设置位于侧栏底部; 不再提供“高级”的二级 Tab 容器。侧栏选中项使用无边框卡片底色与强调色文字。 @@ -39,7 +38,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 长标题和系统大字号下仍保留返回、设置及主要动作的可达性,不通过缩小文字适配。 平板与桌面宽布局的共享 AppBar、`PageActionBar` 背景均与 `Scaffold` 背景一致,滚动时不额外着色或增加抬升效果; 手机 AppBar 沿用 Material 默认表现。业务卡片和弹窗继续使用各自的背景、边框与圆角。 -字号为整数,正文和一般辅助文字不小于 12;侧栏分组名使用共享的 11px 导航角色,手机底栏标签遵循 `BottomNavigationBar` 默认排版。 +字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。 字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。 界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。 @@ -51,7 +50,7 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重 `ResponsiveContent` 只限制内容宽度,不叠加页面内边距;公共按钮保留系统文字缩放。 `SectionView` 的标题、默认位置的说明与正文共用同一张圆角卡片,不在标题与正文间绘制通栏分隔线; `descriptionBelow` 指定的说明仍位于卡片外。`SettingSection` 默认不绘制行间分隔线,需要时显式启用 `separated`。 -侧栏和卡片共用现有字体家族与文字角色;侧栏辅助标签使用专属角色,不改写正文等已有角色。 +导航和卡片共用现有字体家族与文字角色;导航辅助标签使用专属角色,不改写正文等已有角色。 共享触控下限按平台应用:iOS 为 44×44 pt,Android 为 48×48 dp,不依赖窗口宽度。 共享开关保留紧凑轨道,以实际点击层补足触控下限;轨道及外围点击均只回调一次,桌面不扩大轨道。 弹窗关闭、返回及设置动作可保留较小的视觉图标,通过实际点击区域满足下限;横屏不切换为桌面触控尺寸。 diff --git a/docs/validation.md b/docs/validation.md index 2b9b184b7..10df4c85e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -39,8 +39,7 @@ - 代表性手机、平板和桌面窗口分别核对连接/服务器/路由/隧道/Xray/设置六个目的地。 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。 - 手机底栏核对 `BottomNavigationBar` / `BottomNavigationBarItem` 五项内置 fixed 模式的标签、选中语义和分支切换, - 不添加悬浮圆角卡片,不额外限定高度或定制颜色、标签和图标样式;分支栈与详情隐藏规则保持不变。 + 手机底栏核对内缩圆角卡片、蓝底白字选中项、五项标签与选中语义;切换保留分支栈,进入详情隐藏、返回恢复。 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。 平板与桌面宽布局业务区直接填满侧栏旁的剩余空间,不保留整区圆角裁切、装饰外框或外层留白; @@ -48,7 +47,7 @@ - 页面标题、返回及页面操作位于共享标准 Flutter AppBar,不固定展示 logo、App 名称或工作区路径,也不在下方添加重复页标题区。 AppBar 没有主题快捷按钮,主题仍可由设置修改;检查长标题、窄宽度和大字号下返回与主要操作可达。 平板与桌面宽布局在明暗主题下分别检查 AppBar、`PageActionBar` 与 `Scaffold` 背景一致, - 滚动前后没有额外表面着色或抬升效果;手机 AppBar 和根导航保持 Material 默认表现,业务卡片与弹窗样式保持原合同。 + 滚动前后没有额外表面着色或抬升效果;手机 AppBar 沿用 Material 默认表现,业务卡片与弹窗样式保持原合同。 - Welcome、准备配置、隐私和选择子页检查完整页面布局:正文上对齐单列滚动,底部动作独立固定, 短屏、键盘、大字号及 768 宽 RTL 下不以居中卡片或弹窗承载主流程,操作不随正文滚走。 隐私需明确同意,配置返回不撤销同意;地区建议不直接入库,跳过、清空和子页取消分别保持原草稿语义。 diff --git a/lib/pages/main/adaptive_shell.dart b/lib/pages/main/adaptive_shell.dart index fc7db05a0..e3d85dc65 100644 --- a/lib/pages/main/adaptive_shell.dart +++ b/lib/pages/main/adaptive_shell.dart @@ -266,9 +266,7 @@ class _AdaptiveMainShellState extends State { } Widget _bottomNavigationScaffold(BuildContext context) { - final destinations = AppPrimaryDestination.values - .where((primary) => primary != AppPrimaryDestination.settings) - .toList(growable: false); + final palette = ColorManager.palette(context); return Scaffold( body: navigationShell, bottomNavigationBar: @@ -284,23 +282,94 @@ class _AdaptiveMainShellState extends State { ? null : navigationShell.currentIndex == AppPrimaryDestination.settings.index ? null - : BottomNavigationBar( - key: const ValueKey('primary-mobile-navigation'), - type: BottomNavigationBarType.fixed, - currentIndex: navigationShell.currentIndex, - onTap: (index) => - context.goPrimary(navigationShell, destinations[index]), - items: [ - for (final primary in destinations) - BottomNavigationBarItem( - icon: Icon( - _icon(primary), - key: ValueKey('primary-navigation-${primary.name}'), - ), - label: _label(context, primary), + : SafeArea( + top: false, + child: Container( + key: const ValueKey('primary-mobile-navigation'), + margin: const EdgeInsets.fromLTRB(12, 0, 12, 10), + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: palette.card, + borderRadius: BorderRadius.circular(22), + ), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final primary in AppPrimaryDestination.values.where( + (p) => p != AppPrimaryDestination.settings, + )) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: _mobileDestination(context, primary), + ), + ), + ], ), - ], + ), + ), + ), + ); + } + + Widget _mobileDestination( + BuildContext context, + AppPrimaryDestination primary, + ) { + final palette = ColorManager.palette(context); + final selected = primary.index == navigationShell.currentIndex; + final color = selected + ? palette.primaryForeground + : palette.mutedForeground; + return Semantics( + key: ValueKey('primary-navigation-${primary.name}'), + label: _label(context, primary), + selected: selected, + button: true, + child: Material( + color: selected ? palette.primarySolid : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadii.control), + child: InkWell( + onTap: () => context.goPrimary(navigationShell, primary), + borderRadius: BorderRadius.circular(AppRadii.control), + hoverColor: selected ? palette.primarySolid : palette.secondary, + child: ExcludeSemantics( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: AppLayout.touchExtent( + 56, + platform: Theme.of(context).platform, + ), + minWidth: AppLayout.touchExtent( + 0, + platform: Theme.of(context).platform, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(_icon(primary), size: 19, color: color), + const SizedBox(height: 5), + Text( + _label(context, primary), + textAlign: TextAlign.center, + style: + (selected + ? AppTypography.selectedMobileNavigationLabel + : AppTypography.mobileNavigationLabel) + .copyWith(color: color), + ), + ], + ), + ), ), + ), + ), + ), ); } diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index 31bb59225..3e6906983 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -750,6 +750,30 @@ abstract final class AppTheme { palette.accent.withValues(alpha: 0.72), ), ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: palette.card.withValues(alpha: 0.96), + elevation: 0, + surfaceTintColor: Colors.transparent, + indicatorColor: Colors.transparent, + overlayColor: const WidgetStatePropertyAll(Colors.transparent), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + color: states.contains(WidgetState.selected) + ? palette.sidebarPrimary + : palette.mutedStrong, + ), + ), + labelTextStyle: WidgetStateProperty.resolveWith( + (states) => TextStyle( + color: states.contains(WidgetState.selected) + ? palette.primary + : palette.mutedStrong, + fontWeight: states.contains(WidgetState.selected) + ? FontWeight.w600 + : FontWeight.w400, + ), + ), + ), navigationRailTheme: NavigationRailThemeData( backgroundColor: palette.sidebar, indicatorColor: palette.selectedSurface, diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index 840b6f5bb..811efe00d 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -13,6 +13,7 @@ import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/main/url.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; @@ -301,36 +302,48 @@ void main() { await tester.pumpAndSettle(); expect(desktopNavigation, findsNothing); final navigation = find.byKey(const ValueKey('primary-mobile-navigation')); + final connectDestination = find.byKey( + const ValueKey('primary-navigation-connect'), + ); expect(navigation, findsOneWidget); - final navigationBar = tester.widget(navigation); - expect(navigationBar.currentIndex, AppPrimaryDestination.connect.index); - expect(navigationBar.type, BottomNavigationBarType.fixed); - expect(navigationBar.items, hasLength(5)); - expect(navigationBar.backgroundColor, isNull); - expect(navigationBar.elevation, isNull); - expect(navigationBar.selectedItemColor, isNull); - expect(navigationBar.unselectedItemColor, isNull); - expect(navigationBar.selectedIconTheme, isNull); - expect(navigationBar.unselectedIconTheme, isNull); - expect(navigationBar.selectedLabelStyle, isNull); - expect(navigationBar.unselectedLabelStyle, isNull); - expect(navigationBar.showSelectedLabels, isNull); - expect(navigationBar.showUnselectedLabels, isNull); - expect(find.byType(NavigationBar), findsNothing); + final navigationCard = tester.widget(navigation); + expect(navigationCard.margin, const EdgeInsets.fromLTRB(12, 0, 12, 10)); + expect(navigationCard.padding, const EdgeInsets.all(6)); + final decoration = navigationCard.decoration! as BoxDecoration; + expect(decoration.color, palette.card); + expect(decoration.borderRadius, BorderRadius.circular(22)); expect( - find.ancestor(of: navigation, matching: find.byType(SafeArea)), - findsNothing, + find.descendant(of: navigation, matching: find.byType(InkWell)), + findsNWidgets(5), ); - expect(tester.getRect(navigation).left, 0); - expect(tester.getRect(navigation).right, 720); expect( find.byKey(const ValueKey('primary-navigation-settings')), findsNothing, ); - expect(navigationBar.items.first.label, 'Connect'); - final mobileIcon = navigationBar.items.first.icon as Icon; - expect(mobileIcon.size, isNull); - expect(mobileIcon.color, isNull); + expect( + tester.widget(connectDestination).properties.label, + 'Connect', + ); + expect( + tester.widget(connectDestination).properties.selected, + isTrue, + ); + expect(tester.getSize(connectDestination).height, 56); + final selectedMobileSurface = tester.widget( + find + .descendant(of: connectDestination, matching: find.byType(Material)) + .first, + ); + expect(selectedMobileSurface.color, palette.primarySolid); + final mobileLabel = tester.widget( + find.descendant(of: connectDestination, matching: find.byType(Text)), + ); + expect(mobileLabel.style!.fontSize, 11); + expect(mobileLabel.style!.color, palette.primaryForeground); + expect( + mobileLabel.style!.fontWeight, + AppTypography.selectedMobileNavigationLabel.fontWeight, + ); expect(find.byType(Badge), findsNothing); router.go('/settings'); await tester.pumpAndSettle(); @@ -426,7 +439,7 @@ void main() { (2.0, Locale('fa'), Brightness.dark), ]) { testWidgets( - 'fixed BottomNavigationBar stays reachable on $platform in $locale at $scale', + 'inset mobile navigation stays reachable on $platform in $locale at $scale', (tester) async { await tester.binding.setSurfaceSize(const Size(390, 360)); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -460,18 +473,10 @@ void main() { final navigation = find.byKey( const ValueKey('primary-mobile-navigation'), ); + final palette = ColorManager.palette(tester.element(navigation)); final rect = tester.getRect(navigation); - expect(rect.left, 0); - expect(rect.right, 390); - expect(rect.bottom, 360); + expect(rect.bottom, lessThanOrEqualTo(360 - padding.bottom)); expect(rect.top, greaterThanOrEqualTo(padding.top)); - final navigationBar = tester.widget(navigation); - expect(navigationBar.type, BottomNavigationBarType.fixed); - expect(navigationBar.items, hasLength(5)); - expect(navigationBar.selectedItemColor, isNull); - expect(navigationBar.unselectedItemColor, isNull); - expect(navigationBar.selectedLabelStyle, isNull); - expect(navigationBar.unselectedLabelStyle, isNull); expect( Directionality.of(tester.element(navigation)), locale.languageCode == 'fa' ? TextDirection.rtl : TextDirection.ltr, @@ -479,16 +484,10 @@ void main() { final destinations = AppPrimaryDestination.values.where( (primary) => primary != AppPrimaryDestination.settings, ); - final labelRects = []; for (final primary in destinations) { - final item = navigationBar.items[primary.index]; - final label = find.descendant( - of: navigation, - matching: find.text(item.label!), + final destination = find.byKey( + ValueKey('primary-navigation-${primary.name}'), ); - final destination = find - .ancestor(of: label, matching: find.byType(InkResponse)) - .first; final size = tester.getSize(destination); expect( size.width, @@ -496,41 +495,38 @@ void main() { AppLayout.touchExtent(0, platform: platform), ), ); + expect(size.height, greaterThanOrEqualTo(56)); expect( size.height, - greaterThanOrEqualTo( - AppLayout.touchExtent(0, platform: platform), + closeTo( + tester + .getSize( + find.byKey(const ValueKey('primary-navigation-connect')), + ) + .height, + .01, ), ); - expect(size.width, closeTo(rect.width / 5, .01)); - final labelRect = tester.getRect(label); - labelRects.add(labelRect); + final labelRect = tester.getRect( + find.descendant(of: destination, matching: find.byType(Text)), + ); final iconRect = tester.getRect( find.descendant(of: destination, matching: find.byType(Icon)), ); final destinationRect = tester.getRect(destination); expect( - destinationRect.bottom, - lessThanOrEqualTo(360 - padding.bottom), + size.height, + greaterThanOrEqualTo( + iconRect.height + 5 + labelRect.height + 12 - .01, + ), ); - expect(iconRect.top, greaterThanOrEqualTo(destinationRect.top)); - expect(iconRect.bottom, lessThanOrEqualTo(destinationRect.bottom)); - expect(labelRect.left, greaterThanOrEqualTo(rect.left)); - expect(labelRect.right, lessThanOrEqualTo(rect.right)); - expect(labelRect.top, greaterThanOrEqualTo(rect.top)); - expect(labelRect.bottom, lessThanOrEqualTo(rect.bottom)); - final selectionSemantics = find - .ancestor( - of: label, - matching: find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.selected != null, - ), - ) - .first; - final semantics = tester.getSemantics(selectionSemantics); - expect(semantics.getSemanticsData().label, contains(item.label!)); + expect(labelRect.left, greaterThanOrEqualTo(destinationRect.left)); + expect(labelRect.right, lessThanOrEqualTo(destinationRect.right)); + expect( + labelRect.bottom, + lessThanOrEqualTo(destinationRect.bottom - 6 + .01), + ); + final semantics = tester.getSemantics(destination); expect( semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue, @@ -543,13 +539,15 @@ void main() { ); expect(find.text('${primary.name}-content'), findsOneWidget); expect( - tester.widget(selectionSemantics).properties.selected, + tester.widget(destination).properties.selected, isTrue, ); - expect( - tester.widget(navigation).currentIndex, - primary.index, + final surface = tester.widget( + find + .descendant(of: destination, matching: find.byType(Material)) + .first, ); + expect(surface.color, palette.primarySolid); await tester.tap(destination); await tester.pumpAndSettle(); expect( @@ -558,13 +556,13 @@ void main() { ); expect(tester.takeException(), isNull); } - for (var index = 1; index < labelRects.length; index++) { - expect( - labelRects[index - 1].overlaps(labelRects[index]), - isFalse, - reason: 'Adjacent default navigation labels must remain separate', - ); - } + final destinationHeight = tester + .getSize(find.byKey(const ValueKey('primary-navigation-connect'))) + .height; + expect( + destinationHeight, + scale > 1 ? greaterThan(56) : greaterThanOrEqualTo(56), + ); if (locale.languageCode == 'fa') { expect( tester @@ -1070,11 +1068,12 @@ void main() { } else { expect( tester - .widget( - find.byKey(const ValueKey('primary-mobile-navigation')), + .widget( + find.byKey(ValueKey('primary-navigation-${primary.name}')), ) - .currentIndex, - primary.index, + .properties + .selected, + isTrue, ); } expect(tester.takeException(), isNull); diff --git a/test/pages/theme/theme_test.dart b/test/pages/theme/theme_test.dart index bb0e400e4..ee1d5cf83 100644 --- a/test/pages/theme/theme_test.dart +++ b/test/pages/theme/theme_test.dart @@ -79,7 +79,10 @@ void main() { expect(material.colorScheme.surface, palette.card); expect(material.iconTheme.color, palette.foreground); expect(material.dividerTheme.color, palette.border); - expect(material.navigationBarTheme, const NavigationBarThemeData()); + expect( + material.navigationBarTheme.backgroundColor, + palette.card.withValues(alpha: 0.96), + ); expect(material.appBarTheme.backgroundColor, palette.background); expect(material.appBarTheme.surfaceTintColor, Colors.transparent); expect(material.appBarTheme.elevation, 0); @@ -237,10 +240,22 @@ void main() { expect(material.appBarTheme.surfaceTintColor, isNull); expect(material.appBarTheme.elevation, isNull); expect(material.appBarTheme.scrolledUnderElevation, isNull); - expect(material.navigationBarTheme, const NavigationBarThemeData()); + final navigation = material.navigationBarTheme; + expect(navigation.height, isNull); + expect(navigation.indicatorColor, Colors.transparent); + expect(navigation.iconTheme!.resolve({})!.size, isNull); + expect( + navigation.iconTheme!.resolve({})!.color, + AppPalette.light.mutedStrong, + ); + expect(navigation.labelTextStyle!.resolve({})!.fontSize, isNull); + expect( + navigation.labelTextStyle!.resolve({})!.fontWeight, + FontWeight.w400, + ); expect( - material.bottomNavigationBarTheme, - const BottomNavigationBarThemeData(), + navigation.labelTextStyle!.resolve({WidgetState.selected})!.fontWeight, + FontWeight.w600, ); expect( material.filledButtonTheme.style!.minimumSize!.resolve({}), From 62c88b4fc11fc8d5822820c17096f87adc164beb Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 18:04:51 +0800 Subject: [PATCH 41/60] refactor(json): fill editor workspaces and preserve a minimum height --- docs/app.md | 3 +- docs/validation.md | 4 +- lib/pages/connect/json_editor/page.dart | 364 +++++------------- lib/pages/servers/editor/page.dart | 48 ++- lib/pages/servers/form_page.dart | 22 +- lib/pages/servers/import/page.dart | 115 +++--- .../shared/widgets/json_editor_workspace.dart | 110 ++++++ .../shared/widgets/outbound_json_editor.dart | 18 +- lib/pages/theme/layout.dart | 1 + test/pages/connect/raw_editor_test.dart | 133 +++++++ test/pages/servers/editor_dismissal_test.dart | 159 +++++--- test/pages/servers/import_test.dart | 172 ++++++--- .../widgets/outbound_json_editor_test.dart | 150 ++++++++ 13 files changed, 817 insertions(+), 482 deletions(-) create mode 100644 lib/pages/shared/widgets/json_editor_workspace.dart diff --git a/docs/app.md b/docs/app.md index a832e238f..bcb8348cf 100644 --- a/docs/app.md +++ b/docs/app.md @@ -182,7 +182,8 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。 资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。 导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。 -节点、Raw 与高级 JSON 使用全宽表单和原有 `re_editor`,保留诊断位置、补全、撤销及固定保存区。 +节点、手动添加、Raw 与高级 JSON 复用全宽编辑工作区和原有 `re_editor`:编辑器占满 AppBar 与固定操作区之间扣除名称、工具和必要提示后的剩余宽高,不设置固定编辑器高度或正文最大宽度,编辑器保留 160 逻辑像素的最小高度。 +名称与工具、提示可分别滚动;正常空间仅在代码编辑器内部滚动,横屏、短屏或键盘压缩到最小布局以下时,工作区整体允许滚动,保存区仍固定。诊断位置、补全与撤销保持不变。 按已加载或成功保存的原文判断未保存修改,Raw/高级同时比较名称;光标移动不算修改,手动还原原文和名称恢复干净状态。 已修改时,关闭、取消、系统返回和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。 侧滑受同一可退出状态约束,干净稿保留平台手势,不能越过未保存或保存中的退出限制。 diff --git a/docs/validation.md b/docs/validation.md index 10df4c85e..916ead239 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -74,7 +74,9 @@ 重命名、隐藏缓存或新计划不改写运行身份;未知速率/流量/时长不填假值。空库首屏的导入、恢复和完整配置入口可达。 目标选择器覆盖三标签、搜索、长列表按需构建、管理返回与取消不提交;来源/节点同数字 ID、Raw/provider 同名不串选。 重连确认取消保留持久化选择和运行快照;确认前资产被删或来源方式改变时不得按旧列表提交。 -- 节点、Raw 和高级 JSON 全宽编辑覆盖干净/修改/手动还原、关闭/取消/系统返回/Escape、继续/明确放弃、 +- 节点、手动添加、Raw 和高级 JSON 编辑检查编辑器随可用正文宽高伸缩,填满名称、工具与提示之外的剩余区域; + 正常空间不保留固定代码框高度或整页外层滚动。横屏、短屏和键盘下编辑器至少 160 逻辑像素,空间不足时工作区整体可滚动,名称/工具/必要提示仍可到达,保存区固定。 + 节点、Raw 和高级 JSON 全宽编辑覆盖干净/修改/手动还原、关闭/取消/系统返回/Escape、继续/明确放弃、 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;光标移动不算修改,名称与原文还原恢复干净。 旧节点弹窗另覆盖遮罩关闭,全宽页不增加遮罩路径;明确放弃不写库,成功保存不循环提示。 iOS 干净稿保留原侧滑,未保存或保存中的稿不能经手势绕过退出守卫,不以 Widget 返回替代真机手势验收。 diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index fb7bcc50e..ddec32364 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -8,7 +8,6 @@ import 'package:onexray/core/errors/json_diagnostic.dart'; import 'package:onexray/service/shared/json_editing.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/json_editor/controller.dart'; -import 'package:onexray/pages/connect/routing/widgets.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; @@ -17,8 +16,7 @@ import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/pages/shared/widgets/json_editor.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; -import 'package:onexray/pages/shared/widgets/responsive_content.dart'; -import 'package:onexray/pages/shared/widgets/section.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/service/shared/share/configuration_transfer.dart'; @@ -73,8 +71,6 @@ class _JsonConfigurationEditorState extends State { final l10n = AppLocalizations.of(context)!; final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final palette = ColorManager.palette(context); - const gap = 16.0; final canClose = !state.closeBlocked && !state.confirmingClose && @@ -154,157 +150,25 @@ class _JsonConfigurationEditorState extends State { ), body: SafeArea( bottom: false, - child: SingleChildScrollView( - padding: EdgeInsets.fromLTRB( - mobile ? 16 : AppSpacing.page, - 16, - mobile ? 16 : AppSpacing.page, - mobile ? 12 : AppSpacing.desktopPageBottom, - ), - child: ResponsiveContent( - desktopMaxWidth: AppLayout.routingMaxWidth, - child: controller.advanced - ? _advancedContent(context, state, mobile: mobile) - : RoutingCard( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, - children: [ - ConfigurationTransferTools( - controller: controller.transfers, - disabled: state.busy || !state.loaded, - ), - if (state.sharingDataCount - case final count? when count > 0) - Padding( - padding: const EdgeInsets.only( - top: 8, - ), - child: _RawNote( - l10n.prototypeSharingDataSourceLinks( - count, - ), - ), - ), - SizedBox(height: gap), - Text( - l10n.prototypeConfigurationName, - style: AppTypography.fieldLabel - .copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - ConstrainedBox( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent(44), - ), - child: TextField( - controller: controller.name, - maxLength: 32, - enabled: state.loaded, - style: AppTypography.rowValue, - decoration: InputDecoration( - hintText: - l10n.prototypeConfigurationName, - hintStyle: AppTypography.rowValue - .copyWith( - color: - palette.mutedForeground, - ), - counterText: '', - contentPadding: - const EdgeInsets.symmetric( - horizontal: 12, - ), - ), - ), - ), - SizedBox(height: gap), - Text( - controller.advanced - ? l10n.routingAdvancedTemplate - : l10n.prototypeCompleteXrayConfiguration, - style: AppTypography.fieldLabel - .copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - SizedBox( - height: AppJsonEditor.heightForViewport( - context, - mobile ? 392 : 412, - ), - child: AbsorbPointer( - absorbing: !state.loaded, - child: AppJsonEditor( - controller: controller.text, - kind: controller.advanced - ? JsonEditorKind.advancedRouting - : JsonEditorKind.raw, - diagnostic: - state.diagnostic ?? - (state.error == null - ? null - : JsonDiagnostic( - state.error!, - )), - domainSuggestions: - state.domainSuggestions, - ipSuggestions: state.ipSuggestions, - ), - ), - ), - if (controller.advanced) - Align( - alignment: - AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: () => controller - .openDocumentation(context), - icon: const Icon( - LucideIcons.bookOpen, - ), - label: Text( - l10n.prototypeDocumentation, - ), - ), - ), - if (!state.loaded && state.busy) - const LinearProgressIndicator(), - if (!state.loaded && state.error != null) - Padding( - padding: const EdgeInsets.only( - top: 12, - ), - child: Semantics( - liveRegion: true, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: - MediaQuery.sizeOf(context) - .height / - 4, - ), - child: SingleChildScrollView( - child: SelectableText( - state.error!, - style: AppTypography.rawNote - .copyWith( - color: - palette.destructive, - ), - ), - ), - ), - ), - ), - ], - ), - ), + child: JsonEditorWorkspace( + header: _workspaceHeader(context, state), + editor: AbsorbPointer( + absorbing: !state.loaded, + child: AppJsonEditor( + controller: controller.text, + kind: controller.advanced + ? JsonEditorKind.advancedRouting + : JsonEditorKind.raw, + diagnostic: + state.diagnostic ?? + (state.error == null + ? null + : JsonDiagnostic(state.error!)), + domainSuggestions: state.domainSuggestions, + ipSuggestions: state.ipSuggestions, + ), ), + footer: _workspaceFooter(context, state), ), ), bottomNavigationBar: PageActionBar( @@ -340,137 +204,101 @@ class _JsonConfigurationEditorState extends State { ), ); - Widget _advancedContent( + Widget _workspaceHeader( BuildContext context, - JsonConfigurationEditorState state, { - required bool mobile, - }) { + JsonConfigurationEditorState state, + ) { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); + final nameLabel = controller.advanced + ? l10n.configurationNameLabel + : l10n.prototypeConfigurationName; return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, - spacing: 20, + spacing: 8, children: [ - SectionView( - title: l10n.prototypeConfigurationName, - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l10n.configurationNameLabel, - style: AppTypography.fieldLabel.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 8), - ConstrainedBox( - constraints: BoxConstraints( - minHeight: AppLayout.touchExtent(44), - ), - child: Semantics( - label: l10n.configurationNameLabel, - child: TextField( - controller: controller.name, - maxLength: 32, - enabled: state.loaded, - style: AppTypography.rowValue, - decoration: InputDecoration( - hintText: l10n.configurationNameHint, - hintStyle: AppTypography.rowValue.copyWith( - color: palette.mutedForeground, - ), - counterText: '', - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - ), - ), - ), + Text( + nameLabel, + style: AppTypography.fieldLabel.copyWith(color: palette.foreground), + ), + ConstrainedBox( + constraints: BoxConstraints(minHeight: AppLayout.touchExtent(44)), + child: Semantics( + label: nameLabel, + child: TextField( + controller: controller.name, + maxLength: 32, + enabled: state.loaded, + style: AppTypography.rowValue, + decoration: InputDecoration( + hintText: controller.advanced + ? l10n.configurationNameHint + : l10n.prototypeConfigurationName, + hintStyle: AppTypography.rowValue.copyWith( + color: palette.mutedForeground, ), + counterText: '', + contentPadding: const EdgeInsets.symmetric(horizontal: 12), ), - ], + ), ), ), - SectionView( - title: l10n.routingAdvancedCardTitle, - icon: LucideIcons.fileJson, - action: PageHelpButton( - title: l10n.routingAdvancedCardTitle, - messages: [l10n.routingAdvancedNotice], - ), - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'JSON', + Row( + children: [ + Expanded( + child: Text( + controller.advanced + ? l10n.routingAdvancedCardTitle + : l10n.prototypeCompleteXrayConfiguration, style: AppTypography.fieldLabel.copyWith( color: palette.foreground, ), ), - const SizedBox(height: 8), - SizedBox( - height: AppJsonEditor.heightForViewport( - context, - mobile ? 392 : 412, - ), - child: AbsorbPointer( - absorbing: !state.loaded, - child: AppJsonEditor( - controller: controller.text, - kind: JsonEditorKind.advancedRouting, - diagnostic: - state.diagnostic ?? - (state.error == null - ? null - : JsonDiagnostic(state.error!)), - domainSuggestions: state.domainSuggestions, - ipSuggestions: state.ipSuggestions, - ), - ), - ), - const SizedBox(height: 12), - ConfigurationTransferTools( - controller: controller.transfers, - disabled: state.busy || !state.loaded, + ), + if (controller.advanced) ...[ + PageHelpButton( + title: l10n.routingAdvancedCardTitle, + messages: [l10n.routingAdvancedNotice], ), - if (state.sharingDataCount case final count? when count > 0) - Padding( - padding: const EdgeInsets.only(top: 8), - child: _RawNote(l10n.prototypeSharingDataSourceLinks(count)), - ), - Align( - alignment: AlignmentDirectional.centerStart, - child: TextButton.icon( - onPressed: () => controller.openDocumentation(context), - icon: const Icon(LucideIcons.bookOpen), - label: Text(l10n.prototypeDocumentation), - ), + IconButton( + tooltip: l10n.prototypeDocumentation, + onPressed: () => controller.openDocumentation(context), + icon: const Icon(LucideIcons.bookOpen), ), - if (!state.loaded && state.busy) const LinearProgressIndicator(), - if (!state.loaded && state.error != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Semantics( - liveRegion: true, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height / 4, - ), - child: SingleChildScrollView( - child: SelectableText( - state.error!, - style: AppTypography.rawNote.copyWith( - color: palette.destructive, - ), - ), - ), - ), - ), - ), ], - ), + ], + ), + ], + ); + } + + Widget _workspaceFooter( + BuildContext context, + JsonConfigurationEditorState state, + ) { + final l10n = AppLocalizations.of(context)!; + final palette = ColorManager.palette(context); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 8, + children: [ + ConfigurationTransferTools( + controller: controller.transfers, + disabled: state.busy || !state.loaded, ), + if (state.sharingDataCount case final count? when count > 0) + _RawNote(l10n.prototypeSharingDataSourceLinks(count)), + if (!state.loaded && state.busy) const LinearProgressIndicator(), + if (!state.loaded && state.error != null) + Semantics( + liveRegion: true, + child: SelectableText( + state.error!, + style: AppTypography.rawNote.copyWith(color: palette.destructive), + ), + ), ], ); } diff --git a/lib/pages/servers/editor/page.dart b/lib/pages/servers/editor/page.dart index 7b51a17c7..352b69b0c 100644 --- a/lib/pages/servers/editor/page.dart +++ b/lib/pages/servers/editor/page.dart @@ -8,6 +8,7 @@ import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; class ServerEditorPage extends StatelessWidget { final int serverId; @@ -37,9 +38,10 @@ class ServerEditorPage extends StatelessWidget { child: ServerFormPage( title: l.prototypeEditServer, onClose: () => controller.closePage(context), - body: ServerFormSection( - title: l.configurationContentTitle, - child: Column( + expandBody: true, + body: JsonEditorWorkspace( + header: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (state.name case final name? @@ -50,7 +52,7 @@ class ServerEditorPage extends StatelessWidget { color: p.foreground, ), ), - const SizedBox(height: 12), + const SizedBox(height: 8), ], Text( l.prototypeXrayOutboundJson, @@ -58,16 +60,19 @@ class ServerEditorPage extends StatelessWidget { color: p.foreground, ), ), - const SizedBox(height: 7), - OutboundJsonEditor( - controller: controller.text, - diagnostic: - state.diagnostic ?? - (state.error == null - ? null - : JsonDiagnostic(state.error!)), - ), - const SizedBox(height: 7), + ], + ), + editor: OutboundJsonEditor( + controller: controller.text, + expand: true, + diagnostic: + state.diagnostic ?? + (state.error == null ? null : JsonDiagnostic(state.error!)), + ), + footer: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ Text( state.fromSubscription ? l.prototypeSubscriptionOutboundHint @@ -81,17 +86,10 @@ class ServerEditorPage extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 12), child: Semantics( liveRegion: true, - child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.sizeOf(context).height / 4, - ), - child: SingleChildScrollView( - child: SelectableText( - state.error!, - style: AppTypography.subscriptionInfo.copyWith( - color: p.destructive, - ), - ), + child: SelectableText( + state.error!, + style: AppTypography.subscriptionInfo.copyWith( + color: p.destructive, ), ), ), diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart index d2d871fd0..b85e02219 100644 --- a/lib/pages/servers/form_page.dart +++ b/lib/pages/servers/form_page.dart @@ -42,6 +42,7 @@ class ServerFormPage extends StatelessWidget { this.subtitle, required this.body, this.actions = const [], + this.expandBody = false, this.onBack, this.onClose, }); @@ -49,6 +50,9 @@ class ServerFormPage extends StatelessWidget { final String? subtitle; final Widget body; final List actions; + + /// Gives an editor workspace the Scaffold's finite remaining space. + final bool expandBody; final VoidCallback? onBack, onClose; @override @@ -75,14 +79,16 @@ class ServerFormPage extends StatelessWidget { ], ), body: SafeArea( - child: ResponsiveContent( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [body, const SizedBox(height: 24)], - ), - ), - ), + child: expandBody + ? body + : ResponsiveContent( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [body, const SizedBox(height: 24)], + ), + ), + ), ), bottomNavigationBar: actions.isEmpty ? null diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index f4120b5af..5fec5c950 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -16,6 +16,7 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/pages/servers/form_page.dart'; @@ -183,45 +184,46 @@ class ServerImportFormPage extends StatelessWidget { : l10n.prototypePasteLink, onBack: onBack, onClose: onClose ?? () => controller.closeFlow(context), - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (subscription) - Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 16 : 28, - 16, - mobile ? 16 : 28, - 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (controller.editingSubscription) ...[ - Text( - l10n.prototypeChangesApplyToFutureUpdates, - style: AppTypography.supporting.copyWith( - color: ColorManager.palette(context) - .mutedForeground, - ), + expandBody: manual, + body: manual + ? _manual(context) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (subscription) + Padding( + padding: EdgeInsets.fromLTRB( + mobile ? 16 : 28, + 16, + mobile ? 16 : 28, + 0, ), - const SizedBox(height: 16), - ], - _subscription(context), - ], - ), - ) - else if (manual) - _manual(context) - else - _paste(context), - _ImportFeedback(state: state, showError: !manual), - ConnectCallout( - icon: LucideIcons.lockKeyhole, - text: l10n.prototypeLocalInputPrivacy, - ), - ], - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (controller.editingSubscription) ...[ + Text( + l10n.prototypeChangesApplyToFutureUpdates, + style: AppTypography.supporting.copyWith( + color: ColorManager.palette(context) + .mutedForeground, + ), + ), + const SizedBox(height: 16), + ], + _subscription(context), + ], + ), + ) + else + _paste(context), + _ImportFeedback(state: state, showError: !manual), + ConnectCallout( + icon: LucideIcons.lockKeyhole, + text: l10n.prototypeLocalInputPrivacy, + ), + ], + ), actions: [ ConnectDialogButton( label: l10n.prototypeCancel, @@ -319,30 +321,35 @@ class ServerImportFormPage extends StatelessWidget { Widget _manual(BuildContext context) { final l10n = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - return ServerFormSection( - title: l10n.configurationContentTitle, - child: Column( - spacing: 7, + return JsonEditorWorkspace( + header: Text( + l10n.prototypeXrayNodeJson, + style: AppTypography.fieldLabel.copyWith(color: palette.foreground), + ), + editor: OutboundJsonEditor( + controller: controller.jsonText, + expand: true, + diagnostic: + controller.state.jsonDiagnostic ?? + (controller.state.error == null + ? null + : JsonDiagnostic(controller.state.error!)), + ), + footer: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - l10n.prototypeXrayNodeJson, - style: AppTypography.fieldLabel.copyWith(color: palette.foreground), - ), - OutboundJsonEditor( - controller: controller.jsonText, - diagnostic: - controller.state.jsonDiagnostic ?? - (controller.state.error == null - ? null - : JsonDiagnostic(controller.state.error!)), - ), Text( l10n.prototypeNodeJsonHint, style: AppTypography.importJsonHint.copyWith( color: palette.mutedForeground, ), ), + _ImportFeedback(state: controller.state, showError: false), + ConnectCallout( + icon: LucideIcons.lockKeyhole, + text: l10n.prototypeLocalInputPrivacy, + ), ], ), ); diff --git a/lib/pages/shared/widgets/json_editor_workspace.dart b/lib/pages/shared/widgets/json_editor_workspace.dart new file mode 100644 index 000000000..bb34f9fed --- /dev/null +++ b/lib/pages/shared/widgets/json_editor_workspace.dart @@ -0,0 +1,110 @@ +import 'dart:math' as math; + +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/pages/theme/layout.dart'; + +/// A bounded page body. The enclosing Scaffold/SafeArea owns system insets. +class JsonEditorWorkspace extends StatefulWidget { + const JsonEditorWorkspace({ + super.key, + required this.editor, + this.header, + this.footer, + }); + + final Widget editor; + final Widget? header; + final Widget? footer; + + @override + State createState() => _JsonEditorWorkspaceState(); +} + +class _JsonEditorWorkspaceState extends State { + // Adding the short-screen viewport must not replace the editor's focus, + // selection or internal scroll controllers. + final _canvasKey = GlobalKey(); + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) { + assert( + constraints.hasBoundedWidth && constraints.hasBoundedHeight, + 'JsonEditorWorkspace requires bounded page-body constraints.', + ); + final mobile = + MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; + // Shrink chrome spacing when a keyboard or a short window leaves little + // body space. Natural-height chrome never takes the editor's whole slot. + final spacingScale = math.min(1.0, constraints.maxHeight / 240); + final top = 16 * spacingScale; + final bottom = 12 * spacingScale; + final headerGap = widget.header == null ? 0.0 : 16 * spacingScale; + final footerGap = widget.footer == null ? 0.0 : 7 * spacingScale; + final panelCount = + (widget.header == null ? 0 : 1) + (widget.footer == null ? 0 : 1); + final spacingHeight = top + bottom + headerGap + footerGap; + final canvasHeight = math.max( + constraints.maxHeight, + spacingHeight + AppLayout.jsonEditorMinHeight + 48 * panelCount, + ); + final contentHeight = canvasHeight - spacingHeight; + // Reserve the minimum editor height and a usable viewport for each panel. + // Short content keeps its natural height; long content scrolls locally. + final chromeLimit = panelCount == 0 + ? 0.0 + : math.min( + contentHeight / 4, + (contentHeight - AppLayout.jsonEditorMinHeight) / panelCount, + ); + final canvas = Padding( + key: _canvasKey, + padding: EdgeInsets.fromLTRB( + mobile ? AppSpacing.mobilePage : AppSpacing.page, + top, + mobile ? AppSpacing.mobilePage : AppSpacing.page, + bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.header != null) ...[ + ConstrainedBox( + constraints: BoxConstraints(maxHeight: chromeLimit), + child: SingleChildScrollView( + key: const ValueKey('json-editor-workspace-header'), + primary: false, + child: widget.header, + ), + ), + SizedBox(height: headerGap), + ], + Expanded( + key: const ValueKey('json-editor-workspace-editor'), + child: widget.editor, + ), + if (widget.footer != null) ...[ + SizedBox(height: footerGap), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: chromeLimit), + child: SingleChildScrollView( + key: const ValueKey('json-editor-workspace-footer'), + primary: false, + child: widget.footer, + ), + ), + ], + ], + ), + ); + if (canvasHeight > constraints.maxHeight) { + return SingleChildScrollView( + key: const ValueKey('json-editor-workspace-scroll'), + primary: false, + child: SizedBox(height: canvasHeight, child: canvas), + ); + } + return canvas; + }, + ); +} diff --git a/lib/pages/shared/widgets/outbound_json_editor.dart b/lib/pages/shared/widgets/outbound_json_editor.dart index 49fb524d8..cfe600877 100644 --- a/lib/pages/shared/widgets/outbound_json_editor.dart +++ b/lib/pages/shared/widgets/outbound_json_editor.dart @@ -6,31 +6,35 @@ import 'package:onexray/pages/shared/widgets/json_editor.dart'; import 'package:onexray/service/shared/json_editing.dart'; import 'package:re_editor/re_editor.dart'; -/// Compact outbound JSON input used by manual import and server editing. +/// Outbound JSON input, compact by default or expanded in a page workspace. class OutboundJsonEditor extends StatelessWidget { const OutboundJsonEditor({ super.key, required this.controller, this.diagnostic, + this.expand = false, }); final CodeLineEditingController controller; final JsonDiagnostic? diagnostic; + final bool expand; @override Widget build(BuildContext context) { + final editor = AppJsonEditor( + controller: controller, + diagnostic: diagnostic, + kind: JsonEditorKind.outbound, + textStyle: AppTypography.importJson, + ); + if (expand) return editor; final height = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint ? 340 : 290; return SizedBox( height: AppJsonEditor.heightForViewport(context, height.toDouble()), - child: AppJsonEditor( - controller: controller, - diagnostic: diagnostic, - kind: JsonEditorKind.outbound, - textStyle: AppTypography.importJson, - ), + child: editor, ); } } diff --git a/lib/pages/theme/layout.dart b/lib/pages/theme/layout.dart index f842844f3..9cd670448 100644 --- a/lib/pages/theme/layout.dart +++ b/lib/pages/theme/layout.dart @@ -23,6 +23,7 @@ abstract final class AppLayout { static const settingsMaxWidth = 960.0; static const routingMaxWidth = 960.0; static const routingEditorMaxWidth = 760.0; + static const jsonEditorMinHeight = 160.0; static const contentBreakpoint = 840.0; static const pageActionMinHeight = 72.0; static const mobilePageActionMinHeight = 64.0; diff --git a/test/pages/connect/raw_editor_test.dart b/test/pages/connect/raw_editor_test.dart index 0d3b4abfe..a40291775 100644 --- a/test/pages/connect/raw_editor_test.dart +++ b/test/pages/connect/raw_editor_test.dart @@ -14,7 +14,10 @@ import 'package:onexray/pages/connect/json_editor/controller.dart'; import 'package:onexray/pages/connect/json_editor/page.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/json_editor.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; +import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/service/connect/raw/editor.dart'; import 'package:onexray/service/connect/routing/custom/advanced.dart'; import 'package:onexray/service/connect/routing/custom/editor.dart'; @@ -148,6 +151,130 @@ void main() { expect(tester.takeException(), isNull); }); + for (final advanced in [false, true]) { + for (final size in const [ + Size(390, 844), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + '${advanced ? "advanced" : "Raw"} JSON fills the remaining workspace at $size', + (tester) async { + final fixture = _DiagnosticFixture(advanced); + addTearDown(fixture.dispose); + final session = await _mountFullEditor(tester, fixture, size: size); + final l = AppLocalizations.of(session.context)!; + final workspace = find.byType(JsonEditorWorkspace); + final editor = find.byType(AppJsonEditor); + final slot = find.byKey( + const ValueKey('json-editor-workspace-editor'), + ); + final header = find.byKey( + const ValueKey('json-editor-workspace-header'), + ); + final footer = find.byKey( + const ValueKey('json-editor-workspace-footer'), + ); + expect(workspace, findsOneWidget); + expect(editor, findsOneWidget); + expect( + find.ancestor( + of: editor, + matching: find.byType(SingleChildScrollView), + ), + findsNothing, + ); + expect( + find.ancestor(of: editor, matching: find.byType(ShadCard)), + findsNothing, + ); + final editorRect = tester.getRect(editor); + expect(editorRect, tester.getRect(slot)); + final gutter = size.width <= 720 ? 16.0 : 28.0; + expect(editorRect.left, closeTo(gutter, .01)); + expect(editorRect.right, closeTo(size.width - gutter, .01)); + expect(editorRect.top, greaterThan(tester.getRect(header).bottom)); + expect(editorRect.bottom, lessThan(tester.getRect(footer).top)); + expect(editorRect.height, greaterThan(0)); + if (size.width > 1000) { + expect(editorRect.width, greaterThan(960)); + } + expect( + find.descendant( + of: footer, + matching: find.byType(ConfigurationTransferTools), + ), + findsOneWidget, + ); + final nameField = find.byWidgetPredicate( + (widget) => + widget is TextField && + identical(widget.controller, fixture.controller.name), + ); + expect( + find.descendant(of: header, matching: nameField), + findsOneWidget, + ); + expect(tester.widget(nameField).maxLength, 32); + if (advanced) { + expect( + find.ancestor( + of: nameField, + matching: find.byWidgetPredicate( + (widget) => + widget is Semantics && + widget.properties.label == l.configurationNameLabel, + ), + ), + findsOneWidget, + ); + expect(find.byTooltip(l.prototypeDocumentation), findsOneWidget); + expect( + find.byTooltip('${l.pageHelp} · ${l.routingAdvancedCardTitle}'), + findsOneWidget, + ); + } + await tester.enterText(nameField, 'Workspace draft'); + final source = const JsonEncoder.withIndent(' ').convert({ + 'outbounds': [{}], + 'routing': {'rules': []}, + 'notes': List.generate(160, (index) => 'Line $index'), + }); + fixture.controller.text.text = source; + await tester.pumpAndSettle(); + final vertical = find.descendant( + of: find.byType(CodeEditor), + matching: find.byWidgetPredicate( + (widget) => + widget is Scrollable && + (widget.axisDirection == AxisDirection.down || + widget.axisDirection == AxisDirection.up), + ), + ); + expect(vertical, findsOneWidget); + final position = tester.state(vertical).position; + expect(position.maxScrollExtent, greaterThan(400)); + position.jumpTo(320); + await tester.pumpAndSettle(); + expect(position.pixels, 320); + final previousHeight = tester.getSize(editor).height; + tester.view.physicalSize = Size(size.width, size.height + 200); + await tester.pumpAndSettle(); + expect( + tester.getSize(editor).height, + closeTo(previousHeight + 200, .01), + ); + expect(fixture.controller.name.text, 'Workspace draft'); + expect(fixture.controller.text.text, source); + expect(fixture.controller.state.dirty, isTrue); + expect(position.pixels, 320); + expect(session.result.isCompleted, isFalse); + expect(tester.takeException(), isNull); + }, + ); + } + } + testWidgets( 'advanced editor uses its own template and keeps imported fields on save', (tester) async { @@ -234,6 +361,12 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); + expect(find.byType(JsonEditorWorkspace), findsOneWidget); + expect( + tester.getSize(find.byType(AppJsonEditor)).height, + greaterThanOrEqualTo(AppLayout.jsonEditorMinHeight), + ); + final cancel = find.widgetWithText(ShadButton, l.prototypeCancel); final save = find.widgetWithText(ShadButton, l.prototypeSave); final delete = find.widgetWithText(ShadButton, l.prototypeDelete); diff --git a/test/pages/servers/editor_dismissal_test.dart b/test/pages/servers/editor_dismissal_test.dart index 77a1ede6a..cc7f86518 100644 --- a/test/pages/servers/editor_dismissal_test.dart +++ b/test/pages/servers/editor_dismissal_test.dart @@ -10,8 +10,10 @@ import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/servers/editor/controller.dart'; import 'package:onexray/pages/servers/editor/page.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; import 'package:onexray/pages/shared/widgets/settings_page.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/server.dart'; import 'package:onexray/service/settings/language/locale.dart'; @@ -130,67 +132,108 @@ void main() { } } - testWidgets('node JSON card keeps its draft and save above the keyboard', ( - tester, - ) async { - final session = await openEditor(tester, size: const Size(390, 844)); - final l = AppLocalizations.of(session.context)!; - final editor = find.byType(OutboundJsonEditor); - final card = find.ancestor(of: editor, matching: find.byType(ShadCard)); - expect(card, findsOneWidget); - expect( - find.descendant(of: card, matching: find.text(_original.name)), - findsOneWidget, - ); - expect( - find.descendant( - of: find.byType(AppBar), - matching: find.text(_original.name), - ), - findsNothing, - ); - expect( - find.descendant( - of: card, - matching: find.text(l.configurationContentTitle), - ), - findsOneWidget, - ); - expect( - find.descendant( - of: card, - matching: find.text(l.prototypeXrayOutboundJson), - ), - findsOneWidget, - ); - expect(tester.getRect(editor).left, greaterThan(tester.getRect(card).left)); - expect(tester.getRect(editor).right, lessThan(tester.getRect(card).right)); + for (final size in const [ + Size(390, 844), + Size(390, 667), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'node JSON fills remaining space and keeps keyboard actions at $size', + (tester) async { + final session = await openEditor(tester, size: size); + final l = AppLocalizations.of(session.context)!; + final editor = find.byType(OutboundJsonEditor); + final workspace = find.byType(JsonEditorWorkspace); + expect(workspace, findsOneWidget); + expect(tester.widget(editor).expand, isTrue); + expect( + find.ancestor(of: editor, matching: find.byType(ShadCard)), + findsNothing, + ); + expect( + find.ancestor(of: editor, matching: find.byType(Scrollable)), + findsNothing, + ); + expect( + find.descendant(of: workspace, matching: find.text(_original.name)), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text(_original.name), + ), + findsNothing, + ); + expect( + find.descendant( + of: workspace, + matching: find.text(l.prototypeXrayOutboundJson), + ), + findsOneWidget, + ); + final gutter = size.width <= 720 ? 16.0 : 28.0; + expect(tester.getRect(editor).left, closeTo(gutter, .01)); + expect(tester.getRect(editor).right, closeTo(size.width - gutter, .01)); + final initialHeight = tester.getSize(editor).height; + tester.view.physicalSize = Size(size.width, size.height + 160); + await tester.pump(); + expect(tester.getSize(editor).height, greaterThan(initialHeight + 120)); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - addTearDown(tester.view.resetViewInsets); - await tester.pump(); - tester - .widget(find.byType(CodeEditor)) - .focusNode! - .requestFocus(); - await tester.pump(); - await tester.ensureVisible(editor); - await tester.pump(const Duration(milliseconds: 200)); + tester.view.physicalSize = size; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + tester + .widget(find.byType(CodeEditor)) + .focusNode! + .requestFocus(); + await tester.pump(const Duration(milliseconds: 200)); - final save = find.widgetWithText(FilledButton, l.prototypeSave); - expect(save.hitTestable(), findsOneWidget); - expect(tester.widget(save).onPressed, isNotNull); - expect(tester.getRect(save).bottom, lessThanOrEqualTo(544)); - expect( - tester - .getRect(find.byType(CodeEditor)) - .overlaps(Rect.fromLTRB(0, 0, 390, tester.getRect(save).top)), - isTrue, + final save = find.widgetWithText(FilledButton, l.prototypeSave); + expect(save.hitTestable(), findsOneWidget); + expect(tester.widget(save).onPressed, isNotNull); + expect( + tester.getRect(save).bottom, + lessThanOrEqualTo(size.height - 300), + ); + final actionRect = tester.getRect(save); + final bodyScrollers = find.ancestor( + of: editor, + matching: find.byType(Scrollable), + ); + if (bodyScrollers.evaluate().isNotEmpty) { + final position = tester + .state(bodyScrollers.first) + .position; + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + } + await tester.ensureVisible(editor); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(save), actionRect); + expect(save.hitTestable(), findsOneWidget); + expect(find.byType(CodeEditor).hitTestable(), findsOneWidget); + expect( + tester.getSize(editor).height, + greaterThanOrEqualTo(AppLayout.jsonEditorMinHeight), + ); + expect(tester.getSize(editor).height, lessThan(initialHeight)); + expect( + tester + .getRect(find.byType(CodeEditor)) + .overlaps( + Rect.fromLTRB(0, 0, size.width, tester.getRect(save).top), + ), + isTrue, + ); + expect(controller.text.text, _initial); + expect(controller.state.dirty, isFalse); + expect(tester.takeException(), isNull); + }, ); - expect(controller.text.text, _initial); - expect(controller.state.dirty, isFalse); - expect(tester.takeException(), isNull); - }); + } for (final restored in [false, true]) { testWidgets('clean editor closes without saving (restored: $restored)', ( diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index 2b9296942..77dbf1551 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -16,9 +16,11 @@ import 'package:onexray/pages/servers/import/controller.dart'; import 'package:onexray/pages/servers/import/page.dart'; import 'package:onexray/pages/servers/subscription/form_view.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/service/servers/import.dart'; import 'package:onexray/service/shared/db/config_writer.dart'; @@ -79,69 +81,119 @@ void main() { }, ); - testWidgets('manual JSON card keeps its input and add above the keyboard', ( - tester, - ) async { - _mobileViewport(tester); - final controller = ServerImportController( - loadSubscription: (_) async => null, - ); - addTearDown(controller.close); - const source = '{"tag":"Manual draft","protocol":"freedom"}'; - controller.jsonText.text = source; - await tester.pumpWidget( - _app( - ServerImportFormPage( - controller: controller, - action: ServerImportAction.json, - ), - ), - ); - await tester.pump(); - final l = AppLocalizations.of( - tester.element(find.byType(ServerImportFormPage)), - )!; - final editor = find.byType(OutboundJsonEditor); - final card = find.ancestor(of: editor, matching: find.byType(ShadCard)); - expect(card, findsOneWidget); - expect( - find.descendant( - of: card, - matching: find.text(l.configurationContentTitle), - ), - findsOneWidget, - ); - expect( - find.descendant(of: card, matching: find.text(l.prototypeXrayNodeJson)), - findsOneWidget, - ); - expect(tester.getRect(editor).left, greaterThan(tester.getRect(card).left)); - expect(tester.getRect(editor).right, lessThan(tester.getRect(card).right)); + for (final size in const [ + Size(390, 844), + Size(390, 667), + Size(1032, 1376), + Size(1160, 720), + ]) { + testWidgets( + 'manual JSON fills remaining space and keeps keyboard actions at $size', + (tester) async { + _mobileViewport(tester); + tester.view.physicalSize = size; + final controller = ServerImportController( + loadSubscription: (_) async => null, + ); + addTearDown(controller.close); + const source = '{"tag":"Manual draft","protocol":"freedom"}'; + controller.jsonText.text = source; + await tester.pumpWidget( + _app( + ServerImportFormPage( + controller: controller, + action: ServerImportAction.json, + ), + ), + ); + await tester.pump(); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportFormPage)), + )!; + final editor = find.byType(OutboundJsonEditor); + final workspace = find.byType(JsonEditorWorkspace); + expect(workspace, findsOneWidget); + expect(tester.widget(editor).expand, isTrue); + expect( + find.ancestor(of: editor, matching: find.byType(ShadCard)), + findsNothing, + ); + expect( + find.ancestor(of: editor, matching: find.byType(Scrollable)), + findsNothing, + ); + expect( + find.descendant( + of: workspace, + matching: find.text(l.prototypeXrayNodeJson), + ), + findsOneWidget, + ); + expect(find.text(l.prototypeNodeJsonHint), findsOneWidget); + expect(find.text(l.prototypeLocalInputPrivacy), findsOneWidget); + final gutter = size.width <= 720 ? 16.0 : 28.0; + expect(tester.getRect(editor).left, closeTo(gutter, .01)); + expect(tester.getRect(editor).right, closeTo(size.width - gutter, .01)); + final initialHeight = tester.getSize(editor).height; + tester.view.physicalSize = Size(size.width, size.height + 160); + await tester.pump(); + expect( + tester.getSize(editor).height, + greaterThanOrEqualTo(initialHeight + 120 - .01), + ); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - addTearDown(tester.view.resetViewInsets); - await tester.pump(); - tester - .widget(find.byType(CodeEditor)) - .focusNode! - .requestFocus(); - await tester.pump(); - await tester.ensureVisible(editor); - await tester.pump(const Duration(milliseconds: 200)); + tester.view.physicalSize = size; + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.resetViewInsets); + await tester.pump(); + tester + .widget(find.byType(CodeEditor)) + .focusNode! + .requestFocus(); + await tester.pump(const Duration(milliseconds: 200)); - final add = find.widgetWithText(FilledButton, l.prototypeAdd); - expect(add.hitTestable(), findsOneWidget); - expect(tester.widget(add).onPressed, isNotNull); - expect(tester.getRect(add).bottom, lessThanOrEqualTo(544)); - expect( - tester - .getRect(find.byType(CodeEditor)) - .overlaps(Rect.fromLTRB(0, 0, 390, tester.getRect(add).top)), - isTrue, + final add = find.widgetWithText(FilledButton, l.prototypeAdd); + expect(add.hitTestable(), findsOneWidget); + expect(tester.widget(add).onPressed, isNotNull); + expect( + tester.getRect(add).bottom, + lessThanOrEqualTo(size.height - 300), + ); + final actionRect = tester.getRect(add); + final bodyScrollers = find.ancestor( + of: editor, + matching: find.byType(Scrollable), + ); + if (bodyScrollers.evaluate().isNotEmpty) { + final position = tester + .state(bodyScrollers.first) + .position; + position.jumpTo(position.maxScrollExtent); + await tester.pump(); + } + await tester.ensureVisible(editor); + await tester.pump(const Duration(milliseconds: 200)); + expect(tester.getRect(add), actionRect); + expect(add.hitTestable(), findsOneWidget); + expect(find.byType(CodeEditor).hitTestable(), findsOneWidget); + expect( + tester.getSize(editor).height, + greaterThanOrEqualTo(AppLayout.jsonEditorMinHeight), + ); + expect(tester.getSize(editor).height, lessThan(initialHeight)); + expect( + tester + .getRect(find.byType(CodeEditor)) + .overlaps( + Rect.fromLTRB(0, 0, size.width, tester.getRect(add).top), + ), + isTrue, + ); + expect(controller.jsonText.text, source); + expect(tester.takeException(), isNull); + }, ); - expect(controller.jsonText.text, source); - expect(tester.takeException(), isNull); - }); + } test( 'panels share the default UA and preserve manual input while loading', diff --git a/test/pages/shared/widgets/outbound_json_editor_test.dart b/test/pages/shared/widgets/outbound_json_editor_test.dart index 81cd87e44..a36d03d5b 100644 --- a/test/pages/shared/widgets/outbound_json_editor_test.dart +++ b/test/pages/shared/widgets/outbound_json_editor_test.dart @@ -5,11 +5,161 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/pages/shared/widgets/json_editor_workspace.dart'; import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:re_editor/re_editor.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { + testWidgets('expanded workspace fills the body and bounds keyboard chrome', ( + tester, + ) async { + final controller = CodeLineEditingController.fromText( + '{"tag":"Local draft","protocol":"socks"}', + ); + addTearDown(controller.dispose); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + var footerActions = 0; + Future show({required bool keyboard}) async { + tester.view.physicalSize = keyboard + ? const Size(844, 390) + : const Size(1160, 720); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(viewInsets: EdgeInsets.only(bottom: keyboard ? 200 : 0)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light), + child: child!, + ), + ), + home: Scaffold( + appBar: AppBar(title: const Text('JSON')), + body: SafeArea( + top: false, + bottom: false, + child: JsonEditorWorkspace( + header: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const TextField( + decoration: InputDecoration(labelText: 'Name'), + ), + if (keyboard) + for (var i = 0; i < 12; i++) Text('Header guidance $i'), + ], + ), + editor: OutboundJsonEditor( + controller: controller, + expand: true, + ), + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (keyboard) + for (var i = 0; i < 12; i++) Text('Footer feedback $i'), + TextButton( + key: const ValueKey('workspace-footer-action'), + onPressed: () => footerActions++, + child: const Text('Footer action'), + ), + ], + ), + ), + ), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton(onPressed: () {}, child: const Text('Save')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + final editor = find.byType(OutboundJsonEditor); + await show(keyboard: false); + expect(tester.getSize(editor).width, 1160 - 56); + expect(tester.getSize(editor).height, greaterThan(290)); + expect( + find.ancestor(of: editor, matching: find.byType(Scrollable)), + findsNothing, + ); + final original = tester.widget(find.byType(CodeEditor)); + final focus = original.focusNode!; + focus.requestFocus(); + await tester.pump(); + await show(keyboard: true); + final rect = tester.getRect(editor); + expect(rect.width, 844 - 56); + expect( + rect.height, + greaterThanOrEqualTo(AppLayout.jsonEditorMinHeight - .01), + ); + final expanded = tester.widget(find.byType(CodeEditor)); + expect(expanded.controller, same(controller)); + expect(expanded.focusNode, same(focus)); + expect(expanded.scrollController, same(original.scrollController)); + expect(focus.hasFocus, isTrue); + final header = find.byKey(const ValueKey('json-editor-workspace-header')); + final footer = find.byKey(const ValueKey('json-editor-workspace-footer')); + ScrollableState scroll(Finder panel) => tester.state( + find.descendant(of: panel, matching: find.byType(Scrollable)).first, + ); + final headerScroll = scroll(header); + final footerScroll = scroll(footer); + final outer = find.byKey(const ValueKey('json-editor-workspace-scroll')); + final outerScroll = scroll(outer); + expect(outerScroll.position.maxScrollExtent, greaterThan(0)); + final viewport = tester.getRect(outer); + expect(viewport.bottom, lessThanOrEqualTo(390 - 200)); + final save = find.widgetWithText(FilledButton, 'Save'); + final saveRect = tester.getRect(save); + await tester.ensureVisible(editor); + await tester.pumpAndSettle(); + expect(tester.getRect(editor).overlaps(viewport), isTrue); + outerScroll.position.jumpTo(outerScroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + final scrolledEditor = tester.getRect(editor); + expect(headerScroll.position.maxScrollExtent, greaterThan(0)); + expect(footerScroll.position.maxScrollExtent, greaterThan(0)); + expect(headerScroll.position, isNot(same(footerScroll.position))); + headerScroll.position.jumpTo(headerScroll.position.maxScrollExtent); + footerScroll.position.jumpTo(footerScroll.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(tester.getRect(editor), scrolledEditor); + expect(tester.getRect(save), saveRect); + expect(save.hitTestable(), findsOneWidget); + expect( + find.byKey(const ValueKey('workspace-footer-action')).hitTestable(), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('workspace-footer-action'))); + expect(footerActions, 1); + expect(controller.text, '{"tag":"Local draft","protocol":"socks"}'); + focus.requestFocus(); + await tester.pump(); + await show(keyboard: false); + expect(outer, findsNothing); + expect( + tester.widget(find.byType(CodeEditor)).focusNode, + same(focus), + ); + expect(focus.hasFocus, isTrue); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('long outbound JSON stays bounded, scrollable and LTR in fa', ( tester, ) async { From 8e0364f70d1b46c801f7b109a1c9d2f351d788f0 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 18:21:18 +0800 Subject: [PATCH 42/60] fix(json): reveal the caret in short editor viewports --- docs/app.md | 3 +- lib/pages/shared/widgets/json_editor.dart | 122 ++++++++++++++---- .../widgets/outbound_json_editor_test.dart | 105 +++++++++++++++ 3 files changed, 207 insertions(+), 23 deletions(-) diff --git a/docs/app.md b/docs/app.md index bcb8348cf..4a6d1d54e 100644 --- a/docs/app.md +++ b/docs/app.md @@ -183,7 +183,8 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。 导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。 节点、手动添加、Raw 与高级 JSON 复用全宽编辑工作区和原有 `re_editor`:编辑器占满 AppBar 与固定操作区之间扣除名称、工具和必要提示后的剩余宽高,不设置固定编辑器高度或正文最大宽度,编辑器保留 160 逻辑像素的最小高度。 -名称与工具、提示可分别滚动;正常空间仅在代码编辑器内部滚动,横屏、短屏或键盘压缩到最小布局以下时,工作区整体允许滚动,保存区仍固定。诊断位置、补全与撤销保持不变。 +名称与工具、提示可分别滚动;正常空间仅在代码编辑器内部滚动,横屏、短屏或键盘压缩到最小布局以下时,工作区整体允许滚动,保存区仍固定。 +短视口中的自动滚动按实际光标行定位,避免将整个编辑框底对齐而隐藏当前输入;诊断位置、补全与撤销保持不变。 按已加载或成功保存的原文判断未保存修改,Raw/高级同时比较名称;光标移动不算修改,手动还原原文和名称恢复干净状态。 已修改时,关闭、取消、系统返回和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。 侧滑受同一可退出状态约束,干净稿保留平台手势,不能越过未保存或保存中的退出限制。 diff --git a/lib/pages/shared/widgets/json_editor.dart b/lib/pages/shared/widgets/json_editor.dart index 4ea936471..49d3befe0 100644 --- a/lib/pages/shared/widgets/json_editor.dart +++ b/lib/pages/shared/widgets/json_editor.dart @@ -60,6 +60,9 @@ class _AppJsonEditorState extends State final _focus = FocusNode(); final _scroll = CodeScrollController(); final _suggestionScroll = ScrollController(); + final _indicatorKey = GlobalKey(); + CodeIndicatorValueNotifier? _indicatorValue; + bool _revealScheduled = false; late JsonEditorCubit _cubit; @override @@ -114,17 +117,86 @@ class _AppJsonEditorState extends State } void _revealEditor() { + if (_revealScheduled) return; + _revealScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_focus.hasFocus) return; - final scrollable = Scrollable.maybeOf(context, axis: Axis.vertical); - final render = context.findRenderObject(); - if (scrollable == null || render == null) return; - scrollable.position.ensureVisible( - render, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, - duration: const Duration(milliseconds: 120), - ); + if (!mounted || + !_focus.hasFocus || + Scrollable.maybeOf(context, axis: Axis.vertical) == null) { + _revealScheduled = false; + return; + } + // Let re_editor reveal the selection in its own viewport first. Its + // published paragraph geometry is refreshed by the following layout. + _scroll.makeVisible(widget.controller.selection.extent); + WidgetsBinding.instance.addPostFrameCallback((_) { + _revealScheduled = false; + if (!mounted || !_focus.hasFocus) return; + final scrollable = Scrollable.maybeOf(context, axis: Axis.vertical); + final viewport = scrollable?.context.findRenderObject(); + final editor = context.findRenderObject(); + if (scrollable != null && + viewport is RenderBox && + viewport.hasSize && + editor is RenderBox && + editor.hasSize && + editor.size.height <= viewport.size.height) { + // Keep completions and diagnostics visible when the full editor fits. + unawaited( + scrollable.position.ensureVisible( + editor, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + duration: const Duration(milliseconds: 120), + ), + ); + return; + } + final indicator = _indicatorKey.currentContext?.findRenderObject(); + final selection = widget.controller.selection; + final paragraph = _indicatorValue?.value?.paragraphs + .where((paragraph) => paragraph.index == selection.extentIndex) + .firstOrNull; + final caret = paragraph?.getOffset(selection.extent.textPosition); + if (scrollable == null || + viewport is! RenderBox || + indicator is! RenderBox || + !viewport.hasSize || + !indicator.hasSize || + paragraph == null || + caret == null) { + return; + } + // Indicator paragraphs share the code field's vertical origin and + // already include its padding and internal scroll offset. Use the + // rendered caret line, rather than the whole editor's minimum height. + final caretTop = indicator + .localToGlobal(Offset(0, paragraph.offset.dy + caret.dy)) + .dy; + final caretBottom = caretTop + paragraph.preferredLineHeight; + final viewportTop = viewport.localToGlobal(Offset.zero).dy; + final viewportBottom = viewportTop + viewport.size.height; + final delta = caretTop < viewportTop + ? caretTop - viewportTop + : caretBottom > viewportBottom + ? caretBottom - viewportBottom + : 0.0; + final position = scrollable.position; + final target = (position.pixels + delta) + .clamp(position.minScrollExtent, position.maxScrollExtent) + .toDouble(); + if (target != position.pixels) { + unawaited( + position.animateTo( + target, + duration: const Duration(milliseconds: 120), + curve: Curves.ease, + ), + ); + } + }); + WidgetsBinding.instance.ensureVisualUpdate(); }); + WidgetsBinding.instance.ensureVisualUpdate(); } @override @@ -202,6 +274,9 @@ class _AppJsonEditorState extends State textDirection: TextDirection.ltr, child: CodeEditor( controller: widget.controller, + onChanged: (_) { + if (_focus.hasFocus) _revealEditor(); + }, focusNode: _focus, scrollController: _scroll, toolbarController: _toolbar, @@ -269,21 +344,24 @@ class _AppJsonEditorState extends State theme: codeTheme, ), ), - indicatorBuilder: (context, editingController, _, notifier) => - Container( - color: Color.lerp(palette.card, palette.surfaceHover, .76), - // Paragraph offsets already include the editor's top padding. - padding: const EdgeInsets.symmetric(horizontal: 5), - child: DefaultCodeLineNumber( - controller: editingController, - notifier: notifier, - textStyle: lineNumberStyle, - focusedTextStyle: lineNumberStyle.copyWith( - color: palette.primary, - fontWeight: FontWeight.w600, - ), + indicatorBuilder: (context, editingController, _, notifier) { + _indicatorValue = notifier; + return Container( + key: _indicatorKey, + color: Color.lerp(palette.card, palette.surfaceHover, .76), + // Paragraph offsets already include the editor's top padding. + padding: const EdgeInsets.symmetric(horizontal: 5), + child: DefaultCodeLineNumber( + controller: editingController, + notifier: notifier, + textStyle: lineNumberStyle, + focusedTextStyle: lineNumberStyle.copyWith( + color: palette.primary, + fontWeight: FontWeight.w600, ), ), + ); + }, leadingDivider: Container(width: 1, color: palette.border), ), ), diff --git a/test/pages/shared/widgets/outbound_json_editor_test.dart b/test/pages/shared/widgets/outbound_json_editor_test.dart index a36d03d5b..2aebdb98d 100644 --- a/test/pages/shared/widgets/outbound_json_editor_test.dart +++ b/test/pages/shared/widgets/outbound_json_editor_test.dart @@ -13,6 +13,111 @@ import 'package:re_editor/re_editor.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; void main() { + testWidgets( + 'landscape keyboard reveals the rendered caret without page assistance', + (tester) async { + final source = const JsonEncoder.withIndent(' ').convert({ + 'values': [for (var i = 0; i < 40; i++) 'Item $i'], + }); + final controller = CodeLineEditingController.fromText(source); + addTearDown(controller.dispose); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1160, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetViewInsets); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => + ShadTheme(data: AppTheme.shad(Brightness.light), child: child!), + home: Scaffold( + appBar: AppBar(title: const Text('JSON')), + body: JsonEditorWorkspace( + header: const Text('Configuration JSON'), + editor: OutboundJsonEditor(controller: controller, expand: true), + footer: const Text('Stored locally'), + ), + bottomNavigationBar: PageActionBar( + children: [ + FilledButton(onPressed: () {}, child: const Text('Save')), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + final focus = tester + .widget(find.byType(CodeEditor)) + .focusNode!; + controller.selection = const CodeLineSelection.collapsed( + index: 0, + offset: 0, + ); + focus.requestFocus(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + tester.view.physicalSize = const Size(844, 390); + tester.view.viewInsets = const FakeViewPadding(bottom: 200); + // Wait through both geometry frames and the production reveal animation. + // Deliberately do not ensureVisible or move any outer scroll position. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pumpAndSettle(); + final outer = find.byKey(const ValueKey('json-editor-workspace-scroll')); + expect(outer, findsOneWidget); + final viewport = tester.getRect(outer); + final outerScroll = tester.state( + find.descendant(of: outer, matching: find.byType(Scrollable)).first, + ); + void expectCaretVisible() { + final numbers = tester.widget( + find.byType(DefaultCodeLineNumber), + ); + final paragraph = numbers.notifier.value!.paragraphs.singleWhere( + (paragraph) => paragraph.index == controller.selection.extentIndex, + ); + final caret = paragraph.getOffset( + controller.selection.extent.textPosition, + )!; + final render = tester.renderObject( + find.byType(DefaultCodeLineNumber), + ); + final top = render + .localToGlobal(Offset(0, paragraph.offset.dy + caret.dy)) + .dy; + expect(top, greaterThanOrEqualTo(viewport.top - .01)); + expect( + top + paragraph.preferredLineHeight, + lessThanOrEqualTo(viewport.bottom + .01), + ); + expect(focus.hasFocus, isTrue); + } + + expect(controller.selection.extentIndex, 0); + expectCaretVisible(); + final firstLineScroll = outerScroll.position.pixels; + controller.moveCursorToPageEnd(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pump(const Duration(milliseconds: 250)); + await tester.pumpAndSettle(); + expect(controller.selection.extentIndex, controller.codeLines.length - 1); + expectCaretVisible(); + expect(outerScroll.position.pixels, greaterThan(firstLineScroll)); + expect(controller.text, source); + expect( + find.widgetWithText(FilledButton, 'Save').hitTestable(), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets('expanded workspace fills the body and bounds keyboard chrome', ( tester, ) async { From c1d4e83c68da393d6b938f2ff1eab7d2a8a61c30 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 19:07:15 +0800 Subject: [PATCH 43/60] fix(android): open the app when long-pressing the VPN tile --- android/app/src/main/AndroidManifest.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e05361101..5db4c137b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,10 @@ + + + + From b656d3883a23b8487d0de7680a6c05a84d9936f1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 19:14:20 +0800 Subject: [PATCH 44/60] fix(vpn): clear managed logs before each admitted Core start --- .../net/yuandev/onexray/vpn/OneVpnService.kt | 30 ++- .../yuandev/onexray/vpn/RuntimeLogFiles.kt | 31 +++ .../onexray/vpn/RuntimeLogFilesTest.kt | 58 ++++++ docs/xray-configuration.md | 10 + lib/core/ffi/base_ffi_api.dart | 25 +++ lib/core/ffi/linux_ffi_api.dart | 15 +- lib/core/ffi/windows/exe_ffi_api.dart | 17 +- lib/core/ffi/windows/msix_ffi_api.dart | 14 +- swift/All/RuntimeEnv.swift | 95 +++++++++ swift/App/SimulatorProxy.swift | 1 + swift/Tunnel/PacketTunnelProvider.swift | 117 +++++++---- test/core/ffi/base_ffi_api_test.dart | 63 ++++++ test/core/ffi/linux_ffi_api_test.dart | 6 + test/core/ffi/windows_exe_test.dart | 32 +++ test/core/ffi/windows_msix_test.dart | 88 ++++++++- test/native/RuntimeEnvTests.swift | 182 ++++++++++++++++++ 16 files changed, 723 insertions(+), 61 deletions(-) create mode 100644 android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt create mode 100644 android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt index 10dffadd8..be89e4703 100644 --- a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt +++ b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt @@ -27,6 +27,9 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import libXray.DialerController import libXray.LibXray import net.yuandev.onexray.MainActivity @@ -41,6 +44,7 @@ import net.yuandev.onexray.pigeon.PerAppVPNMode import net.yuandev.onexray.pigeon.StartVpnRequest import net.yuandev.onexray.pigeon.TunJson import net.yuandev.onexray.pigeon.VpnStatus +import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference @@ -68,6 +72,7 @@ class OneVpnService : VpnService() { // Core retains protection callbacks for the lifetime of :native. private val controller = VPNController() private var controllerInit = false + private val coreLifecycleLock = Any() @Synchronized private fun initController() { @@ -510,7 +515,15 @@ class OneVpnService : VpnService() { if (generation != startGeneration.get() || tunnel !== establishedTunnel) { return@launch } - val result = LibXray.invoke(patchRuntimeEnv(coreInvokeText, establishedTunnel.fd)) + val requestJson = patchRuntimeEnv(coreInvokeText, establishedTunnel.fd) + val result = synchronized(coreLifecycleLock) { + if (generation != startGeneration.get() || tunnel !== establishedTunnel) { + return@launch + } + check(!isXrayRunning()) { "The previous Xray instance is still running" } + RuntimeLogFiles.clear(File(filesDir, "run")) + LibXray.invoke(requestJson) + } validateRunXrayResult(result) if (generation != startGeneration.get() || tunnel !== establishedTunnel) { XLog.d("OneVpnService: stale runXray result ignored") @@ -547,8 +560,19 @@ class OneVpnService : VpnService() { } private fun stopXray() { - val request = LibXrayInvokeRequest(method = LibXrayMethod.STOP_XRAY) - LibXray.invoke(JsonTool.json.encodeToString(request)) + synchronized(coreLifecycleLock) { + val request = LibXrayInvokeRequest(method = LibXrayMethod.STOP_XRAY) + validateRunXrayResult(LibXray.invoke(JsonTool.json.encodeToString(request))) + } + } + + private fun isXrayRunning(): Boolean { + val request = LibXrayInvokeRequest(method = LibXrayMethod.GET_XRAY_STATE) + val result = LibXray.invoke(JsonTool.json.encodeToString(request)) + validateRunXrayResult(result) + return JsonTool.json.parseToJsonElement(result).jsonObject["data"] + ?.jsonObject?.get("running")?.jsonPrimitive?.booleanOrNull + ?: throw IllegalStateException("Invalid Xray state response") } private fun patchRuntimeEnv(requestJson: String, fd: Int): String { diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt new file mode 100644 index 000000000..3a9aa8e22 --- /dev/null +++ b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt @@ -0,0 +1,31 @@ +package net.yuandev.onexray.vpn + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.NoSuchFileException +import java.nio.file.Path +import java.nio.file.attribute.BasicFileAttributes + +/** Called only for an admitted new Core start, after the previous Core has stopped. */ +internal object RuntimeLogFiles { + fun clear(directory: File) { + val root = directory.toPath() + val rootAttributes = attributes(root) ?: return + if (!rootAttributes.isDirectory) throw IOException("Invalid runtime log directory: $root") + val files = listOf("access.log", "error.log").map { root.resolve(it) }.filter { path -> + val attributes = attributes(path) ?: return@filter false + if (!attributes.isRegularFile) throw IOException("Invalid runtime log file: $path") + true + } + // Validate both before removing either, without enumerating the directory. + files.forEach { Files.delete(it) } + } + + private fun attributes(path: Path): BasicFileAttributes? = try { + Files.readAttributes(path, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS) + } catch (_: NoSuchFileException) { + null + } +} diff --git a/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt b/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt new file mode 100644 index 000000000..feb67e071 --- /dev/null +++ b/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt @@ -0,0 +1,58 @@ +package net.yuandev.onexray.vpn + +import java.io.File +import java.io.IOException +import java.nio.file.Files +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test + +class RuntimeLogFilesTest { + private lateinit var root: File + + @Before fun prepare() { + val fixtures = File("../../../references/onexray-tests/android/runtime-logs").absoluteFile + check(fixtures.mkdirs() || fixtures.isDirectory) + root = Files.createTempDirectory(fixtures.toPath(), "logs-").toFile() + } + + @After fun cleanup() { root.deleteRecursively() } + + @Test fun clearsOnlyManagedLogsAndAllowsMissingFiles() { + val run = File(root, "run") + RuntimeLogFiles.clear(run) + check(run.mkdir()) + for (name in listOf("access.log", "error.log", "start.json", "export.log")) { + File(run, name).writeText(name) + } + RuntimeLogFiles.clear(run) + RuntimeLogFiles.clear(run) + assertFalse(File(run, "access.log").exists()) + assertFalse(File(run, "error.log").exists()) + assertEquals("start.json", File(run, "start.json").readText()) + assertEquals("export.log", File(run, "export.log").readText()) + } + + @Test fun rejectsDirectoryLogBeforeClearingEither() { + File(root, "access.log").writeText("old access") + check(File(root, "error.log").mkdir()) + assertThrows(IOException::class.java) { RuntimeLogFiles.clear(root) } + assertEquals("old access", File(root, "access.log").readText()) + } + + @Test fun rejectsLinkedLogAndRuntimeDirectory() { + val access = File(root, "access.log").apply { writeText("old access") } + val outside = File(root, "outside.log").apply { writeText("outside") } + Files.createSymbolicLink(File(root, "error.log").toPath(), outside.toPath()) + assertThrows(IOException::class.java) { RuntimeLogFiles.clear(root) } + assertEquals("old access", access.readText()) + assertEquals("outside", outside.readText()) + val linkedRun = File(root, "linked-run") + Files.createSymbolicLink(linkedRun.toPath(), root.toPath()) + assertThrows(IOException::class.java) { RuntimeLogFiles.clear(linkedRun) } + assertEquals("old access", access.readText()) + } +} diff --git a/docs/xray-configuration.md b/docs/xray-configuration.md index df2d20c20..abfc54d34 100644 --- a/docs/xray-configuration.md +++ b/docs/xray-configuration.md @@ -453,6 +453,16 @@ run/start.json 是唯一原生启动请求:coreInvokeText 为实际输入,me 停止失败不继续准备;启停已开始后的失败尽力停止本次运行并进入 failed,不恢复旧连接/输入。 不能确认停止时仍以原生状态为准,缺少 metrics 或描述不等于断开。 +每次实际启动新的 Core 前,运行拥有者在旧 Core 已停止、输入准备完成后清空固定 `run/access.log` 和 `run/error.log`, +日志关闭时也处理旧文件;文件不存在可继续,目录或符号链接占位及删除失败会阻止本次启动。 +只处理这两个托管日志,不清理节点、路由、运行请求、Geodata、用户导出或启动诊断 `.error`。 +普通 Apple NE、SE 扩展容器、模拟器、Android 原生 saved-start 和桌面 App 启动分别在实际 Core 启动入口处理; +MSIX 先停止系统会话并确认旧 Core 进程退出,外部宿主自主恢复仍由宿主负责。 +预览、保存、校验、状态刷新和已运行时重复启动不清日志;开始本轮后失败保留本轮日志供诊断。 +清理发生在下一轮启动,单次持续运行期间不进行大小或年龄轮换。 +Apple 隧道扩展在后台串行队列内检查 Core 状态、复核启动代次、清日志并启动;停止先使旧代次失效再排队关闭 Core, +迟到的准备、启动结果和 SE 等待超时不清日志、不重启已停止的隧道,也不覆盖新一代状态。 + 桌面启动前在旧 Core 停止后清理 core-inputs,生成本次唯一输入和 error 文件,通过 -error-file 获取加载/构造/启动原因, 无诊断才用通用错误,不再运行 testXray。发布需包含支持该参数的 Core。 MSIX snapshotToken 只验证 Vole 宿主会话,不是可删的 App 快照。 diff --git a/lib/core/ffi/base_ffi_api.dart b/lib/core/ffi/base_ffi_api.dart index 8b16c7d79..7b4cb3f0e 100644 --- a/lib/core/ffi/base_ffi_api.dart +++ b/lib/core/ffi/base_ffi_api.dart @@ -79,6 +79,31 @@ abstract class BaseFfiApi { return LibXrayRunConfig.fromInvokeText(request.coreInvokeText!); } + /// Only call after the previous Core has stopped and before launching its replacement. + @protected + Future clearRuntimeLogs() async { + final directory = p.join(await getTunFilesDir(), 'run'); + final type = await FileSystemEntity.type(directory, followLinks: false); + if (type == FileSystemEntityType.notFound) return; + if (type != FileSystemEntityType.directory) { + throw FileSystemException('Invalid runtime log directory', directory); + } + final files = []; + for (final name in ['access.log', 'error.log']) { + final file = File(p.join(directory, name)); + final type = await FileSystemEntity.type(file.path, followLinks: false); + if (type == FileSystemEntityType.notFound) continue; + if (type != FileSystemEntityType.file) { + throw FileSystemException('Invalid runtime log file', file.path); + } + files.add(file); + } + // Validate both before removing either; never traverse the runtime directory. + for (final file in files) { + await file.delete(); + } + } + Future materializeRunXrayConfig(LibXrayRunConfig request) async { final runPath = p.join(await getTunFilesDir(), 'run'); final root = Directory(p.join(runPath, 'core-inputs')); diff --git a/lib/core/ffi/linux_ffi_api.dart b/lib/core/ffi/linux_ffi_api.dart index b53abde66..92c4c336e 100644 --- a/lib/core/ffi/linux_ffi_api.dart +++ b/lib/core/ffi/linux_ffi_api.dart @@ -154,15 +154,14 @@ class LinuxFfiApi extends BaseFfiApi { } final errorFile = desktopCoreErrorFile(inputs); await errorFile.writeAsString('', flush: true); - final process = await _startProcess( - corePath, - desktopCoreRunArguments( - dns: tun?.tunDnsIPv4 ?? '', - interfaceName: tun?.autoOutboundsInterface ?? '', - configPath: inputs, - errorFile: errorFile.path, - ), + final arguments = desktopCoreRunArguments( + dns: tun?.tunDnsIPv4 ?? '', + interfaceName: tun?.autoOutboundsInterface ?? '', + configPath: inputs, + errorFile: errorFile.path, ); + await clearRuntimeLogs(); + final process = await _startProcess(corePath, arguments); _coreProcess = process; _bindProcess(process); unawaited(_monitor.waitForExit(process.pid)); diff --git a/lib/core/ffi/windows/exe_ffi_api.dart b/lib/core/ffi/windows/exe_ffi_api.dart index 9c20a7b85..538cecc28 100644 --- a/lib/core/ffi/windows/exe_ffi_api.dart +++ b/lib/core/ffi/windows/exe_ffi_api.dart @@ -107,16 +107,15 @@ class WindowsExeFfiApi extends WindowsFfiApi { // Create as the App user before Windows starts an elevated Core. final errorFile = desktopCoreErrorFile(config); await errorFile.writeAsString('', flush: true); - launchAttempted = true; - final pid = await _process.start( - _corePath, - desktopCoreRunArguments( - dns: request.tun?.tunDnsIPv4 ?? '', - interfaceName: request.tun?.autoOutboundsInterface ?? '', - configPath: config, - errorFile: errorFile.path, - ), + final arguments = desktopCoreRunArguments( + dns: request.tun?.tunDnsIPv4 ?? '', + interfaceName: request.tun?.autoOutboundsInterface ?? '', + configPath: config, + errorFile: errorFile.path, ); + await clearRuntimeLogs(); + launchAttempted = true; + final pid = await _process.start(_corePath, arguments); await Future.delayed(const Duration(seconds: 1)); if (!(await _monitor.readPids()).contains(pid)) { throw StateError( diff --git a/lib/core/ffi/windows/msix_ffi_api.dart b/lib/core/ffi/windows/msix_ffi_api.dart index 1ae92ce34..70d7596ef 100644 --- a/lib/core/ffi/windows/msix_ffi_api.dart +++ b/lib/core/ffi/windows/msix_ffi_api.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:onexray/core/ffi/base_ffi_api.dart'; import 'package:onexray/core/ffi/windows/ffi_api.dart'; +import 'package:onexray/core/ffi/windows/core_process.dart'; import 'package:onexray/core/ffi/windows/model.dart'; import 'package:onexray/core/ffi/windows/native_api.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; @@ -14,6 +15,7 @@ import 'package:onexray/core/tools/logger.dart'; class WindowsMsixFfiApi extends WindowsFfiApi { WindowsMsixFfiApi({ WindowsNativeApi? native, + WindowsCoreProcess? coreProcess, Future Function()? readRequest, Future Function(VpnStatus)? notify, void Function(Object)? notifyError, @@ -22,6 +24,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi { this.startTimeout = const Duration(seconds: 30), this.stopTimeout = const Duration(seconds: 15), }) : _native = native ?? WindowsNativeApi(), + _coreProcess = coreProcess ?? WindowsCoreProcess(), _readRequest = readRequest ?? StartVpnRequestReader.readFromStartFile, _notify = notify ?? AppFlutterApi().vpnStatusChanged, _notifyError = @@ -31,6 +34,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi { static const _coreRelativePath = 'OneXrayCore.exe'; final WindowsNativeApi _native; + final WindowsCoreProcess _coreProcess; final Future Function() _readRequest; final Future Function(VpnStatus) _notify; final void Function(Object) _notifyError; @@ -152,9 +156,16 @@ class WindowsMsixFfiApi extends WindowsFfiApi { _commandActive = true; var providerStartInvoked = false; + var previousStopped = false; String? coreConfig; try { final request = await _readRequest(); + final stopped = await _native.stopVpn(); + await _confirm(WindowsVpnStatus.disconnected, stopped.status); + if ((await _coreProcess.findPids()).isNotEmpty) { + throw StateError('The previous Windows Core process is still running.'); + } + previousStopped = true; coreConfig = await _publishCoreConfig(readRunXrayRequest(request)); final errorFile = desktopCoreErrorFile(coreConfig); await errorFile.writeAsString('', flush: true); @@ -172,6 +183,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi { ], ); + await clearRuntimeLogs(); await _emitWindowsStatus(WindowsVpnStatus.connecting); providerStartInvoked = true; final state = await _native.startVpn( @@ -190,7 +202,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi { return commandSuccess(status: VpnStatus.connected); } catch (error, stackTrace) { ygLogger('start Windows VPN failed: $error\n$stackTrace'); - await _cleanupFailedStart(providerStartInvoked); + if (previousStopped) await _cleanupFailedStart(providerStartInvoked); return commandFailed( providerStartInvoked && coreConfig != null ? await readDesktopCoreStartError(coreConfig, error.toString()) diff --git a/swift/All/RuntimeEnv.swift b/swift/All/RuntimeEnv.swift index c3f7555cf..b7409474d 100644 --- a/swift/All/RuntimeEnv.swift +++ b/swift/All/RuntimeEnv.swift @@ -1,4 +1,99 @@ import Foundation +import Darwin + +/// Retires starts synchronously, while Core work and Stop wait on a background queue. +final class XrayRuntimeLifecycle: @unchecked Sendable { + private let stateLock = NSLock() + private let coreQueue = DispatchQueue(label: "net.yuandev.onexray.core.lifecycle", qos: .userInitiated) + private var generation: UInt64 = 0 + + func beginStart() -> UInt64 { + stateLock.lock() + defer { stateLock.unlock() } + generation &+= 1 + return generation + } + + func isCurrent(_ token: UInt64) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + return token == generation + } + + func check(_ token: UInt64) throws { + guard isCurrent(token) else { throw CancellationError() } + } + + func start( + generation token: UInt64, + operation: @escaping @Sendable () throws -> Void, + completion: @escaping @Sendable (Error?) -> Void + ) { + coreQueue.async { + do { + try self.check(token) + try operation() + try self.check(token) + completion(nil) + } catch { + completion(error) + } + } + } + + @discardableResult + func stop( + operation: @escaping @Sendable () -> Void, + completion: @escaping @Sendable (UInt64) -> Void + ) -> UInt64 { + stateLock.lock() + defer { stateLock.unlock() } + generation &+= 1 + let token = generation + // Enqueue before admitting a newer generation, so this Stop cannot close it. + coreQueue.async { + operation() + completion(token) + } + return token + } +} + +enum XrayRuntimeLogError: LocalizedError { + case invalidDirectory(String) + case invalidFile(String) + + var errorDescription: String? { + switch self { + case let .invalidDirectory(path): return "Invalid runtime log directory: \(path)" + case let .invalidFile(path): return "Invalid runtime log file: \(path)" + } + } +} + +/// Clear only the two managed logs after the previous Core has stopped. +func clearXrayRuntimeLogs(in directory: URL, fileManager: FileManager = .default) throws { + guard let directoryType = try xrayRuntimeLogFileType(directory) else { return } + guard directoryType == S_IFDIR else { + throw XrayRuntimeLogError.invalidDirectory(directory.path) + } + var files: [URL] = [] + for name in ["access.log", "error.log"] { + let file = directory.appendingPathComponent(name) + guard let type = try xrayRuntimeLogFileType(file) else { continue } + guard type == S_IFREG else { throw XrayRuntimeLogError.invalidFile(file.path) } + files.append(file) + } + // Validate both before removing either; do not follow links or enumerate run/. + for file in files { try fileManager.removeItem(at: file) } +} + +private func xrayRuntimeLogFileType(_ url: URL) throws -> mode_t? { + var info = stat() + if lstat(url.path, &info) == 0 { return info.st_mode & S_IFMT } + if errno == ENOENT { return nil } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSFilePathErrorKey: url.path]) +} /// Preserve Core-owned environment entries while installing the platform FD. /// Resource paths for the System Extension are applied by its caller afterwards. diff --git a/swift/App/SimulatorProxy.swift b/swift/App/SimulatorProxy.swift index 9917c3aef..e13c80855 100644 --- a/swift/App/SimulatorProxy.swift +++ b/swift/App/SimulatorProxy.swift @@ -19,6 +19,7 @@ enum SimulatorProxy { ) try JsonTool.encode(adapted).write(to: startURL, options: .atomic) // Persist exactly what is passed to the core; a failed write must not start it. + try clearXrayRuntimeLogs(in: startURL.deletingLastPathComponent()) _ = try await invoke(adapted.coreInvokeText!) } diff --git a/swift/Tunnel/PacketTunnelProvider.swift b/swift/Tunnel/PacketTunnelProvider.swift index 6f443d217..4219df29f 100644 --- a/swift/Tunnel/PacketTunnelProvider.swift +++ b/swift/Tunnel/PacketTunnelProvider.swift @@ -49,13 +49,15 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } private static let stateQueue = DispatchQueue(label: "net.yuandev.onexray.tunnel.state") - private var startContinuation: CheckedContinuation? + private let lifecycle = XrayRuntimeLifecycle() + private var startContinuation: (generation: UInt64, continuation: CheckedContinuation)? private var pendingStartSignal = false override func startTunnel( options: [String: NSObject]? = nil, completionHandler: @escaping @Sendable (Error?) -> Void ) { + let generation = lifecycle.beginStart() #if os(iOS) AppleWidgetPublisher.publish(.connecting) #endif @@ -69,7 +71,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { } } catch { #if os(iOS) - AppleWidgetPublisher.publish(.unknown) + if lifecycle.isCurrent(generation) { AppleWidgetPublisher.publish(.unknown) } #endif completionHandler(error) return @@ -77,29 +79,32 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { Task { do { if let systemExtensionRequest { - try await startTunnelSE(request: systemExtensionRequest, startedByApp: startedByApp) + try await startTunnelSE(request: systemExtensionRequest, startedByApp: startedByApp, generation: generation) } else { - try await startTunnelLegacy() + try await startTunnelLegacy(generation: generation) } + try lifecycle.check(generation) completionHandler(nil) } catch { #if os(iOS) - AppleWidgetPublisher.publish(.unknown) + if lifecycle.isCurrent(generation) { AppleWidgetPublisher.publish(.unknown) } #endif completionHandler(error) } } } - private func startTunnelLegacy() async throws { + private func startTunnelLegacy(generation: UInt64) async throws { + try lifecycle.check(generation) guard let request = StartVpnRequest.startModel else { YGLog("startTunnel noStartModel") throw TunnelError.noStartModel } let settings = try buildSettings(request: request) try await setTunnelNetworkSettings(settings) + try lifecycle.check(generation) if let coreInvokeText = request.coreInvokeText { - try await startXray(coreInvokeText) + try await startXray(coreInvokeText, generation: generation) } YGLog("startTunnel finished") } @@ -129,15 +134,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { if try !runtimeDirectoryExists(runDirectory) { try fm.createDirectory(at: runDirectory, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) } - for name in ["access.log", "error.log"] { - let file = runDirectory.appending(path: name) - var attributes = stat() - if lstat(file.path(percentEncoded: false), &attributes) == 0 { - try fm.removeItem(at: file) - } else if errno != ENOENT { - throw RuntimeStateError.invalid - } - } // A failed transfer may leave only this shared temporary directory. let staging = extGroupURL.appending(path: "dat.staging") if try runtimeDirectoryExists(staging) { try fm.removeItem(at: staging) } @@ -147,24 +143,27 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { return request } - private func startTunnelSE(request: StartVpnRequest, startedByApp: Bool) async throws { + private func startTunnelSE(request: StartVpnRequest, startedByApp: Bool, generation: UInt64) async throws { + try lifecycle.check(generation) // App-driven starts synchronize the shared root first. On Demand reuses // the last complete root published by an App-driven start. if startedByApp { YGLog("startTunnel awaiting start_xray signal") - try await waitStartSignal(timeout: 30) + try await waitStartSignal(timeout: 30, generation: generation) } else { YGLog("startTunnel on-demand, skipping XPC sync") } + try lifecycle.check(generation) guard let dat = datDir(), try DatFileTransfer.directoryExists(dat) else { throw TunnelError.noRoutingData } let settings = try buildSettings(request: request) try await setTunnelNetworkSettings(settings) + try lifecycle.check(generation) if let coreInvokeText = request.coreInvokeText { - try await startXray(coreInvokeText) + try await startXray(coreInvokeText, generation: generation) } } @@ -253,20 +252,25 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { settings.ipv6Settings?.excludedRoutes = ipv6Routes } - private func waitStartSignal(timeout: TimeInterval) async throws { + private func waitStartSignal(timeout: TimeInterval, generation: UInt64) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in Self.stateQueue.async { + guard self.lifecycle.isCurrent(generation) else { + continuation.resume(throwing: CancellationError()) + return + } if self.pendingStartSignal { self.pendingStartSignal = false continuation.resume(returning: ()) return } - self.startContinuation = continuation + self.startContinuation?.continuation.resume(throwing: CancellationError()) + self.startContinuation = (generation, continuation) let deadline = DispatchTime.now() + timeout Self.stateQueue.asyncAfter(deadline: deadline) { - if let c = self.startContinuation { + if let c = self.startContinuation, c.generation == generation { self.startContinuation = nil - c.resume(throwing: TunnelError.startXrayTimeout) + c.continuation.resume(throwing: TunnelError.startXrayTimeout) } } } @@ -277,7 +281,12 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { Self.stateQueue.async { if let c = self.startContinuation { self.startContinuation = nil - c.resume(returning: ()) + if self.lifecycle.isCurrent(c.generation) { + c.continuation.resume(returning: ()) + } else { + c.continuation.resume(throwing: CancellationError()) + self.pendingStartSignal = true + } } else { self.pendingStartSignal = true } @@ -288,7 +297,19 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { #if os(iOS) AppleWidgetPublisher.publish(.disconnecting) #endif - self.stopXray() + let generation = await withCheckedContinuation { continuation in + let token = lifecycle.stop(operation: { self.stopXray() }) { token in + continuation.resume(returning: token) + } + Self.stateQueue.async { + if self.lifecycle.isCurrent(token) { self.pendingStartSignal = false } + if let pending = self.startContinuation, !self.lifecycle.isCurrent(pending.generation) { + self.startContinuation = nil + pending.continuation.resume(throwing: CancellationError()) + } + } + } + guard lifecycle.isCurrent(generation) else { return } #if os(iOS) // Core stopped, but NetworkExtension has not confirmed its final status. AppleWidgetPublisher.publish(.unknown) @@ -372,7 +393,8 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { // MARK: - Xray lifecycle - private func startXray(_ requestJson: String) async throws { + private func startXray(_ requestJson: String, generation: UInt64) async throws { + try lifecycle.check(generation) guard let fd = self.tunnelFileDescriptor else { YGLog("PacketTunnelProvider TunnelError.noSocketFd") throw TunnelError.noSocketFd @@ -383,19 +405,38 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { ) let requestText = try request.toText() - let responseText = await Task.detached(priority: .userInitiated) { - requestText.withCString { p -> String? in - let p0 = UnsafeMutablePointer(mutating: p) - guard let response = CGoInvoke(p0) else { return nil } - defer { CGoFree(response) } - return String(cString: response) + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + lifecycle.start(generation: generation, operation: { + let stateText = self.invokeXray(try LibXrayInvokeRequest(method: .getXrayState).toText()) + let stateResult = LibXrayInvokeResponse.fromText(stateText) + guard stateResult.isSuccess, let stateText, + let data = try JsonTool.decodeObject(from: Data(stateText.utf8))["data"] as? [String: Any], + let running = data["running"] as? Bool else { + throw TunnelError.startXrayFailed(stateResult.error) + } + try self.lifecycle.check(generation) + guard !running else { throw TunnelError.startXrayFailed("Xray is already running") } + guard let container = extensionGroupContainerURL() else { throw TunnelError.noGroupContainer } + try clearXrayRuntimeLogs(in: container.appending(path: "run")) + + let result = LibXrayInvokeResponse.fromText(self.invokeXray(requestText)) + if !result.isSuccess { + YGLog("PacketTunnelProvider startXray \(result.error)") + throw TunnelError.startXrayFailed(result.error) + } + }) { error in + if let error { continuation.resume(throwing: error) } + else { continuation.resume(returning: ()) } } - }.value - let result = LibXrayInvokeResponse.fromText(responseText) - if !result.isSuccess { - let error = result.error - YGLog("PacketTunnelProvider startXray \(error)") - throw TunnelError.startXrayFailed(error) + } + } + + private func invokeXray(_ requestText: String) -> String? { + requestText.withCString { p -> String? in + let p0 = UnsafeMutablePointer(mutating: p) + guard let response = CGoInvoke(p0) else { return nil } + defer { CGoFree(response) } + return String(cString: response) } } diff --git a/test/core/ffi/base_ffi_api_test.dart b/test/core/ffi/base_ffi_api_test.dart index bb48b2fd2..c1232f528 100644 --- a/test/core/ffi/base_ffi_api_test.dart +++ b/test/core/ffi/base_ffi_api_test.dart @@ -112,6 +112,67 @@ void main() { expect(api.statuses, [VpnStatus.disconnecting, VpnStatus.disconnected]); }); + test('clears only managed logs and tolerates missing files', () async { + final directory = await createTestDirectory('onexray-runtime-logs-'); + addTearDown(() => directory.delete(recursive: true)); + final api = _TestFfiApi(stopResult: true, directory: directory.path); + addTearDown(api.stopSharedIsolate); + await api.clearLogs(); + final run = Directory(p.join(directory.path, 'run')); + await run.create(); + for (final name in [ + 'access.log', + 'error.log', + 'start.json', + 'export.log', + ]) { + await File(p.join(run.path, name)).writeAsString(name); + } + await api.clearLogs(); + await api.clearLogs(); + expect(await File(p.join(run.path, 'access.log')).exists(), isFalse); + expect(await File(p.join(run.path, 'error.log')).exists(), isFalse); + expect( + await File(p.join(run.path, 'start.json')).readAsString(), + 'start.json', + ); + expect( + await File(p.join(run.path, 'export.log')).readAsString(), + 'export.log', + ); + }); + + for (final link in [false, true]) { + test( + 'rejects ${link ? 'linked' : 'directory'} logs before clearing either', + () async { + final directory = await createTestDirectory('onexray-invalid-logs-'); + addTearDown(() => directory.delete(recursive: true)); + final api = _TestFfiApi(stopResult: true, directory: directory.path); + addTearDown(api.stopSharedIsolate); + final access = File(p.join(directory.path, 'run', 'access.log')); + await access.parent.create(); + await access.writeAsString('old access'); + final error = p.join(access.parent.path, 'error.log'); + if (link) { + final target = File(p.join(directory.path, 'outside.log')); + await target.writeAsString('outside'); + await Link(error).create(target.path); + } else { + await Directory(error).create(); + } + await expectLater(api.clearLogs(), throwsA(isA())); + expect(await access.readAsString(), 'old access'); + if (link) { + expect( + await File(p.join(directory.path, 'outside.log')).readAsString(), + 'outside', + ); + } + }, + ); + } + test('a stop failure never invents a connected state', () async { final api = _TestFfiApi(stopResult: false); addTearDown(api.stopSharedIsolate); @@ -146,6 +207,8 @@ final class _TestFfiApi extends LinuxFfiApi { @override Future stopCore() async => stopResult; + + Future clearLogs() => clearRuntimeLogs(); } LibXrayRunConfig _request(String json) => LibXrayRunConfig( diff --git a/test/core/ffi/linux_ffi_api_test.dart b/test/core/ffi/linux_ffi_api_test.dart index dbd28ee72..d46280edc 100644 --- a/test/core/ffi/linux_ffi_api_test.dart +++ b/test/core/ffi/linux_ffi_api_test.dart @@ -90,14 +90,20 @@ void main() { final fixture = await _Fixture.create(); final process = _OwnedProcess(42); addTearDown(process.finish); + final access = File(p.join(fixture.directory.path, 'run', 'access.log')); + await access.parent.create(recursive: true); + await access.writeAsString('old access'); final api = fixture.api( startProcess: (_, _) async { + expect(await access.exists(), isFalse); + await access.writeAsString('current connection'); fixture.pids.add(process.pid); return process; }, ); await api.observeVpnStatus(); expect(await api.startCore(_runConfig(), _tunConfig()), isTrue); + expect(await access.readAsString(), 'current connection'); expect(fixture.watchedPids, isEmpty); final nextStatus = fixture.nextStatus(); diff --git a/test/core/ffi/windows_exe_test.dart b/test/core/ffi/windows_exe_test.dart index 2eb8e68a0..2ed7b3469 100644 --- a/test/core/ffi/windows_exe_test.dart +++ b/test/core/ffi/windows_exe_test.dart @@ -142,6 +142,8 @@ void main() { ); await old.parent.create(recursive: true); await old.writeAsString('{}'); + final access = File(p.join(directory.path, 'run', 'access.log')); + await access.writeAsString('previous connection'); var requestRead = false; final api = create( readRequest: () async { @@ -154,6 +156,7 @@ void main() { expect(result.message, contains('Stop failed')); expect(requestRead, isFalse); expect(await old.exists(), isTrue); + expect(await access.readAsString(), 'previous connection'); expect(process.arguments, isNull); expect((await api.readVpnStatus()).status, VpnStatus.connected); expect(events, isNot(contains(VpnStatus.disconnected))); @@ -162,11 +165,38 @@ void main() { test('EXE replaces all existing named Cores before starting', () async { process.pids.addAll({10, 20}); + final access = File(p.join(directory.path, 'run', 'access.log')); + final error = File(p.join(directory.path, 'run', 'error.log')); + await access.parent.create(recursive: true); + await access.writeAsString('old access'); + await error.writeAsString('old error'); + process.onStart = () async { + expect(process.pids, isEmpty); + expect(await access.exists(), isFalse); + expect(await error.exists(), isFalse); + await error.writeAsString('current connection'); + }; final api = create(); expect((await api.startVpn()).status, VpnStatus.connected); expect(process.pids, {42}); + expect(await error.readAsString(), 'current connection'); }); + test( + 'an invalid managed log blocks EXE launch and preserves both inputs', + () async { + final access = File(p.join(directory.path, 'run', 'access.log')); + await access.parent.create(recursive: true); + await access.writeAsString('old access'); + await Directory(p.join(access.parent.path, 'error.log')).create(); + final result = await create().startVpn(); + expect(result.state, NativeVpnCommandState.failed); + expect(result.message, contains('Invalid runtime log file')); + expect(process.arguments, isNull); + expect(await access.readAsString(), 'old access'); + }, + ); + test( 'EXE starts with current CLI arguments and replaces old inputs', () async { @@ -479,6 +509,7 @@ class _Process extends WindowsCoreProcess { Future>? nextQuery; bool failStart = false; bool failStop = false; + Future Function()? onStart; int? stopBeforeFailure; int stops = 0; List? arguments; @@ -526,6 +557,7 @@ class _Process extends WindowsCoreProcess { @override Future start(String executable, List arguments) async { + await onStart?.call(); if (failStart) throw StateError('UAC cancelled'); this.arguments = arguments; running = true; diff --git a/test/core/ffi/windows_msix_test.dart b/test/core/ffi/windows_msix_test.dart index fa961b2ad..4cd0d6b4c 100644 --- a/test/core/ffi/windows_msix_test.dart +++ b/test/core/ffi/windows_msix_test.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/ffi/windows/core_process.dart'; import 'package:onexray/core/ffi/windows/msix_ffi_api.dart'; import 'package:onexray/core/ffi/windows/model.dart'; import 'package:onexray/core/ffi/windows/native_api.dart'; @@ -132,9 +133,15 @@ void main() { addTearDown(() => directory.delete(recursive: true)); final calls = []; final events = []; + final access = File('${directory.path}/run/access.log'); + final logError = File('${directory.path}/run/error.log'); + await access.parent.create(); + await access.writeAsString('old access'); + await logError.writeAsString('old error'); File? errorFile; List? arguments; final api = WindowsMsixFfiApi( + coreProcess: _CoreProcesses(), native: WindowsNativeApi.forTest((text) async { final request = jsonDecode(text) as Map; final method = request['method'] as String; @@ -150,6 +157,9 @@ void main() { }); } if (method == 'startVpn') { + expect(await access.exists(), isFalse); + expect(await logError.exists(), isFalse); + await logError.writeAsString('current connection'); final process = request['payload']['sessionBackend']['processes'].single; expect(process['executableRelativePath'], 'OneXrayCore.exe'); @@ -163,6 +173,7 @@ void main() { ); } expect(method, 'stopVpn'); + if (errorFile == null) return response('disconnected'); // Diagnostics may finish writing while the failed session is stopped. if (diagnostic == null) { await errorFile!.delete(); @@ -206,8 +217,81 @@ void main() { arguments![arguments!.indexOf('-error-file') + 1], errorFile!.path, ); - expect(calls, ['getEnvironment', 'startVpn', 'stopVpn']); - expect(events, [VpnStatus.connecting, VpnStatus.disconnected]); + expect(calls, ['stopVpn', 'getEnvironment', 'startVpn', 'stopVpn']); + expect(events, [ + VpnStatus.disconnected, + VpnStatus.connecting, + VpnStatus.disconnected, + ]); + expect(await logError.readAsString(), 'current connection'); }); } + + for (final failure in ['stop', 'running', 'query']) { + test( + 'MSIX preserves logs when previous Core cannot be confirmed stopped: $failure', + () async { + final root = await Directory('../references/windows-msix-tests') + .absolute + .create(recursive: true); + final directory = await root.createTemp('logs-'); + addTearDown(() => directory.delete(recursive: true)); + final access = File('${directory.path}/run/access.log'); + await access.parent.create(); + await access.writeAsString('previous connection'); + final processes = _CoreProcesses() + ..pids = failure == 'running' ? {42} : {} + ..error = failure == 'query' + ? StateError('process enumeration denied') + : null; + final calls = []; + final events = []; + final api = WindowsMsixFfiApi( + coreProcess: processes, + native: WindowsNativeApi.forTest((text) async { + final method = jsonDecode(text)['method'] as String; + calls.add(method); + expect(method, 'stopVpn'); + if (failure == 'stop') throw StateError('Stop failed'); + return response('disconnected'); + }), + readRequest: () async => StartVpnRequest(null, null, null, null), + notify: (status) async => events.add(status), + ); + addTearDown(api.stopSharedIsolate); + final result = await api.startVpn( + configYaml: 'fixture', + networkSettings: const WindowsVpnNetworkSettings( + ipv4Address: '172.19.0.1', + ipv6Address: '', + dnsIpv4Address: '8.8.8.8', + dnsIpv6Address: '', + ), + ); + expect(result.state, NativeVpnCommandState.failed); + expect( + result.message, + contains(switch (failure) { + 'stop' => 'Stop failed', + 'running' => 'previous Windows Core process is still running', + _ => 'process enumeration denied', + }), + ); + expect(calls, ['stopVpn']); + expect(await access.readAsString(), 'previous connection'); + if (failure == 'stop') expect(events, isEmpty); + }, + ); + } +} + +class _CoreProcesses extends WindowsCoreProcess { + Set pids = {}; + Object? error; + + @override + Future> findPids() async { + if (error != null) throw error!; + return {...pids}; + } } diff --git a/test/native/RuntimeEnvTests.swift b/test/native/RuntimeEnvTests.swift index 6e09cc36a..47d64687b 100644 --- a/test/native/RuntimeEnvTests.swift +++ b/test/native/RuntimeEnvTests.swift @@ -2,6 +2,167 @@ import Foundation import XCTest final class RuntimeEnvTests: XCTestCase { + private func withRuntimeDirectory(_ action: (URL) throws -> Void) throws { + let root = URL(fileURLWithPath: "../references/onexray-tests/native/fixtures", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try action(root) + } + + func testStopDuringStateQueryRetiresStartBeforeClearingLogs() throws { + try withRuntimeDirectory { root in + let access = root.appendingPathComponent("access.log") + try Data("previous connection".utf8).write(to: access) + let lifecycle = XrayRuntimeLifecycle() + let generation = lifecycle.beginStart() + let queried = expectation(description: "state query entered") + let started = expectation(description: "retired start completes") + let stopped = expectation(description: "stop completes") + let releaseQuery = DispatchSemaphore(value: 0) + defer { releaseQuery.signal() } + lifecycle.start(generation: generation, operation: { + queried.fulfill() + XCTAssertEqual(releaseQuery.wait(timeout: .now() + 3), .success) + // The provider checks again after the synchronous Core query. + try lifecycle.check(generation) + try clearXrayRuntimeLogs(in: root) + XCTFail("A retired start must not invoke Core") + }) { error in + XCTAssertTrue(error is CancellationError) + started.fulfill() + } + wait(for: [queried], timeout: 2) + lifecycle.stop(operation: {}) { _ in stopped.fulfill() } + // Stop invalidates immediately, without waiting for the Core queue. + XCTAssertFalse(lifecycle.isCurrent(generation)) + releaseQuery.signal() + wait(for: [started, stopped], timeout: 3) + XCTAssertEqual(try String(contentsOf: access, encoding: .utf8), "previous connection") + } + } + + func testStopDrainsAdmittedRunBeforeReplacementAndOldCompletionCannotStopIt() { + let lifecycle = XrayRuntimeLifecycle() + let events = RuntimeLifecycleEvents() + let first = lifecycle.beginStart() + let running = expectation(description: "first Core entered run") + let firstCompleted = expectation(description: "first start retired") + let stopped = expectation(description: "stop drains first Core") + let replacementCompleted = expectation(description: "replacement starts") + let releaseRun = DispatchSemaphore(value: 0) + defer { releaseRun.signal() } + lifecycle.start(generation: first, operation: { + events.append("run first") + running.fulfill() + XCTAssertEqual(releaseRun.wait(timeout: .now() + 3), .success) + events.append("first run returned") + }) { error in + XCTAssertTrue(error is CancellationError) + events.append("old completion cancelled") + firstCompleted.fulfill() + } + wait(for: [running], timeout: 2) + lifecycle.stop(operation: { + events.append("stop first") + }) { _ in stopped.fulfill() } + let replacement = lifecycle.beginStart() + lifecycle.start(generation: replacement, operation: { + events.append("run replacement") + }) { error in + XCTAssertNil(error) + replacementCompleted.fulfill() + } + releaseRun.signal() + wait(for: [firstCompleted, stopped, replacementCompleted], timeout: 3) + XCTAssertEqual(events.values, ["run first", "first run returned", "old completion cancelled", "stop first", "run replacement"]) + XCTAssertTrue(lifecycle.isCurrent(replacement)) + XCTAssertThrowsError(try lifecycle.check(first)) + } + + func testStartPreparedBeforeStopCannotRunAfterNewGeneration() { + let lifecycle = XrayRuntimeLifecycle() + let events = RuntimeLifecycleEvents() + let old = lifecycle.beginStart() + let stopped = expectation(description: "stop completes") + let replacementCompleted = expectation(description: "replacement completes") + let oldCompleted = expectation(description: "late preparation is rejected") + lifecycle.stop(operation: { events.append("stop") }) { _ in stopped.fulfill() } + let replacement = lifecycle.beginStart() + lifecycle.start(generation: replacement, operation: { events.append("replacement") }) { error in + XCTAssertNil(error) + replacementCompleted.fulfill() + } + lifecycle.start(generation: old, operation: { + XCTFail("Late preparation must not clear logs or invoke Core") + }) { error in + XCTAssertTrue(error is CancellationError) + oldCompleted.fulfill() + } + wait(for: [stopped, replacementCompleted, oldCompleted], timeout: 3) + XCTAssertEqual(events.values, ["stop", "replacement"]) + XCTAssertTrue(lifecycle.isCurrent(replacement)) + } + + func testClearsOnlyManagedLogsAndAllowsMissingFiles() throws { + try withRuntimeDirectory { root in + let run = root.appendingPathComponent("run") + try clearXrayRuntimeLogs(in: run) + try FileManager.default.createDirectory(at: run, withIntermediateDirectories: false) + for name in ["access.log", "error.log", "start.json", "export.log"] { + try Data(name.utf8).write(to: run.appendingPathComponent(name)) + } + try clearXrayRuntimeLogs(in: run) + try clearXrayRuntimeLogs(in: run) + for name in ["access.log", "error.log"] { + XCTAssertFalse(FileManager.default.fileExists(atPath: run.appendingPathComponent(name).path)) + } + for name in ["start.json", "export.log"] { + XCTAssertEqual(try String(contentsOf: run.appendingPathComponent(name), encoding: .utf8), name) + } + } + } + + func testRejectsLinkedOrDirectoryLogsBeforeClearingEither() throws { + for link in [false, true] { + try withRuntimeDirectory { root in + let access = root.appendingPathComponent("access.log") + let error = root.appendingPathComponent("error.log") + let target = root.appendingPathComponent("outside.log") + try Data("old access".utf8).write(to: access) + try Data("outside".utf8).write(to: target) + if link { + try FileManager.default.createSymbolicLink(at: error, withDestinationURL: target) + } else { + try FileManager.default.createDirectory(at: error, withIntermediateDirectories: false) + } + XCTAssertThrowsError(try clearXrayRuntimeLogs(in: root)) + XCTAssertEqual(try String(contentsOf: access, encoding: .utf8), "old access") + XCTAssertEqual(try String(contentsOf: target, encoding: .utf8), "outside") + } + } + } + + func testRejectsLinkedRuntimeDirectory() throws { + try withRuntimeDirectory { root in + let run = root.appendingPathComponent("run") + try FileManager.default.createSymbolicLink(at: run, withDestinationURL: root) + let access = root.appendingPathComponent("access.log") + try Data("old".utf8).write(to: access) + XCTAssertThrowsError(try clearXrayRuntimeLogs(in: run)) + XCTAssertEqual(try String(contentsOf: access, encoding: .utf8), "old") + } + } + + func testRemovalFailurePropagatesAndPreservesExistingLog() throws { + try withRuntimeDirectory { root in + let access = root.appendingPathComponent("access.log") + try Data("old".utf8).write(to: access) + XCTAssertThrowsError(try clearXrayRuntimeLogs(in: root, fileManager: FailingLogRemoval())) + XCTAssertEqual(try String(contentsOf: access, encoding: .utf8), "old") + } + } + func testPreservesCoreEnvironmentWhenReplacingNativeDescriptor() throws { let original: [String: Any] = [ "xray.location.asset": "/assets", @@ -46,6 +207,27 @@ final class RuntimeEnvTests: XCTestCase { } } +private final class RuntimeLifecycleEvents: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String] = [] + + func append(_ value: String) { + lock.lock() + defer { lock.unlock() } + stored.append(value) + } + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return stored + } +} + +private final class FailingLogRemoval: FileManager, @unchecked Sendable { + override func removeItem(at URL: URL) throws { throw CocoaError(.fileWriteNoPermission) } +} + @main enum RuntimeEnvTestRunner { static func main() { From c032ae1381c16d1f0f5c62350fb4afdee38ed4c3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 19:15:31 +0800 Subject: [PATCH 45/60] feat(ios): add explicit Control Center VPN actions --- docs/app.md | 7 ++-- docs/external-interfaces.md | 3 +- docs/validation.md | 8 +++++ ios/widget/en.lproj/Widget.strings | 1 + ios/widget/fa.lproj/Widget.strings | 1 + ios/widget/ru.lproj/Widget.strings | 1 + ios/widget/widget.swift | 44 +++++++++++++++++++++++++ ios/widget/widgetBundle.swift | 4 +++ ios/widget/zh-Hans.lproj/Widget.strings | 1 + ios/widget/zh-Hant.lproj/Widget.strings | 1 + swift/iOS/AppleWidgetPublisher.swift | 3 ++ 11 files changed, 71 insertions(+), 3 deletions(-) diff --git a/docs/app.md b/docs/app.md index 4a6d1d54e..5e374ec9c 100644 --- a/docs/app.md +++ b/docs/app.md @@ -342,6 +342,8 @@ EXE/ZIP/Linux 的 Quit 先停止 VPN。需要停止的退出失败时保持 App Android 设置页可请求固定 `home_widget`,不把请求完成当作已添加;Widget 展示状态、本次速率和流量, 标题/数据打开 App,图标启停 VPN。Widget/Tile 直接复用已保存运行输入的边界见 [运行协调与统计](xray-configuration.md#运行协调与统计)。外部自动化的授权与失败行为见 [外部接口](external-interfaces.md)。 +Android 快捷设置磁贴单击沿用启停;长按通过 `QS_TILE_PREFERENCES` 打开现有主 Activity,恢复当前页面。 +长按不提交快捷启动命令;普通冷启动仍尊重既有“启动时连接”偏好。 ### iOS/iPadOS Widget @@ -349,8 +351,9 @@ iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMediu `accessoryCircular`、`accessoryRectangular`,可添加位置由系统版本与设备决定。 iOS/iPadOS 16 静态展示状态,点击打开连接页; 17 起主屏幕小号/中号 Widget 增加显式“启动 VPN”“停止 VPN”,通过前台 App Intent 打开 App。 -锁屏两种 accessory 只导航。当前仅接入 Widget,不发布可发现的 Siri/App Shortcuts, -也不提供 Control Widget 或 Live Activity。 +锁屏两种 accessory 只导航。18 起另提供“启动 VPN”“停止 VPN”两个控制中心动作按钮, +说明明确会打开 OneXray;两者复用同一前台 App Intent,按当前连接计划执行,不显示连接态或根据过期快照反向切换。 +控制中心动作名称跟随 App 有效语言,语言变化后请求 Controls 刷新。当前不发布可发现的 Siri/App Shortcuts 或 Live Activity。 主屏组件跟随系统浅深色,配色复用 App 色板,由 Widget 命名颜色资源统一管理; 系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号居中展示状态,底部并排显示简短启停文案; diff --git a/docs/external-interfaces.md b/docs/external-interfaces.md index 1f682024d..c7af9bdd2 100644 --- a/docs/external-interfaces.md +++ b/docs/external-interfaces.md @@ -126,12 +126,13 @@ Widget/Tile 的缺输入打开 App 回退不用于广播。 ## iOS Widget 前台动作与链接 -iOS/iPadOS 17 起 Widget 的启动/停止使用不公开发现的前台 App Intent;App 内存接收动作, +iOS/iPadOS 17 起 Widget、18 起控制中心的显式启动/停止使用不公开发现的前台 App Intent;App 内存接收动作, 经原生桥交给既有快捷入口和连接协调器。冷启动与回前台均读取待处理动作,Shell 未就绪时保留最后一个动作, 并抑制本次偏好自动连接;消费后清除,同类在途动作合并。较晚到达的停止会取消待执行启动, iOS 原生先排空配置准备写入再停止,不等待已提交启动的连接状态确认。 输入仅限启动/停止,不接受配置参数、节点选择或通用 invoke,也不形成跨进程持久命令队列。 它使用当前 App 选择,不复用 Android Widget/Tile 或 Apple 按需连接的 saved-start 路径。 +控制中心提供两个静态动作按钮,动作本身打开 App 后执行;不把点击按钮当作连接成功,也不从展示快照推断切换目标。 Widget 唯一公开 URL 为 `onexray://widget/connect`,只打开连接页,不启停 VPN。 带查询、fragment、端口、用户信息或其他路径的地址不进入 Widget 动作入口;启停不通过 URL 参数开放。 diff --git a/docs/validation.md b/docs/validation.md index 916ead239..1f4bc1c8d 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -252,6 +252,9 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时 Robolectric 调用正式 Receiver、保存配置、Widget 资源与 VPN 状态桥; 状态桥回归使用真实 command/changed 与可控 Binder 重演停止确认后旧事件迟到, 并区分当前错误/退出、旧绑定回调和外部启动;不替代真实 VPN 与桌面验收。 +- 启动日志清理:Dart FFI 与 `bash tool/test_runtime_env.sh`、Android `RuntimeLogFilesTest` 覆盖两份托管文件、 + 缺失幂等、符号链接/目录拒绝及旁侧文件保留;启动回归检查停止失败、旧 Core 残留和进程查询失败均保留旧日志。 + 文件测试不替代各平台真实启动与原生 saved-start 验收。 - Android Manifest/网络 XML 变更补跑 `./gradlew :app:lintVitalRelease`; 发布相关修复还需本地 Release AAB 构建,不调用会上传商店的部署脚本。 - 原生桥合同变更验证实际序列化/消费方与对应平台构建; @@ -266,11 +269,16 @@ Android 模拟器直接使用当前分支构建覆盖已经安装的 OneXray, 缩放、旋转、主题、语言与启动/停止。通知和真实代理请求需运行 VPN 后验证。 连接生命周期修改还须在同一 `:native` PID 内连续重连,逐次确认受控 HTTP 响应和服务端收据; 仅显示已连接或杀进程后的成功不能代替此项。 +- Android 磁贴分别检查单击启停和真实长按打开 App;冷启动、子页热恢复、锁屏取消与启动连接偏好分别核对, + 长按不得进入 App Info 或重放 `startVpn` 快捷动作。 +- 日志在连续连接、配置重连和原生快捷启动后仅保留本轮内容;关闭日志后启动仍清旧文件, + 重复启动运行实例不清当前日志。编译、权限、停止或清理失败以及取消路径保留适当诊断,节点与设置不变。 - 完整配置适配在 Android 验证 Raw/提供方真实 VPN 启动、DNS 查询、受控网页响应、连续重连和隧道 DNS 设置变更。 iOS 模拟器只验证配置与 SOCKS 路径,不能验收真机 DNS、DoT 或 includeAllNetworks;这些项目需要 Apple 真机。 - iOS/iPadOS Widget 最低部署版本为 16.0;16 验证主屏幕小号/中号静态导航及两种锁屏 accessory。 在系统图库与实际主屏幕/锁屏验证系统支持的 family; 17 起验证主屏幕小号/中号启动、停止实际前台打开 App,并使用当前选择而非最近生成的 saved-start。 + 18 起在控制中心图库添加两个显式动作,核对准确的打开 App 说明、启停交付与语言切换;不以按钮动画证明已连接。 覆盖冷启动、App 已运行/后台、重复点击和启动中停止,确认连接协调器、权限失败与页面/通知反馈。 核对五语言及系统语言匹配、波斯语 RTL、大字、明暗主题、可访问名称,以及未知/过期状态不会继续展示已连接, Widget 不泄露配置名、订阅、地址或流量。签名安装包须检查 Widget 扩展嵌入、独立 provisioning profile、 diff --git a/ios/widget/en.lproj/Widget.strings b/ios/widget/en.lproj/Widget.strings index e8b09e1d2..af148ac45 100644 --- a/ios/widget/en.lproj/Widget.strings +++ b/ios/widget/en.lproj/Widget.strings @@ -1,5 +1,6 @@ "Connection" = "Connection"; "VPN status and actions that open OneXray." = "VPN status and actions that open OneXray."; +"Open OneXray to perform this action." = "Open OneXray to perform this action."; "Open app" = "Open app"; "Open app to refresh" = "Open app to refresh"; "Observed" = "Observed"; diff --git a/ios/widget/fa.lproj/Widget.strings b/ios/widget/fa.lproj/Widget.strings index cb9f60b2a..98431113c 100644 --- a/ios/widget/fa.lproj/Widget.strings +++ b/ios/widget/fa.lproj/Widget.strings @@ -1,5 +1,6 @@ "Connection" = "اتصال"; "VPN status and actions that open OneXray." = "وضعیت VPN و اقداماتی که OneXray را باز می‌کنند."; +"Open OneXray to perform this action." = "برای انجام این کار OneXray را باز کنید."; "Open app" = "باز کردن برنامه"; "Open app to refresh" = "برای تازه‌سازی برنامه را باز کنید"; "Observed" = "زمان مشاهده"; diff --git a/ios/widget/ru.lproj/Widget.strings b/ios/widget/ru.lproj/Widget.strings index 161c2c752..e98ec0edc 100644 --- a/ios/widget/ru.lproj/Widget.strings +++ b/ios/widget/ru.lproj/Widget.strings @@ -1,5 +1,6 @@ "Connection" = "Подключение"; "VPN status and actions that open OneXray." = "Статус VPN и действия с открытием OneXray."; +"Open OneXray to perform this action." = "Откройте OneXray для выполнения этого действия."; "Open app" = "Открыть приложение"; "Open app to refresh" = "Откройте для обновления"; "Observed" = "Обновлено"; diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index 68cb4903b..088b1e1e8 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -59,6 +59,50 @@ struct ConnectionWidget: Widget { } } +// Explicit commands never infer a target from the Widget's display snapshot. +// Both controls use the same foreground App delivery as the Home Screen buttons. +@available(iOS 18.0, *) +struct StartVpnControl: ControlWidget { + var body: some ControlWidgetConfiguration { + let language = AppleWidgetStore.shared?.languageCode() ?? "en" + let title = LocalizedStringResource("Start VPN", table: "Widget", locale: Locale(identifier: language)) + return StaticControlConfiguration(kind: "net.yuandev.onexray.start-vpn") { + ControlWidgetButton(action: StartVpnWidgetIntent()) { + Label { + Text(title) + } icon: { + Image(systemName: ConnectionWidgetAction.start.symbol) + } + } + } + .displayName(title) + .description(LocalizedStringResource( + "Open OneXray to perform this action.", table: "Widget", locale: Locale(identifier: language) + )) + } +} + +@available(iOS 18.0, *) +struct StopVpnControl: ControlWidget { + var body: some ControlWidgetConfiguration { + let language = AppleWidgetStore.shared?.languageCode() ?? "en" + let title = LocalizedStringResource("Stop VPN", table: "Widget", locale: Locale(identifier: language)) + return StaticControlConfiguration(kind: "net.yuandev.onexray.stop-vpn") { + ControlWidgetButton(action: StopVpnWidgetIntent()) { + Label { + Text(title) + } icon: { + Image(systemName: ConnectionWidgetAction.stop.symbol) + } + } + } + .displayName(title) + .description(LocalizedStringResource( + "Open OneXray to perform this action.", table: "Widget", locale: Locale(identifier: language) + )) + } +} + private struct ConnectionWidgetView: View { @Environment(\.widgetFamily) private var family @Environment(\.dynamicTypeSize) private var dynamicTypeSize diff --git a/ios/widget/widgetBundle.swift b/ios/widget/widgetBundle.swift index be5d84ef4..45b315665 100644 --- a/ios/widget/widgetBundle.swift +++ b/ios/widget/widgetBundle.swift @@ -12,5 +12,9 @@ import SwiftUI struct widgetBundle: WidgetBundle { var body: some Widget { ConnectionWidget() + if #available(iOS 18.0, *) { + StartVpnControl() + StopVpnControl() + } } } diff --git a/ios/widget/zh-Hans.lproj/Widget.strings b/ios/widget/zh-Hans.lproj/Widget.strings index 1f8db2c98..f9f96b3a7 100644 --- a/ios/widget/zh-Hans.lproj/Widget.strings +++ b/ios/widget/zh-Hans.lproj/Widget.strings @@ -1,5 +1,6 @@ "Connection" = "连接"; "VPN status and actions that open OneXray." = "查看 VPN 状态,打开 OneXray 执行操作。"; +"Open OneXray to perform this action." = "打开 OneXray 执行此操作。"; "Open app" = "打开 App"; "Open app to refresh" = "打开 App 刷新"; "Observed" = "观测时间"; diff --git a/ios/widget/zh-Hant.lproj/Widget.strings b/ios/widget/zh-Hant.lproj/Widget.strings index 6ae3dfdb2..fdfe9a58f 100644 --- a/ios/widget/zh-Hant.lproj/Widget.strings +++ b/ios/widget/zh-Hant.lproj/Widget.strings @@ -1,5 +1,6 @@ "Connection" = "連線"; "VPN status and actions that open OneXray." = "查看 VPN 狀態,開啟 OneXray 執行操作。"; +"Open OneXray to perform this action." = "開啟 OneXray 執行此操作。"; "Open app" = "開啟 App"; "Open app to refresh" = "開啟 App 重新整理"; "Observed" = "觀測時間"; diff --git a/swift/iOS/AppleWidgetPublisher.swift b/swift/iOS/AppleWidgetPublisher.swift index b366b0183..169c77d7d 100644 --- a/swift/iOS/AppleWidgetPublisher.swift +++ b/swift/iOS/AppleWidgetPublisher.swift @@ -13,6 +13,9 @@ enum AppleWidgetPublisher { guard let store = AppleWidgetStore.shared else { return } if try store.writeLanguage(code) { WidgetCenter.shared.reloadTimelines(ofKind: AppleWidgetStore.kind) + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadAllControls() + } } } } From 07e1f8db2b3f2a65611ef582cbd5e5ccf25d0cb6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 19:58:44 +0800 Subject: [PATCH 46/60] fix(logs): reset the viewer when a new VPN session starts --- docs/app.md | 1 + docs/validation.md | 1 + lib/pages/advanced/xray/log/controller.dart | 61 ++++- .../advanced/xray/log_controller_test.dart | 213 ++++++++++++++++++ 4 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 test/pages/advanced/xray/log_controller_test.dart diff --git a/docs/app.md b/docs/app.md index 5e374ec9c..fd1797e09 100644 --- a/docs/app.md +++ b/docs/app.md @@ -248,6 +248,7 @@ Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用 日志偏好与恢复默认位于独立日志设置页,草稿保存后才生效;Xray 根页不显示日志表单或固定保存按钮。System Extension 隐藏日志入口,独立页面、诊断和深链同样限制日志访问;其他入口不受影响。 日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存; 保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。 +日志页在运行身份变化或进入新连接时重置内存缓冲和读取位置,丢弃旧会话在途读取;断开本身保留本轮诊断。 数据更新页仅管理订阅与 Geodata 的自动更新开关和周期。User-Agent 在每个订阅表单单独管理,默认填入兼容 UA, 用户可自定义;清空后仍使用订阅默认值,规则见 [订阅合同](subscriptions-and-sharing.md)。 diff --git a/docs/validation.md b/docs/validation.md index 1f4bc1c8d..1e58f1bf8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -255,6 +255,7 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时 - 启动日志清理:Dart FFI 与 `bash tool/test_runtime_env.sh`、Android `RuntimeLogFilesTest` 覆盖两份托管文件、 缺失幂等、符号链接/目录拒绝及旁侧文件保留;启动回归检查停止失败、旧 Core 残留和进程查询失败均保留旧日志。 文件测试不替代各平台真实启动与原生 saved-start 验收。 + 日志查看器另覆盖同路径新文件大小等于或大于旧偏移、旧在途数据/错误回包、新连接边沿、同会话追加以及断开保留诊断。 - Android Manifest/网络 XML 变更补跑 `./gradlew :app:lintVitalRelease`; 发布相关修复还需本地 Release AAB 构建,不调用会上传商店的部署脚本。 - 原生桥合同变更验证实际序列化/消费方与对应平台构建; diff --git a/lib/pages/advanced/xray/log/controller.dart b/lib/pages/advanced/xray/log/controller.dart index 004b973ad..e3118a08e 100644 --- a/lib/pages/advanced/xray/log/controller.dart +++ b/lib/pages/advanced/xray/log/controller.dart @@ -1,9 +1,11 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/advanced/xray/runtime_files.dart'; +import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/shared/failure.dart'; import 'package:path/path.dart' as p; @@ -57,32 +59,73 @@ class LogFileViewerController extends PageCubit { static const Duration _pollInterval = Duration(seconds: 1); final LogFileViewerParams params; + final ConnectionCoordinator _coordinator; + final Future<({Uint8List data, int offset, int size})?> Function( + String path, { + int offset, + }) + _readLog; Timer? _timer; + late String? _runtimeIdentity; + late bool _connected; + int _readGeneration = 0; int _offset = 0; var _droppedContent = false; var _reading = false; List _buffer = const []; - LogFileViewerController(this.params) - : super(LogFileViewerPageState(title: params.title)) { + LogFileViewerController( + this.params, { + ConnectionCoordinator? coordinator, + Future<({Uint8List data, int offset, int size})?> Function( + String path, { + int offset, + })? + readLog, + }) : _coordinator = coordinator ?? ConnectionCoordinator.instance, + _readLog = readLog ?? RuntimeDiagnosticFiles.readLog, + super(LogFileViewerPageState(title: params.title)) { + final connection = _coordinator.state.value; + _runtimeIdentity = connection.runtime?.identity; + _connected = connection.phase == ConnectionPhase.connected; + _coordinator.state.addListener(_connectionChanged); _poll(); _timer = Timer.periodic(_pollInterval, (_) => _poll()); } + void _connectionChanged() { + if (!isPageActive) return; + final connection = _coordinator.state.value; + final identity = connection.runtime?.identity; + final connected = connection.phase == ConnectionPhase.connected; + final newRuntime = identity != null && identity != _runtimeIdentity; + final enteredConnected = connected && !_connected; + _connected = connected; + // Stopping keeps the last session's diagnostics available at the same path. + if (identity != null) _runtimeIdentity = identity; + if (!newRuntime && !enteredConnected) return; + _readGeneration++; + _resetMissingFile(); + unawaited(_poll()); + } + + bool _readIsCurrent(int generation) => + isPageActive && generation == _readGeneration; + Future _poll() async { if (_reading || !isPageActive) return; _reading = true; + final generation = _readGeneration; try { var replace = !state.fileExists; - var chunk = await RuntimeDiagnosticFiles.readLog( - params.path, - offset: replace ? -1 : _offset, - ); + var chunk = await _readLog(params.path, offset: replace ? -1 : _offset); + if (!_readIsCurrent(generation)) return; if (chunk != null && !replace && (chunk.size < _offset || chunk.size - _offset > _maxBufferBytes)) { - chunk = await RuntimeDiagnosticFiles.readLog(params.path); + chunk = await _readLog(params.path); + if (!_readIsCurrent(generation)) return; replace = true; } if (chunk == null) { @@ -103,11 +146,13 @@ class LogFileViewerController extends PageCubit { truncated: replace ? _droppedContent : state.truncated, ); } catch (error) { + if (!_readIsCurrent(generation)) return; // An unavailable file must not leave a stale successful view. _resetMissingFile(); emit(state.copyWith(failure: error)); } finally { _reading = false; + if (isPageActive && generation != _readGeneration) unawaited(_poll()); } } @@ -200,6 +245,8 @@ class LogFileViewerController extends PageCubit { @override Future disposePageResources() async { + _readGeneration++; + _coordinator.state.removeListener(_connectionChanged); _timer?.cancel(); } } diff --git a/test/pages/advanced/xray/log_controller_test.dart b/test/pages/advanced/xray/log_controller_test.dart new file mode 100644 index 000000000..2a7e5640d --- /dev/null +++ b/test/pages/advanced/xray/log_controller_test.dart @@ -0,0 +1,213 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/pigeon/model.dart'; +import 'package:onexray/pages/advanced/xray/log/controller.dart'; +import 'package:onexray/pages/advanced/xray/log/params.dart'; +import 'package:onexray/service/advanced/xray/runtime_files.dart'; +import 'package:onexray/service/connect/compiler.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; + +import '../../../support/test_directory.dart'; + +void main() { + late File file; + late ConnectionCoordinator coordinator; + late ConnectionRuntime first; + + setUp(() async { + final directory = await createTestDirectory('log-viewer-session-'); + addTearDown(() => directory.delete(recursive: true)); + file = File('${directory.path}/access.log'); + await file.writeAsString('OLD\n'); + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + coordinator = ConnectionCoordinator( + database: db, + statusEvents: const Stream.empty(), + disposeStatus: () {}, + ); + addTearDown(coordinator.dispose); + first = _runtime(1); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: first, + ); + }); + + for (final replacement in ['NEW\n', 'NEW\nMORE\n']) { + test( + 'new session replaces equal or larger log without keeping old lines: ${replacement.length} bytes', + () async { + final controller = LogFileViewerController( + LogFileViewerParams(title: 'Access log', path: file.path), + coordinator: coordinator, + ); + addTearDown(controller.close); + await _waitFor(controller, (state) => state.lines.join('\n') == 'OLD'); + await file.delete(); + await file.writeAsString(replacement); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(2), + ); + final expected = replacement.trimRight(); + final state = await _waitFor( + controller, + (state) => state.lines.join('\n') == expected, + ); + expect(state.lines, isNot(contains('OLD'))); + expect(state.failure, isNull); + }, + ); + } + + test('same-session append and repeated connected preserve content; disconnect retains diagnostics and a new connected edge reloads', () async { + final controller = LogFileViewerController( + LogFileViewerParams(title: 'Access log', path: file.path), + coordinator: coordinator, + ); + addTearDown(controller.close); + await _waitFor(controller, (state) => state.lines.join('\n') == 'OLD'); + var missingStates = 0; + final subscription = controller.stream.listen((state) { + if (!state.fileExists) missingStates++; + }); + addTearDown(subscription.cancel); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: first, + uploadSpeed: 20, + ); + await file.writeAsString('APPEND\n', mode: FileMode.append); + await _waitFor( + controller, + (state) => state.lines.join('\n') == 'OLD\nAPPEND', + ); + expect(missingStates, 0); + + coordinator.state.value = const ConnectionView( + phase: ConnectionPhase.disconnected, + ); + expect(controller.state.lines, ['OLD', 'APPEND']); + expect(controller.state.fileExists, isTrue); + await file.delete(); + await file.writeAsString('RESTART ENTRY\n'); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: first, + ); + await _waitFor( + controller, + (state) => state.lines.join('\n') == 'RESTART ENTRY', + ); + expect(missingStates, 1); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: first, + downloadSpeed: 30, + ); + await pumpEventQueue(); + expect(missingStates, 1); + expect(controller.state.lines, ['RESTART ENTRY']); + await controller.close(); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(3), + ); + await pumpEventQueue(); + expect(controller.state.lines, ['RESTART ENTRY']); + }); + + for (final lateError in [false, true]) { + test( + 'new session discards an in-flight old ${lateError ? 'error' : 'chunk'} and reads the replacement', + () async { + final readStarted = Completer(); + final release = Completer<({Uint8List data, int offset, int size})?>(); + ({Uint8List data, int offset, int size})? oldChunk; + var blockNextRead = false; + final controller = LogFileViewerController( + LogFileViewerParams(title: 'Access log', path: file.path), + coordinator: coordinator, + readLog: (path, {int offset = -1}) async { + final chunk = await RuntimeDiagnosticFiles.readLog( + path, + offset: offset, + ); + if (blockNextRead) { + blockNextRead = false; + oldChunk = chunk; + readStarted.complete(); + return release.future; + } + return chunk; + }, + ); + addTearDown(controller.close); + await _waitFor(controller, (state) => state.lines.join('\n') == 'OLD'); + blockNextRead = true; + await file.writeAsString('LATE OLD\n', mode: FileMode.append); + await readStarted.future.timeout(const Duration(seconds: 4)); + await file.delete(); + await file.writeAsString('NEW\nCURRENT\n'); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(2), + ); + final observed = []; + final subscription = controller.stream.listen(observed.add); + addTearDown(subscription.cancel); + if (lateError) { + release.completeError(const FileSystemException('old read failed')); + } else { + release.complete(oldChunk); + } + final state = await _waitFor( + controller, + (state) => state.lines.join('\n') == 'NEW\nCURRENT', + ); + expect(state.failure, isNull); + expect(observed.every((state) => state.failure == null), isTrue); + expect( + observed.expand((state) => state.lines), + isNot(contains('LATE OLD')), + ); + }, + ); + } +} + +Future _waitFor( + LogFileViewerController controller, + bool Function(LogFileViewerPageState) predicate, +) { + if (predicate(controller.state)) return Future.value(controller.state); + return controller.stream + .firstWhere(predicate) + .timeout(const Duration(seconds: 4)); +} + +ConnectionRuntime _runtime(int startedAt) => ConnectionRuntime.create( + configuration: ConnectionConfiguration(), + compiled: CompiledConnection( + xrayJson: '{}', + entries: const [], + finalExit: null, + nodeTags: const {}, + ), + platform: ConnectionPlatform.android, + request: StartVpnRequest( + null, + null, + '18003', + '{"apiVersion":3,"method":"runXray","payload":{"xrayJson":"{}"}}', + ), + startedAt: DateTime.fromMicrosecondsSinceEpoch(startedAt), +); From bf94e3fb307cfd007668a7127be561dc54982ff9 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 20:07:44 +0800 Subject: [PATCH 47/60] test(logs): isolate the viewer settings test coordinator --- test/pages/advanced/xray/settings_ui_test.dart | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/pages/advanced/xray/settings_ui_test.dart b/test/pages/advanced/xray/settings_ui_test.dart index 5edeb4d50..839c8e732 100644 --- a/test/pages/advanced/xray/settings_ui_test.dart +++ b/test/pages/advanced/xray/settings_ui_test.dart @@ -1,10 +1,12 @@ import 'dart:io'; +import 'package:drift/native.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:onexray/core/constants/preferences.dart'; +import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/advanced/xray/log/controller.dart'; @@ -18,6 +20,7 @@ import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/service/shared/ping/state.dart'; import 'package:onexray/service/advanced/xray/data_update/state.dart'; +import 'package:onexray/service/connect/coordinator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; // ignore: depend_on_referenced_packages import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; @@ -343,9 +346,18 @@ void main() { addTearDown(() => tempDir.delete(recursive: true)); final logFile = File('${tempDir.path}/access.log'); await logFile.writeAsString('first line\nsecond line\n'); + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + final coordinator = ConnectionCoordinator( + database: database, + statusEvents: const Stream.empty(), + disposeStatus: () {}, + ); + addTearDown(coordinator.dispose); final controller = LogFileViewerController( LogFileViewerParams(title: 'access log', path: logFile.path), + coordinator: coordinator, ); addTearDown(controller.close); await controller.stream From df01a36bef2255b82e4fbfaad887fe5d884e7e5e Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 21:41:05 +0800 Subject: [PATCH 48/60] feat(ios): run saved VPN actions from widgets in the background --- .../net/yuandev/onexray/pigeon/Messages.g.kt | 101 ++++++++ ios/Runner.xcodeproj/project.pbxproj | 8 +- ios/Runner/AppleWidgetApi.swift | 40 ++++ lib/core/pigeon/messages.g.dart | 102 ++++++++ .../tunnel/android/automation/service.dart | 20 +- lib/service/connect/coordinator.dart | 88 ++++++- lib/service/connect/runtime.dart | 14 ++ lib/service/connect/runtime_host.dart | 90 ++++++- lib/service/shared/apple_widget/service.dart | 60 ++++- pigeon/message.dart | 15 ++ swift/App/Vpn.swift | 223 +++++++++++++++-- swift/App/pigeon/Messages.g.swift | 87 +++++++ swift/iOS/AppleVpnStartAdmission.swift | 70 +++++- swift/iOS/AppleWidgetActionInbox.swift | 48 +++- swift/iOS/AppleWidgetIntents.swift | 123 +++++++++- swift/iOS/AppleWidgetState.swift | 33 +++ swift/iOS/AppleWidgetVpnActions.swift | 58 +++++ test/native/AppleWidgetTests.swift | 226 +++++++++++++++++- test/service/connect/coordinator_test.dart | 185 ++++++++++++++ test/service/connect/runtime_host_test.dart | 69 ++++++ .../shared/apple_widget/service_test.dart | 65 +++++ .../apple_widget/start_blocked_test.dart | 63 +++++ tool/test_apple_widget.sh | 1 + 23 files changed, 1717 insertions(+), 72 deletions(-) create mode 100644 swift/iOS/AppleWidgetVpnActions.swift create mode 100644 test/service/shared/apple_widget/start_blocked_test.dart diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt b/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt index fd9f35016..55d742840 100644 --- a/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt +++ b/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt @@ -682,6 +682,7 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { } } + /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface BridgeHostApi { fun getTunFilesDir(callback: (Result) -> Unit) @@ -1013,6 +1014,11 @@ interface BridgeHostApi { interface AppleWidgetHostApi { fun takeAction(callback: (Result) -> Unit) fun setLanguage(languageCode: String, callback: (Result) -> Unit) + fun beginPreparation(callback: (Result) -> Unit) + fun finishPreparation(token: String, callback: (Result) -> Unit) + fun startPreparedVpn(token: String, requestJson: String, callback: (Result) -> Unit) + fun connectedAtMilliseconds(callback: (Result) -> Unit) + fun setStartBlocked(blocked: Boolean, callback: (Result) -> Unit) companion object { /** The codec used by AppleWidgetHostApi. */ @@ -1060,6 +1066,101 @@ interface AppleWidgetHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.beginPreparation{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.finishPreparation$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tokenArg = args[0] as String + api.finishPreparation(tokenArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + reply.reply(MessagesPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.startPreparedVpn$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val tokenArg = args[0] as String + val requestJsonArg = args[1] as String + api.startPreparedVpn(tokenArg, requestJsonArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.connectedAtMilliseconds$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + api.connectedAtMilliseconds{ result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.setStartBlocked$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val blockedArg = args[0] as Boolean + api.setStartBlocked(blockedArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + reply.reply(MessagesPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 0bd493ec7..a88adbba8 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + EB2401000000000000000002 /* AppleWidgetVpnActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2401000000000000000001 /* AppleWidgetVpnActions.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -108,6 +109,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + EB2401000000000000000001 /* AppleWidgetVpnActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppleWidgetVpnActions.swift; path = ../swift/iOS/AppleWidgetVpnActions.swift; sourceTree = SOURCE_ROOT; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; @@ -292,6 +294,7 @@ EB2400000000000000000011 /* AppleWidgetPublisher.swift */, EB2400000000000000000012 /* AppleWidgetActionInbox.swift */, EB2400000000000000000013 /* AppleWidgetIntents.swift */, + EB2401000000000000000001 /* AppleWidgetVpnActions.swift */, EB2400000000000000000014 /* AppleWidgetApi.swift */, EB2400000000000000000016 /* AppleVpnStartAdmission.swift */, EB2400000000000000000020 /* Widget.strings */, @@ -565,6 +568,7 @@ EB2400000000000000000104 /* AppleWidgetPublisher.swift in Sources */, EB2400000000000000000106 /* AppleWidgetActionInbox.swift in Sources */, EB2400000000000000000108 /* AppleWidgetIntents.swift in Sources */, + EB2401000000000000000002 /* AppleWidgetVpnActions.swift in Sources */, EB240000000000000000010A /* AppleWidgetApi.swift in Sources */, EB240000000000000000010C /* AppleVpnStartAdmission.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, @@ -974,7 +978,7 @@ REGISTER_APP_GROUPS = YES; SKIP_INSTALL = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited) ONEXRAY_WIDGET_EXTENSION"; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -1031,6 +1035,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ONEXRAY_WIDGET_EXTENSION"; }; name = Release; }; @@ -1076,6 +1081,7 @@ SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ONEXRAY_WIDGET_EXTENSION"; }; name = Profile; }; diff --git a/ios/Runner/AppleWidgetApi.swift b/ios/Runner/AppleWidgetApi.swift index b4b746d21..494c2ab3e 100644 --- a/ios/Runner/AppleWidgetApi.swift +++ b/ios/Runner/AppleWidgetApi.swift @@ -33,4 +33,44 @@ final class AppleWidgetApi: NSObject, @preconcurrency AppleWidgetHostApi { completion(.failure(error)) } } + + func beginPreparation(completion: @escaping (Result) -> Void) { + do { + completion(.success(try VPNManager.shared.beginDartPreparation())) + } catch is CancellationError { + completion(.failure(PigeonError(code: "apple-preparation-cancelled", message: nil, details: nil))) + } catch { + completion(.failure(error)) + } + } + + func finishPreparation(token: String, completion: @escaping (Result) -> Void) { + VPNManager.shared.finishDartPreparation(token: token) + completion(.success(())) + } + + func startPreparedVpn(token: String, requestJson: String, completion: @escaping (Result) -> Void) { + Task { @MainActor in + do { + completion(.success(try await VPNManager.shared.startPreparedVpn(token: token, requestJson: requestJson))) + } catch is CancellationError { + completion(.failure(PigeonError(code: "apple-preparation-cancelled", message: nil, details: nil))) + } catch { + completion(.failure(error)) + } + } + } + + func connectedAtMilliseconds(completion: @escaping (Result) -> Void) { + completion(.success(VPNManager.shared.connectedAtMilliseconds())) + } + + func setStartBlocked(blocked: Bool, completion: @escaping (Result) -> Void) { + do { + try VPNManager.shared.setStartBlocked(blocked) + completion(.success(())) + } catch { + completion(.failure(error)) + } + } } diff --git a/lib/core/pigeon/messages.g.dart b/lib/core/pigeon/messages.g.dart index c4f71f81b..6390481da 100644 --- a/lib/core/pigeon/messages.g.dart +++ b/lib/core/pigeon/messages.g.dart @@ -960,6 +960,108 @@ class AppleWidgetHostApi { isNullValid: true, ); } + + Future beginPreparation() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as String; + } + + Future finishPreparation(String token) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.finishPreparation$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [token], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future startPreparedVpn( + String token, + String requestJson, + ) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.startPreparedVpn$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [token, requestJson], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as NativeVpnCommandResult; + } + + Future connectedAtMilliseconds() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.connectedAtMilliseconds$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as int?; + } + + Future setStartBlocked(bool blocked) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.setStartBlocked$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [blocked], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } } class AndroidAutomationHostApi { diff --git a/lib/service/advanced/tunnel/android/automation/service.dart b/lib/service/advanced/tunnel/android/automation/service.dart index df9f37e3a..41a5f3652 100644 --- a/lib/service/advanced/tunnel/android/automation/service.dart +++ b/lib/service/advanced/tunnel/android/automation/service.dart @@ -3,12 +3,20 @@ import 'package:onexray/core/tools/platform.dart'; /// Device authorization only; VPN lifecycle remains entirely native. class AndroidAutomationService { - AndroidAutomationService({AndroidAutomationHostApi? api, bool? supported}) - : _api = api ?? AndroidAutomationHostApi(), - _supported = supported ?? AppPlatform.isAndroid; + AndroidAutomationService({ + AndroidAutomationHostApi? api, + bool? supported, + AppleWidgetHostApi? appleApi, + bool? supportedIOS, + }) : _api = api ?? AndroidAutomationHostApi(), + _supported = supported ?? AppPlatform.isAndroid, + _appleApi = appleApi ?? AppleWidgetHostApi(), + _supportedIOS = supportedIOS ?? AppPlatform.isIOS; final AndroidAutomationHostApi _api; final bool _supported; + final AppleWidgetHostApi _appleApi; + final bool _supportedIOS; static const packageName = 'net.yuandev.onexray'; static const receiver = '$packageName.automation.VpnAutomationReceiver'; @@ -25,7 +33,11 @@ class AndroidAutomationService { Future resetToken() => _api.resetToken(); Future setStartBlocked(bool blocked) async { - if (_supported) await _api.setStartBlocked(blocked); + if (_supported) { + await _api.setStartBlocked(blocked); + } else if (_supportedIOS) { + await _appleApi.setStartBlocked(blocked); + } } Future clear() async { diff --git a/lib/service/connect/coordinator.dart b/lib/service/connect/coordinator.dart index d4e87c91e..0b15a1dd2 100644 --- a/lib/service/connect/coordinator.dart +++ b/lib/service/connect/coordinator.dart @@ -71,14 +71,27 @@ typedef PrepareConnection = Future Function( Future, ); +final class _NativePreparation { + String? token; + bool registering = false; + bool stoppedNatively = false; + final invalidatedDuringRegistration = {}; +} + /// All user/system-entry commands share one serial queue. No SQLite transaction /// remains open while awaiting a permission prompt, a probe or a native VPN. class ConnectionCoordinator with WidgetsBindingObserver { static final instance = ConnectionCoordinator(); final AppDatabase db; late final PrepareConnection _prepare; - late final Future Function(ConnectionRuntime, Future) + late final Future Function( + ConnectionRuntime, + Future, + String?, + ) _start; + late final Future Function() _beginPreparation; + late final Future Function(String) _finishPreparation; late final Future Function() _stop; late final InspectConnection _inspect; late final Future Function(ConnectionRuntime) _readTraffic; @@ -102,6 +115,7 @@ class ConnectionCoordinator with WidgetsBindingObserver { int _trafficGeneration = 0; bool _commandActive = false; Completer? _cancel; + _NativePreparation? _nativePreparation; ConnectionRuntime? _pendingRuntime; Set _preparingNodeIds = {}; bool _closed = false; @@ -121,15 +135,22 @@ class ConnectionCoordinator with WidgetsBindingObserver { Stream? statusEvents, Future Function()? observeStatus, void Function()? disposeStatus, + Future Function()? beginPreparation, + Future Function(String)? finishPreparation, }) : db = database ?? AppDatabase(), _statusEvents = statusEvents ?? AppFlutterApi().vpnStatusController.stream, _observeStatus = observeStatus ?? AppHostApi().observeVpnStatus, _disposeStatus = disposeStatus ?? AppHostApi().disposeVpnStatus { final host = ConnectionRuntimeHost(); + _beginPreparation = + beginPreparation ?? + (start == null ? host.beginPreparation : () async => null); + _finishPreparation = finishPreparation ?? host.finishPreparation; _start = start == null - ? (runtime, cancelled) => host.start(runtime, cancelled: cancelled) - : (runtime, cancelled) => Future.any([ + ? (runtime, cancelled, token) => + host.start(runtime, cancelled: cancelled, preparationToken: token) + : (runtime, cancelled, _) => Future.any([ start(runtime), cancelled.then( (_) => throw const ConnectionHostException('cancelled'), @@ -537,6 +558,8 @@ class ConnectionCoordinator with WidgetsBindingObserver { throw const ConnectionHostException('runtimeMetadataUnavailable'); } bool touchedHost = false; + final preparation = _NativePreparation(); + _nativePreparation = preparation; try { _preparingNodeIds = {...?old?.nodeIds}; _checkCancelled(cancellation); @@ -554,6 +577,23 @@ class ConnectionCoordinator with WidgetsBindingObserver { } } _checkCancelled(cancellation); + if (shouldStart) { + preparation.registering = true; + try { + preparation.token = await _beginPreparation(); + } finally { + preparation.registering = false; + } + if (preparation.token != null && + preparation.invalidatedDuringRegistration.contains( + preparation.token, + )) { + preparation.stoppedNatively = true; + cancel(); + } + preparation.invalidatedDuringRegistration.clear(); + _checkCancelled(cancellation); + } if (!disconnect) { state.value = ConnectionView( phase: ConnectionPhase.preparing, @@ -573,7 +613,11 @@ class ConnectionCoordinator with WidgetsBindingObserver { phase: ConnectionPhase.connecting, traffic: running.traffic, ); - running = await _start(runtime, cancellation.future); + running = await _start( + runtime, + cancellation.future, + preparation.token, + ); _checkCancelled(cancellation); if (!running.connected || running.runtime?.identity != runtime.identity) { @@ -589,8 +633,18 @@ class ConnectionCoordinator with WidgetsBindingObserver { ); _publish(running, issue: runtime?.notice); } catch (error, stack) { + if (error is NativePreparationCancelled) { + preparation.stoppedNatively = true; + cancel(); + } HostConnection? failed; - if (touchedHost) { + if (preparation.stoppedNatively) { + try { + failed = await _inspect(await _currentRuntime()); + } catch (_) { + // A later native start may already own the connection. + } + } else if (touchedHost) { try { failed = await _stop(); } catch (_) { @@ -607,7 +661,10 @@ class ConnectionCoordinator with WidgetsBindingObserver { final issue = cancellation.isCompleted ? 'cancelled' : connectionFailureReason(error); - if (touchedHost) { + if (preparation.stoppedNatively) { + _failureLatched = false; + if (failed != null) _publish(failed); + } else if (touchedHost) { final status = failed?.status; _failureLatched = true; state.value = ConnectionView( @@ -634,9 +691,14 @@ class ConnectionCoordinator with WidgetsBindingObserver { } Error.throwWithStackTrace(error, stack); } finally { + if (identical(_nativePreparation, preparation)) { + _nativePreparation = null; + } _pendingRuntime = null; _preparingNodeIds = {}; _cancel = null; + final token = preparation.token; + if (token != null) await _finishPreparation(token); } } @@ -658,6 +720,20 @@ class ConnectionCoordinator with WidgetsBindingObserver { if (_cancel?.isCompleted == false) _cancel!.complete(); } + /// Native Stop already handled the tunnel; retire only its old Dart work. + void invalidatePreparations(Iterable tokens) { + final preparation = _nativePreparation; + if (preparation == null) return; + if (preparation.registering) { + preparation.invalidatedDuringRegistration.addAll(tokens); + return; + } + if (preparation.token != null && tokens.contains(preparation.token)) { + preparation.stoppedNatively = true; + cancel(); + } + } + void _checkConnectionRequest(int generation) { if (generation != _cancellationGeneration) { throw const ConnectionHostException('cancelled'); diff --git a/lib/service/connect/runtime.dart b/lib/service/connect/runtime.dart index 98e169c66..561f49b11 100644 --- a/lib/service/connect/runtime.dart +++ b/lib/service/connect/runtime.dart @@ -179,6 +179,20 @@ class ConnectionRuntime { this.selectionFallback, }); + ConnectionRuntime withStartedAt(DateTime value) => ConnectionRuntime._( + configuration: configuration, + platform: platform, + request: request, + xrayJson: xrayJson, + entries: entries, + finalExit: finalExit, + profile: profile, + rawName: rawName, + startedAt: value, + notice: notice, + selectionFallback: selectionFallback, + ); + factory ConnectionRuntime.create({ required ConnectionConfiguration configuration, required CompiledConnection compiled, diff --git a/lib/service/connect/runtime_host.dart b/lib/service/connect/runtime_host.dart index 457508b58..ed08056fa 100644 --- a/lib/service/connect/runtime_host.dart +++ b/lib/service/connect/runtime_host.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:onexray/core/errors/failure.dart'; import 'package:onexray/core/ffi/windows/model.dart'; @@ -14,6 +15,7 @@ import 'package:onexray/core/pigeon/host_api.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/core/pigeon/model.dart'; import 'package:onexray/core/pigeon/model_writer.dart'; +import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/service/advanced/platform_policy.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/settings.dart'; @@ -49,6 +51,10 @@ class ConnectionHostException extends AppFailure { : super(FailureCategory.runtime, reason); } +final class NativePreparationCancelled extends ConnectionHostException { + const NativePreparationCancelled() : super('cancelled'); +} + /// Native status owns VPN state. Xray metrics supplies current-session counters. class ConnectionRuntimeHost { final AppHostApi _host = AppHostApi(); @@ -59,6 +65,10 @@ class ConnectionRuntimeHost { _startVpn; final Future Function(ConnectionRuntime runtime)? _writeStartFile; final Future Function()? _stopVpn; + final AppleWidgetHostApi _appleWidgetApi; + final Future Function(String, String)? + _startPreparedVpn; + final Future Function()? _connectedAtMilliseconds; ConnectionRuntimeHost({ String? runDirectory, @@ -68,13 +78,51 @@ class ConnectionRuntimeHost { startVpn, Future Function(ConnectionRuntime runtime)? writeStartFile, Future Function()? stopVpn, + AppleWidgetHostApi? appleWidgetApi, + Future Function(String, String)? startPreparedVpn, + Future Function()? connectedAtMilliseconds, }) : _runDirectory = runDirectory, _readStatus = readStatus, _startVpn = startVpn, _writeStartFile = writeStartFile, _stopVpn = stopVpn, + _appleWidgetApi = appleWidgetApi ?? AppleWidgetHostApi(), + _startPreparedVpn = startPreparedVpn, + _connectedAtMilliseconds = connectedAtMilliseconds, _metrics = readMetrics; + Future beginPreparation() async { + if (!AppPlatform.isIOS) return null; + try { + return await _appleWidgetApi.beginPreparation(); + } on PlatformException catch (error) { + if (error.code == 'apple-preparation-cancelled') { + throw const NativePreparationCancelled(); + } + rethrow; + } + } + + Future finishPreparation(String token) => + _appleWidgetApi.finishPreparation(token); + + Future _submitPrepared( + String token, + String requestJson, + ) async { + try { + return await (_startPreparedVpn ?? _appleWidgetApi.startPreparedVpn)( + token, + requestJson, + ); + } on PlatformException catch (error) { + if (error.code == 'apple-preparation-cancelled') { + throw const NativePreparationCancelled(); + } + rethrow; + } + } + String get _directory => _runDirectory ?? VpnConstants.runDir; Future< @@ -215,6 +263,20 @@ class ConnectionRuntimeHost { final platform = observedStatus == null ? await _status() : (status: observedStatus, permission: null, message: null); + if (platform.status == VpnStatus.connected && + runtime?.platform == ConnectionPlatform.ios) { + final readConnectedAt = + _connectedAtMilliseconds ?? + (_readStatus == null && _startVpn == null + ? _appleWidgetApi.connectedAtMilliseconds + : null); + final milliseconds = await readConnectedAt?.call(); + if (milliseconds != null && milliseconds > 0) { + runtime = runtime!.withStartedAt( + DateTime.fromMillisecondsSinceEpoch(milliseconds), + ); + } + } return HostConnection( platform.status, runtime: platform.status == VpnStatus.disconnected ? null : runtime, @@ -257,6 +319,7 @@ class ConnectionRuntimeHost { Future start( ConnectionRuntime runtime, { Future? cancelled, + String? preparationToken, }) async { var wasCancelled = false; unawaited( @@ -264,11 +327,15 @@ class ConnectionRuntimeHost { wasCancelled = true; }), ); - final writeStartFile = _writeStartFile; - if (writeStartFile != null) { - await writeStartFile(runtime); - } else if (_startVpn == null) { - await runtime.request.writeToStartFile(); + final preparedIos = + runtime.platform == ConnectionPlatform.ios && _startVpn == null; + if (!preparedIos) { + final writeStartFile = _writeStartFile; + if (writeStartFile != null) { + await writeStartFile(runtime); + } else if (_startVpn == null) { + await runtime.request.writeToStartFile(); + } } if (cancelled != null) { // Observe an already completed cancellation before submitting any native @@ -278,7 +345,18 @@ class ConnectionRuntimeHost { } // Only iOS has native admission and preparation draining for concurrent // Stop. Other platforms retain their serialized native-command contract. - final submitted = _start(runtime); + final Future submitted; + if (preparedIos) { + if (preparationToken == null) { + throw const ConnectionHostException('preparationUnavailable'); + } + submitted = _submitPrepared( + preparationToken, + jsonEncode(runtime.request.toJson()), + ); + } else { + submitted = _start(runtime); + } final result = await (cancelled == null || runtime.platform != ConnectionPlatform.ios ? submitted diff --git a/lib/service/shared/apple_widget/service.dart b/lib/service/shared/apple_widget/service.dart index 88d1d0e94..f775d4ca7 100644 --- a/lib/service/shared/apple_widget/service.dart +++ b/lib/service/shared/apple_widget/service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:onexray/core/pigeon/flutter_api.dart'; @@ -6,11 +7,12 @@ import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/core/tools/logger.dart'; import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/launch/app_startup.dart'; import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/service/shared/menu/short_cut/service.dart'; -/// Foreground Apple intents hand off to the existing App connection actions. -/// The Widget never compiles a configuration or directly starts a tunnel. +/// Native intents publish navigation and cancellation effects to the App. final class AppleWidgetService with WidgetsBindingObserver { static final _instance = AppleWidgetService._( AppPlatform.isIOS, @@ -18,6 +20,8 @@ final class AppleWidgetService with WidgetsBindingObserver { AppleWidgetHostApi().takeAction, AppleWidgetHostApi().setLanguage, ShortCutService().receive, + AppStartupService().suppressConnectOnAppLaunch, + (tokens) => ConnectionCoordinator.instance.invalidatePreparations(tokens), ); factory AppleWidgetService() => _instance; @@ -28,6 +32,8 @@ final class AppleWidgetService with WidgetsBindingObserver { this._takeAction, this._setLanguage, this._receive, + this._suppressAutoConnect, + this._invalidatePreparations, ); @visibleForTesting @@ -37,13 +43,25 @@ final class AppleWidgetService with WidgetsBindingObserver { required Future Function() takeAction, required Future Function(String) setLanguage, required Future Function(String) receive, - }) => AppleWidgetService._(enabled, events, takeAction, setLanguage, receive); + void Function()? suppressAutoConnect, + void Function(Iterable)? invalidatePreparations, + }) => AppleWidgetService._( + enabled, + events, + takeAction, + setLanguage, + receive, + suppressAutoConnect ?? () {}, + invalidatePreparations ?? (_) {}, + ); final bool _enabled; final Stream _events; final Future Function() _takeAction; final Future Function(String) _setLanguage; final Future Function(String) _receive; + final void Function() _suppressAutoConnect; + final void Function(Iterable) _invalidatePreparations; StreamSubscription? _subscription; Future? _draining; bool _needsDrain = false; @@ -72,10 +90,7 @@ final class AppleWidgetService with WidgetsBindingObserver { _needsDrain = false; final action = await _takeAction(); if (_subscription == null) return; - if (action == 'startVpn' || action == 'stopVpn') { - // Stop must not wait for an in-flight connection attempt to finish. - unawaited(_receive(action!)); - } + if (action != null) _receiveNativeAction(action); if (action == null && !_needsDrain) return; } } catch (error, stack) { @@ -84,6 +99,37 @@ final class AppleWidgetService with WidgetsBindingObserver { } } + void _receiveNativeAction(String action) { + // Retain the old internal action format while consuming only envelopes + // from the current native intent implementation. + if (action == 'startVpn' || action == 'stopVpn') { + unawaited(_receive(action)); + return; + } + final Object? decoded; + try { + decoded = jsonDecode(action); + } on FormatException { + return; + } + if (decoded is! Map || + decoded['version'] != 1 || + decoded['suppressAutoConnect'] is! bool || + decoded['openConnect'] is! bool || + decoded['invalidatedPreparations'] is! List || + (decoded['invalidatedPreparations'] as List).any( + (value) => value is! String, + )) { + return; + } + if (decoded['suppressAutoConnect'] == true) _suppressAutoConnect(); + final tokens = (decoded['invalidatedPreparations'] as List).cast(); + if (tokens.isNotEmpty) _invalidatePreparations(tokens); + if (decoded['openConnect'] == true) { + unawaited(_receive(ShortCutAction.openConnect.name)); + } + } + /// Public URLs only navigate. Connection commands are private intent input. bool receiveOpenUri(Uri uri) { if (!_enabled || !isOpenUri(uri)) return false; diff --git a/pigeon/message.dart b/pigeon/message.dart index 64898f8ef..b1c1f0f58 100644 --- a/pigeon/message.dart +++ b/pigeon/message.dart @@ -77,6 +77,21 @@ abstract class AppleWidgetHostApi { @asyncCallback void setLanguage(String languageCode); + + @asyncCallback + String beginPreparation(); + + @asyncCallback + void finishPreparation(String token); + + @asyncCallback + NativeVpnCommandResult startPreparedVpn(String token, String requestJson); + + @asyncCallback + int? connectedAtMilliseconds(); + + @asyncCallback + void setStartBlocked(bool blocked); } class AndroidAutomationSettings { diff --git a/swift/App/Vpn.swift b/swift/App/Vpn.swift index 8d3a56b46..bae8fcd83 100644 --- a/swift/App/Vpn.swift +++ b/swift/App/Vpn.swift @@ -30,8 +30,13 @@ class VPNManager { private var observedConnecting = false private var cancellable: Cancellable? private var statusObserver: VPNStatusCallback? + private var stopTask: Task? #if os(iOS) - private let startAdmission = AppleVpnStartAdmission() + private let startAdmission = AppleVpnStartAdmission( + startBlockedURL: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId())? + .appendingPathComponent("vpn-start.blocked") + ) + private var activeStartGeneration: UInt64? #endif #if os(macOS) private let systemExtensionActivation = SystemExtensionActivation() @@ -360,6 +365,143 @@ class VPNManager { return result } + func connectedAtMilliseconds() -> Int64? { + #if targetEnvironment(simulator) + return nil + #else + guard let connection = vpn?.connection, + connection.status == .connected || connection.status == .reasserting, + let date = connection.connectedDate else { return nil } + return Int64(date.timeIntervalSince1970 * 1000) + #endif + } + + #if os(iOS) + func requireStartAllowed() throws { + guard FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId()) != nil else { + throw VPNError.noGroupContainer + } + try startAdmission.requireStartAllowed() + } + + func setStartBlocked(_ blocked: Bool) throws { + try startAdmission.setStartBlocked(blocked) + if blocked { + let tokens = startAdmission.invalidate() + AppleWidgetActionInbox.shared.recordNativeStop(invalidatedPreparations: tokens) + } + } + + func beginDartPreparation() throws -> String { + try requireStartAllowed() + guard stopTask == nil else { throw CancellationError() } + return try startAdmission.beginDartPreparation() + } + + func finishDartPreparation(token: String) { + startAdmission.finishDartPreparation(token: token) + } + + func startPreparedVpn(token: String, requestJson: String) async throws -> NativeVpnCommandResult { + guard startAdmission.consumeDartPreparation(token: token) else { throw CancellationError() } + let request: StartVpnRequest + let generation: UInt64 + do { + try requireStartAllowed() + guard stopTask == nil else { throw CancellationError() } + request = try JsonTool.decode(StartVpnRequest.self, from: requestJson) + guard request.coreInvokeText?.isEmpty == false else { + return NativeVpnCommandResult(state: .failed, message: "Missing Xray run configuration.") + } + guard let group = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId()) else { + throw VPNError.noGroupContainer + } + let file = group.appending(path: StartModelFile) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(requestJson.utf8).write(to: file, options: .atomic) + // No await separates token consumption, publication, and native admission. + generation = startAdmission.begin() + } catch is CancellationError { + throw CancellationError() + } catch { + return NativeVpnCommandResult(state: .failed, message: error.localizedDescription) + } + let result = await startVpn(request: request, generation: generation) + try startAdmission.check(generation) + return result + } + + var widgetCommandGeneration: UInt64 { startAdmission.generation } + + func readWidgetVpnStatus() async throws -> AppleWidgetStatus { + let generation = startAdmission.generation + do { + #if !targetEnvironment(simulator) + let current = try await findVpn() + try startAdmission.check(generation) + vpn = current + #endif + if stopTask != nil { return .disconnecting } + if startAdmission.hasCurrentDartPreparation { return .connecting } + if let active = activeStartGeneration, startAdmission.isCurrent(active) { return .connecting } + let status = try await readStatus() + try startAdmission.check(generation) + let result: AppleWidgetStatus + switch status ?? .invalid { + case .connected: result = .connected + case .connecting, .reasserting: result = .connecting + case .disconnecting: result = .disconnecting + case .disconnected, .invalid: result = .disconnected + @unknown default: result = .unknown + } + AppleWidgetPublisher.publish(result) + return result + } catch is CancellationError { + throw CancellationError() + } catch { + try startAdmission.check(generation) + AppleWidgetPublisher.publish(.unknown) + throw error + } + } + + func startSavedVpnForWidget() async throws -> AppleWidgetCommandOutcome { + try requireStartAllowed() + if stopTask != nil { return .inProgress } + if startAdmission.hasCurrentDartPreparation { return .inProgress } + if let generation = activeStartGeneration, startAdmission.isCurrent(generation) { return .inProgress } + #if !targetEnvironment(simulator) + if vpn?.connection.status == .connected { return .completed } + if let status = vpn?.connection.status, [.connecting, .reasserting, .disconnecting].contains(status) { + return .inProgress + } + guard vpn != nil else { return .needsForeground } + #endif + guard let request = StartVpnRequest.startModel, request.coreInvokeText?.isEmpty == false else { + return .needsForeground + } + let generation = startAdmission.begin() + let result = await startVpn(request: request, generation: generation) + guard startAdmission.isCurrent(generation) else { return .inProgress } + return try widgetOutcome(result, wanted: .connected) + } + + func stopVpnForWidget() async throws -> AppleWidgetCommandOutcome { + try widgetOutcome(await stopVpn(), wanted: .disconnected) + } + + private func widgetOutcome(_ result: NativeVpnCommandResult, wanted: VpnStatus) throws -> AppleWidgetCommandOutcome { + switch result.state { + case .waitingForPlatformPermission: return .needsForeground + case .failed: throw AppleWidgetVpnActionError(message: result.message ?? "VPN action failed.") + case .success: + if result.status == wanted { return .completed } + if result.status == .connecting || result.status == .disconnecting { return .inProgress } + throw AppleWidgetVpnActionError(message: result.message ?? lastCommandError ?? "VPN action failed.") + } + } + #endif + private func checkStartAdmission(_ generation: UInt64?) throws { #if os(iOS) if let generation { try startAdmission.check(generation) } @@ -431,16 +573,40 @@ class VPNManager { func startVpn() async -> NativeVpnCommandResult { #if os(iOS) + do { try requireStartAllowed() } + catch { return NativeVpnCommandResult(state: .failed, message: error.localizedDescription) } + if stopTask != nil { return NativeVpnCommandResult(state: .failed, message: "The VPN is still stopping.") } + if let active = activeStartGeneration, startAdmission.isCurrent(active) { + return NativeVpnCommandResult(state: .success, status: .connecting) + } + #if !targetEnvironment(simulator) + if vpn?.connection.status == .connected { return NativeVpnCommandResult(state: .success, status: .connected) } + if let status = vpn?.connection.status, [.connecting, .reasserting].contains(status) { + return NativeVpnCommandResult(state: .success, status: .connecting) + } + #endif let generation: UInt64? = startAdmission.begin() - defer { finishStartPreparation(generation) } #else let generation: UInt64? = nil #endif + return await startVpn(request: StartVpnRequest.startModel, generation: generation) + } + + private func startVpn(request: StartVpnRequest?, generation: UInt64?) async -> NativeVpnCommandResult { + #if os(iOS) + activeStartGeneration = generation + #endif + defer { + finishStartPreparation(generation) + #if os(iOS) + if activeStartGeneration == generation { activeStartGeneration = nil } + #endif + } lastCommandError = nil awaitingStart = true observedConnecting = false - guard let request = StartVpnRequest.startModel else { - lastCommandError = "Unable to read run/start.json." + guard let request, request.coreInvokeText?.isEmpty == false else { + lastCommandError = "Missing Xray run configuration." return await commandResult(.failed, startGeneration: generation) } #if targetEnvironment(simulator) @@ -524,7 +690,19 @@ class VPNManager { func stopVpn() async -> NativeVpnCommandResult { #if os(iOS) - startAdmission.invalidate() + let tokens = startAdmission.invalidate() + AppleWidgetActionInbox.shared.recordNativeStop(invalidatedPreparations: tokens) + #endif + if let stopTask { return await stopTask.value } + let task = Task { @MainActor in await self.stopVpnCommand() } + stopTask = task + let result = await task.value + stopTask = nil + return result + } + + private func stopVpnCommand() async -> NativeVpnCommandResult { + #if os(iOS) await startAdmission.waitForPreparation() #endif lastCommandError = nil @@ -553,21 +731,26 @@ class VPNManager { } } #endif - if vpn == nil { - do { - vpn = try await findVpn() - } catch { - YGLog(error.localizedDescription) - lastCommandError = error.localizedDescription - return await commandResult(.failed) - } + do { + vpn = try await findVpn() + } catch { + lastCommandError = error.localizedDescription + return await commandResult(.failed) } guard let vpn = vpn else { - lastCommandError = VPNError.sessionNotReady.localizedDescription - return await commandResult(.failed) + #if os(iOS) + AppleWidgetPublisher.publish(.disconnected) + #endif + runStatusObserver() + return NativeVpnCommandResult(state: .success, status: .disconnected) } do { - try await saveVpn(vpn: vpn, tun: TunJson()) + if vpn.isOnDemandEnabled || vpn.onDemandRules != nil { + vpn.isOnDemandEnabled = false + vpn.onDemandRules = nil + try await vpn.saveToPreferences() + try await vpn.loadFromPreferences() + } } catch { YGLog(error.localizedDescription) lastCommandError = error.localizedDescription @@ -577,8 +760,12 @@ class VPNManager { guard let session = vpn.connection as? NETunnelProviderSession else { throw VPNError.sessionNotReady } - try await waitForStatus(session: session, accepted: [.disconnected, .invalid], timeout: 15) { - session.stopTunnel() + if session.status == .disconnecting { + try await waitForStatus(session: session, accepted: [.disconnected, .invalid], timeout: 15) + } else if session.status != .disconnected && session.status != .invalid { + try await waitForStatus(session: session, accepted: [.disconnected, .invalid], timeout: 15) { + session.stopTunnel() + } } runStatusObserver() return await commandResult(.success) diff --git a/swift/App/pigeon/Messages.g.swift b/swift/App/pigeon/Messages.g.swift index afdf18907..feb725b71 100644 --- a/swift/App/pigeon/Messages.g.swift +++ b/swift/App/pigeon/Messages.g.swift @@ -913,6 +913,11 @@ class BridgeHostApiSetup { protocol AppleWidgetHostApi { func takeAction(completion: @escaping (Result) -> Void) func setLanguage(languageCode: String, completion: @escaping (Result) -> Void) + func beginPreparation(completion: @escaping (Result) -> Void) + func finishPreparation(token: String, completion: @escaping (Result) -> Void) + func startPreparedVpn(token: String, requestJson: String, completion: @escaping (Result) -> Void) + func connectedAtMilliseconds(completion: @escaping (Result) -> Void) + func setStartBlocked(blocked: Bool, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -953,6 +958,88 @@ class AppleWidgetHostApiSetup { } else { setLanguageChannel.setMessageHandler(nil) } + let beginPreparationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + beginPreparationChannel.setMessageHandler { _, reply in + api.beginPreparation { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + beginPreparationChannel.setMessageHandler(nil) + } + let finishPreparationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.onexray.AppleWidgetHostApi.finishPreparation\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + finishPreparationChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let tokenArg = args[0] as! String + api.finishPreparation(token: tokenArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + finishPreparationChannel.setMessageHandler(nil) + } + let startPreparedVpnChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.onexray.AppleWidgetHostApi.startPreparedVpn\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startPreparedVpnChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let tokenArg = args[0] as! String + let requestJsonArg = args[1] as! String + api.startPreparedVpn(token: tokenArg, requestJson: requestJsonArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + startPreparedVpnChannel.setMessageHandler(nil) + } + let connectedAtMillisecondsChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.onexray.AppleWidgetHostApi.connectedAtMilliseconds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + connectedAtMillisecondsChannel.setMessageHandler { _, reply in + api.connectedAtMilliseconds { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + connectedAtMillisecondsChannel.setMessageHandler(nil) + } + let setStartBlockedChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.onexray.AppleWidgetHostApi.setStartBlocked\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setStartBlockedChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let blockedArg = args[0] as! Bool + api.setStartBlocked(blocked: blockedArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setStartBlockedChannel.setMessageHandler(nil) + } } } /// Generated protocol from Pigeon that represents a handler of messages from Flutter. diff --git a/swift/iOS/AppleVpnStartAdmission.swift b/swift/iOS/AppleVpnStartAdmission.swift index b3d5a8601..2527a058d 100644 --- a/swift/iOS/AppleVpnStartAdmission.swift +++ b/swift/iOS/AppleVpnStartAdmission.swift @@ -1,11 +1,73 @@ import Foundation +import Darwin /// Stop retires old starts and drains profile writes, not native status waits. @MainActor final class AppleVpnStartAdmission { + enum Failure: LocalizedError { + case startBlocked + + var errorDescription: String? { + "VPN start is temporarily unavailable while app data is being updated." + } + } + private(set) var generation: UInt64 = 0 private var preparing: Set = [] private var drainWaiters: [CheckedContinuation] = [] + private var dartPreparations: [String: UInt64] = [:] + private let startBlockedURL: URL? + + init(startBlockedURL: URL? = nil) { + self.startBlockedURL = startBlockedURL + } + + func requireStartAllowed() throws { + if try markerType() != nil { throw Failure.startBlocked } + } + + func setStartBlocked(_ blocked: Bool) throws { + guard let startBlockedURL else { throw CocoaError(.fileNoSuchFile) } + if let type = try markerType(), type != S_IFREG { + throw CocoaError(.fileWriteUnknown, userInfo: [NSFilePathErrorKey: startBlockedURL.path]) + } + if blocked { + try Data([1]).write(to: startBlockedURL, options: .atomic) + } else if try markerType() != nil { + try FileManager.default.removeItem(at: startBlockedURL) + } + } + + private func markerType() throws -> mode_t? { + guard let startBlockedURL else { return nil } + var info = stat() + if lstat(startBlockedURL.path, &info) == 0 { return info.st_mode & S_IFMT } + if errno == ENOENT { return nil } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSFilePathErrorKey: startBlockedURL.path]) + } + + func beginDartPreparation() throws -> String { + try requireStartAllowed() + let token = UUID().uuidString + dartPreparations[token] = generation + return token + } + + func finishDartPreparation(token: String) { + dartPreparations.removeValue(forKey: token) + } + + func isCurrent(_ token: String) -> Bool { + dartPreparations[token] == generation + } + + var hasCurrentDartPreparation: Bool { + dartPreparations.values.contains(generation) + } + + func consumeDartPreparation(token: String) -> Bool { + dartPreparations.removeValue(forKey: token) == generation + } func begin() -> UInt64 { generation &+= 1 @@ -27,7 +89,13 @@ final class AppleVpnStartAdmission { for waiter in waiters { waiter.resume() } } - func invalidate() { generation &+= 1 } + @discardableResult + func invalidate() -> [String] { + generation &+= 1 + let invalidated = dartPreparations.keys.sorted() + dartPreparations.removeAll() + return invalidated + } func waitForPreparation() async { guard !preparing.isEmpty else { return } diff --git a/swift/iOS/AppleWidgetActionInbox.swift b/swift/iOS/AppleWidgetActionInbox.swift index ac252efc9..cd6d8335c 100644 --- a/swift/iOS/AppleWidgetActionInbox.swift +++ b/swift/iOS/AppleWidgetActionInbox.swift @@ -1,25 +1,49 @@ import Foundation -enum AppleWidgetAction: String, Sendable { - case startVpn - case stopVpn -} - -/// Foreground App Intents run in the containing app. This inbox deliberately has -/// no shared-container persistence or public URL entry point. +/// Native intents complete VPN actions themselves. This process-local inbox +/// only suppresses startup, cancels superseded Dart preparations, or navigates. @MainActor final class AppleWidgetActionInbox { static let shared = AppleWidgetActionInbox() static let actionAvailable = Notification.Name("OneXray.AppleWidgetActionAvailable") - private var pending: AppleWidgetAction? + private var suppressAutoConnect = false + private var openConnect = false + private var invalidatedPreparations = Set() + + func recordBackgroundAction() { + suppressAutoConnect = true + notify() + } + + func recordNativeStop(invalidatedPreparations tokens: [String]) { + suppressAutoConnect = true + invalidatedPreparations.formUnion(tokens) + notify() + } + + func requestOpenConnect() { + suppressAutoConnect = true + openConnect = true + notify() + } - func submit(_ action: AppleWidgetAction) { - pending = action + private func notify() { NotificationCenter.default.post(name: Self.actionAvailable, object: nil) } func takeAction() -> String? { - defer { pending = nil } - return pending?.rawValue + guard suppressAutoConnect || openConnect || !invalidatedPreparations.isEmpty else { return nil } + let envelope: [String: Any] = [ + "version": 1, + "suppressAutoConnect": suppressAutoConnect, + "openConnect": openConnect, + "invalidatedPreparations": invalidatedPreparations.sorted(), + ] + guard let data = try? JSONSerialization.data(withJSONObject: envelope), + let text = String(data: data, encoding: .utf8) else { return nil } + suppressAutoConnect = false + openConnect = false + invalidatedPreparations.removeAll() + return text } } diff --git a/swift/iOS/AppleWidgetIntents.swift b/swift/iOS/AppleWidgetIntents.swift index 68384f21e..3ef4ba53d 100644 --- a/swift/iOS/AppleWidgetIntents.swift +++ b/swift/iOS/AppleWidgetIntents.swift @@ -2,32 +2,135 @@ import AppIntents @available(iOS 17.0, *) struct StartVpnWidgetIntent: AppIntent { - static var title: LocalizedStringResource { LocalizedStringResource("Start VPN", table: "Widget") } - static var openAppWhenRun: Bool { true } - static var isDiscoverable: Bool { false } + static let title = LocalizedStringResource("Start VPN", table: "Widget") + static let openAppWhenRun = false + static let isDiscoverable = false @available(iOS 26.0, *) - static var supportedModes: IntentModes { .foreground } + static let supportedModes: IntentModes = [.background, .foreground(.dynamic)] + + @available(iOS 27.0, *) + static let allowedExecutionTargets: IntentExecutionTargets = .main @MainActor func perform() async throws -> some IntentResult { - AppleWidgetActionInbox.shared.submit(.startVpn) + #if ONEXRAY_WIDGET_EXTENSION + try requireAppProcess() + #else + try await performVpnWidgetCommand(.start, intent: self) + #endif return .result() } } @available(iOS 17.0, *) struct StopVpnWidgetIntent: AppIntent { - static var title: LocalizedStringResource { LocalizedStringResource("Stop VPN", table: "Widget") } - static var openAppWhenRun: Bool { true } - static var isDiscoverable: Bool { false } + static let title = LocalizedStringResource("Stop VPN", table: "Widget") + static let openAppWhenRun = false + static let isDiscoverable = false + + @available(iOS 26.0, *) + static let supportedModes: IntentModes = [.background, .foreground(.dynamic)] + + @available(iOS 27.0, *) + static let allowedExecutionTargets: IntentExecutionTargets = .main + + @MainActor + func perform() async throws -> some IntentResult { + #if ONEXRAY_WIDGET_EXTENSION + try requireAppProcess() + #else + try await performVpnWidgetCommand(.stop, intent: self) + #endif + return .result() + } +} + +@available(iOS 17.0, *) +struct ToggleVpnWidgetIntent: AppIntent { + static let title = LocalizedStringResource("Start or stop VPN", table: "Widget") + static let openAppWhenRun = false + static let isDiscoverable = false @available(iOS 26.0, *) - static var supportedModes: IntentModes { .foreground } + static let supportedModes: IntentModes = [.background, .foreground(.dynamic)] + + @available(iOS 27.0, *) + static let allowedExecutionTargets: IntentExecutionTargets = .main @MainActor func perform() async throws -> some IntentResult { - AppleWidgetActionInbox.shared.submit(.stopVpn) + #if ONEXRAY_WIDGET_EXTENSION + try requireAppProcess() + #else + try await performVpnWidgetCommand(.toggle, intent: self) + #endif return .result() } } + +private enum AppleWidgetIntentError: LocalizedError { + case requiresAppProcess + case foregroundUnavailable + + var errorDescription: String? { + switch self { + case .requiresAppProcess: + AppleWidgetLocalization.text("This VPN action must run in OneXray.") + case .foregroundUnavailable: + AppleWidgetLocalization.text("OneXray cannot open from this system control right now.") + } + } +} + +#if ONEXRAY_WIDGET_EXTENSION +private func requireAppProcess() throws { + // A misrouted intent must fail visibly rather than report false success. + throw AppleWidgetIntentError.requiresAppProcess +} +#else +// Apple documents this conformance as the app-process background route for +// interactive widgets. Keep it on the app target only: the protocol is +// unavailable to app extensions. supportedModes replaces it on iOS 26+. +@available(iOS 17.0, *) +@available(*, deprecated) +extension StartVpnWidgetIntent: ForegroundContinuableIntent {} + +@available(iOS 17.0, *) +@available(*, deprecated) +extension StopVpnWidgetIntent: ForegroundContinuableIntent {} + +@available(iOS 17.0, *) +@available(*, deprecated) +extension ToggleVpnWidgetIntent: ForegroundContinuableIntent {} + +@available(iOS 17.0, *) +private func performVpnWidgetCommand( + _ command: AppleWidgetCommand, intent: Intent +) async throws { + switch try await AppleWidgetVpnActions.shared.perform(command) { + case .completed, .inProgress: + return + case .needsForeground: + let language = AppleWidgetStore.shared?.languageCode() ?? "en" + let dialog = IntentDialog(LocalizedStringResource( + "Open OneXray to finish setup.", table: "Widget", locale: Locale(identifier: language) + )) + if #available(iOS 26.0, *) { + if intent.systemContext.currentMode != .foreground { + guard intent.systemContext.currentMode.canContinueInForeground else { + throw AppleWidgetIntentError.foregroundUnavailable + } + try await intent.continueInForeground(dialog, alwaysConfirm: false) + } + await AppleWidgetActionInbox.shared.requestOpenConnect() + } else { + try await intent.requestToContinueInForeground(dialog) { + AppleWidgetActionInbox.shared.requestOpenConnect() + } + } + // The facade suppresses startup auto-connect. Foreground continuation + // only navigates to setup; it never submits another connection request. + } +} +#endif diff --git a/swift/iOS/AppleWidgetState.swift b/swift/iOS/AppleWidgetState.swift index 68dad2589..fe3bf4191 100644 --- a/swift/iOS/AppleWidgetState.swift +++ b/swift/iOS/AppleWidgetState.swift @@ -1,5 +1,28 @@ import Foundation +enum AppleWidgetCommand: Equatable, Sendable { + case start + case stop + case toggle +} + +enum AppleWidgetCommandOutcome: Equatable, Sendable { + case completed + case inProgress + case needsForeground +} + +/// Interactive Widget actions are available from iOS 17. Execution uses the +/// background APIs provided by each OS; iOS 16 retains navigation-only links. +enum AppleWidgetExecutionPolicy { + static var usesBackgroundActions: Bool { + #if os(iOS) + if #available(iOS 17.0, *) { return true } + #endif + return false + } +} + enum AppleWidgetStatus: String, Codable, Sendable { case unknown case disconnected @@ -43,10 +66,20 @@ enum AppleWidgetLanguage { } } +enum AppleWidgetLocalization { + static func text(_ key: String, languageCode: String? = nil) -> String { + let language = languageCode ?? AppleWidgetStore.shared?.languageCode() ?? "en" + let path = Bundle.main.path(forResource: AppleWidgetLanguage.normalized(language), ofType: "lproj") + let bundle = path.flatMap(Bundle.init(path:)) ?? Bundle.main + return bundle.localizedString(forKey: key, value: key, table: "Widget") + } +} + /// Separate files let the app change language without overwriting a tunnel event. struct AppleWidgetStore: Sendable { static let appGroup = "group.net.yuandev.onexray" static let kind = "net.yuandev.onexray.connection" + static let controlKind = "net.yuandev.onexray.vpn" let directory: URL static var shared: AppleWidgetStore? { diff --git a/swift/iOS/AppleWidgetVpnActions.swift b/swift/iOS/AppleWidgetVpnActions.swift new file mode 100644 index 000000000..3540feae7 --- /dev/null +++ b/swift/iOS/AppleWidgetVpnActions.swift @@ -0,0 +1,58 @@ +import Foundation + +struct AppleWidgetVpnActionError: LocalizedError { + let message: String + var errorDescription: String? { AppleWidgetLocalization.text(message) } +} + +/// Explicit native commands share one status decision; no Widget snapshot selects an action. +@MainActor +final class AppleWidgetVpnActions { + #if os(iOS) + static let shared = AppleWidgetVpnActions( + readStatus: { try await VPNManager.shared.readWidgetVpnStatus() }, + start: { try await VPNManager.shared.startSavedVpnForWidget() }, + stop: { try await VPNManager.shared.stopVpnForWidget() }, + commandGeneration: { VPNManager.shared.widgetCommandGeneration } + ) + #endif + + private let readStatus: @MainActor () async throws -> AppleWidgetStatus + private let start: @MainActor () async throws -> AppleWidgetCommandOutcome + private let stop: @MainActor () async throws -> AppleWidgetCommandOutcome + private let commandGeneration: @MainActor () -> UInt64 + + init( + readStatus: @escaping @MainActor () async throws -> AppleWidgetStatus, + start: @escaping @MainActor () async throws -> AppleWidgetCommandOutcome, + stop: @escaping @MainActor () async throws -> AppleWidgetCommandOutcome, + commandGeneration: @escaping @MainActor () -> UInt64 = { 0 } + ) { + self.readStatus = readStatus + self.start = start + self.stop = stop + self.commandGeneration = commandGeneration + } + + func perform(_ command: AppleWidgetCommand) async throws -> AppleWidgetCommandOutcome { + AppleWidgetActionInbox.shared.recordBackgroundAction() + if command == .stop { return try await stop() } + let generation = commandGeneration() + let status: AppleWidgetStatus + do { + status = try await readStatus() + } catch { + guard commandGeneration() == generation else { return .inProgress } + throw error + } + guard commandGeneration() == generation else { return .inProgress } + switch (command, status) { + case (_, .unknown): throw AppleWidgetVpnActionError(message: "Unable to confirm the current VPN status.") + case (.stop, _): return try await stop() + case (.start, .connected): return .completed + case (.start, .connecting), (.start, .disconnecting), (.toggle, .disconnecting): return .inProgress + case (.toggle, .connected), (.toggle, .connecting): return try await stop() + case (.start, .disconnected), (.toggle, .disconnected): return try await start() + } + } +} diff --git a/test/native/AppleWidgetTests.swift b/test/native/AppleWidgetTests.swift index fed32596f..5f11222e5 100644 --- a/test/native/AppleWidgetTests.swift +++ b/test/native/AppleWidgetTests.swift @@ -52,20 +52,232 @@ final class AppleWidgetTests: XCTestCase { } } - func testActionInboxPreservesEarlyIntentAndConsumesItOnce() { - MainActor.assumeIsolated { + func testActionInboxMergesNativeEnvelopeAndConsumesItOnce() throws { + try MainActor.assumeIsolated { let inbox = AppleWidgetActionInbox() XCTAssertNil(inbox.takeAction()) - inbox.submit(.startVpn) - XCTAssertEqual(inbox.takeAction(), "startVpn") + inbox.recordBackgroundAction() + inbox.recordNativeStop(invalidatedPreparations: ["second", "first"]) + inbox.recordNativeStop(invalidatedPreparations: ["first"]) + inbox.requestOpenConnect() + let text = try XCTUnwrap(inbox.takeAction()) + let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + XCTAssertEqual(envelope["version"] as? Int, 1) + XCTAssertEqual(envelope["suppressAutoConnect"] as? Bool, true) + XCTAssertEqual(envelope["openConnect"] as? Bool, true) + XCTAssertEqual(envelope["invalidatedPreparations"] as? [String], ["first", "second"]) XCTAssertNil(inbox.takeAction()) - inbox.submit(.startVpn) - inbox.submit(.stopVpn) - XCTAssertEqual(inbox.takeAction(), "stopVpn") + inbox.recordBackgroundAction() + let next = try XCTUnwrap(inbox.takeAction()) + let reset = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(next.utf8)) as? [String: Any]) + XCTAssertEqual(reset["openConnect"] as? Bool, false) + XCTAssertEqual(reset["invalidatedPreparations"] as? [String], []) XCTAssertNil(inbox.takeAction()) } } + @MainActor + func testDartPreparationIsConsumedOnceAndStopRetiresItWithoutDrainingDartWork() async throws { + let admission = AppleVpnStartAdmission() + let first = try admission.beginDartPreparation() + let second = try admission.beginDartPreparation() + XCTAssertTrue(admission.hasCurrentDartPreparation) + XCTAssertTrue(admission.consumeDartPreparation(token: first)) + XCTAssertFalse(admission.consumeDartPreparation(token: first)) + XCTAssertEqual(admission.invalidate(), [second]) + await admission.waitForPreparation() + XCTAssertFalse(admission.isCurrent(second)) + XCTAssertFalse(admission.consumeDartPreparation(token: second)) + XCTAssertFalse(admission.hasCurrentDartPreparation) + admission.finishDartPreparation(token: second) + let retry = try admission.beginDartPreparation() + XCTAssertTrue(admission.consumeDartPreparation(token: retry)) + let native = admission.begin() + try admission.check(native) + admission.finishPreparation(native) + } + + @MainActor + func testMaintenanceMarkerPersistsAcrossAdmissionInstancesOutsideRuntimeDirectory() throws { + let root = URL(fileURLWithPath: "../references/onexray-tests/native/widget-admission", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let run = root.appendingPathComponent("run", isDirectory: true) + try FileManager.default.createDirectory(at: run, withIntermediateDirectories: false) + let marker = root.appendingPathComponent("vpn-start.blocked") + let first = AppleVpnStartAdmission(startBlockedURL: marker) + try first.requireStartAllowed() + try first.setStartBlocked(true) + try FileManager.default.removeItem(at: run) + let reopened = AppleVpnStartAdmission(startBlockedURL: marker) + XCTAssertThrowsError(try reopened.requireStartAllowed()) + XCTAssertThrowsError(try reopened.beginDartPreparation()) + reopened.invalidate() + try reopened.setStartBlocked(false) + try reopened.setStartBlocked(false) + try reopened.requireStartAllowed() + XCTAssertTrue(reopened.isCurrent(try reopened.beginDartPreparation())) + } + + @MainActor + func testInvalidMaintenanceStorageCannotAdmitStartOrDeleteAnOccupiedDirectory() throws { + let root = URL(fileURLWithPath: "../references/onexray-tests/native/widget-admission", isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let occupied = root.appendingPathComponent("vpn-start.blocked", isDirectory: true) + try FileManager.default.createDirectory(at: occupied, withIntermediateDirectories: false) + let admission = AppleVpnStartAdmission(startBlockedURL: occupied) + XCTAssertThrowsError(try admission.beginDartPreparation()) + XCTAssertThrowsError(try admission.setStartBlocked(false)) + XCTAssertTrue(FileManager.default.fileExists(atPath: occupied.path)) + let regularParent = root.appendingPathComponent("not-a-directory") + try Data([1]).write(to: regularParent) + let unreadable = AppleVpnStartAdmission(startBlockedURL: regularParent.appendingPathComponent("marker")) + XCTAssertThrowsError(try unreadable.beginDartPreparation()) + XCTAssertThrowsError(try unreadable.setStartBlocked(true)) + } + + @MainActor + func testExplicitStartIsIdempotentForConnectedAndInProgressStates() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + for status in [AppleWidgetStatus.connected, .connecting, .disconnecting] { + let actions = AppleWidgetVpnActions(readStatus: { status }, start: { + XCTFail("An existing native run must not be restarted") + return .completed + }, stop: { + XCTFail("Explicit Start must not become Stop") + return .completed + }) + let result = try await actions.perform(.start) + XCTAssertEqual(result, status == .connected ? .completed : .inProgress) + } + } + + @MainActor + func testToggleStopsConnectedOrPreparingAndDoesNotQueueWhileDisconnecting() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + for status in [AppleWidgetStatus.connected, .connecting, .disconnecting] { + var stops = 0 + let actions = AppleWidgetVpnActions(readStatus: { status }, start: { + XCTFail("A native transition must not enqueue another start") + return .completed + }, stop: { + stops += 1 + return .completed + }) + let result = try await actions.perform(.toggle) + XCTAssertEqual(stops, status == .disconnecting ? 0 : 1) + XCTAssertEqual(result, status == .disconnecting ? .inProgress : .completed) + } + } + + @MainActor + func testToggleRetiresDartPreparationBeforeNativeSubmission() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + let admission = AppleVpnStartAdmission() + let token = try admission.beginDartPreparation() + let actions = AppleWidgetVpnActions(readStatus: { + admission.hasCurrentDartPreparation ? .connecting : .disconnected + }, start: { + XCTFail("Toggle must cancel the pending Dart preparation") + return .completed + }, stop: { + let invalidated = admission.invalidate() + AppleWidgetActionInbox.shared.recordNativeStop(invalidatedPreparations: invalidated) + return .completed + }) + let result = try await actions.perform(.toggle) + XCTAssertEqual(result, .completed) + XCTAssertFalse(admission.consumeDartPreparation(token: token)) + let text = try XCTUnwrap(AppleWidgetActionInbox.shared.takeAction()) + let envelope = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + XCTAssertEqual(envelope["invalidatedPreparations"] as? [String], [token]) + } + + @MainActor + func testUnknownOrUnreadableNativeStatusNeverStarts() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + for command in [AppleWidgetCommand.start, .toggle] { + let actions = AppleWidgetVpnActions(readStatus: { .unknown }, start: { + XCTFail("Unknown status must not start Core") + return .completed + }, stop: { .completed }) + do { + _ = try await actions.perform(command) + XCTFail("Unknown native status must fail") + } catch { + XCTAssertTrue(error is AppleWidgetVpnActionError) + } + } + let denied = NSError(domain: "NE-test", code: 403) + let actions = AppleWidgetVpnActions(readStatus: { throw denied }, start: { + XCTFail("A failed native query must not start Core") + return .completed + }, stop: { .completed }) + do { + _ = try await actions.perform(.start) + XCTFail("The native error must propagate") + } catch { + XCTAssertEqual((error as NSError).domain, "NE-test") + XCTAssertEqual((error as NSError).code, 403) + } + } + + @MainActor + func testStopRetiresStartOrToggleWaitingForNativeStatusIncludingLateErrors() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + for command in [AppleWidgetCommand.start, .toggle] { + for lateError in [false, true] { + let admission = AppleVpnStartAdmission() + let (queries, enteredQuery) = AsyncStream.makeStream() + var reply: CheckedContinuation? + let actions = AppleWidgetVpnActions(readStatus: { + try await withCheckedThrowingContinuation { continuation in + reply = continuation + enteredQuery.yield(()) + } + }, start: { + XCTFail("A status query issued before Stop must not admit a new start") + return .completed + }, stop: { + XCTFail("An old Toggle result must not stop a later run") + return .completed + }, commandGeneration: { admission.generation }) + let pending = Task { @MainActor in try await actions.perform(command) } + var iterator = queries.makeAsyncIterator() + _ = await iterator.next() + admission.invalidate() + if lateError { + reply?.resume(throwing: NSError(domain: "old-status-query", code: 1)) + } else { + reply?.resume(returning: .disconnected) + } + let result = try await pending.value + XCTAssertEqual(result, .inProgress) + enteredQuery.finish() + } + } + } + + @MainActor + func testStopDoesNotReadStartAvailabilityAndMissingStartRequestsForeground() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + let stopOnly = AppleWidgetVpnActions(readStatus: { + XCTFail("Stop must not require a runtime input or a start query") + throw CocoaError(.fileNoSuchFile) + }, start: { + XCTFail("Stop must not prepare input") + return .needsForeground + }, stop: { .completed }) + let stopped = try await stopOnly.perform(.stop) + XCTAssertEqual(stopped, .completed) + let missing = AppleWidgetVpnActions(readStatus: { .disconnected }, start: { .needsForeground }, stop: { .completed }) + let result = try await missing.perform(.start) + XCTAssertEqual(result, .needsForeground) + } + @MainActor func testStopDrainsPreparationAndRejectsEveryOldStart() async throws { let admission = AppleVpnStartAdmission() diff --git a/test/service/connect/coordinator_test.dart b/test/service/connect/coordinator_test.dart index aa4c083e6..a023466ca 100644 --- a/test/service/connect/coordinator_test.dart +++ b/test/service/connect/coordinator_test.dart @@ -1219,6 +1219,191 @@ void main() { }, ); + test( + 'native Stop retires paused preparation without stopping a later start', + () async { + final runtime = _runtime('prepared'); + var host = const HostConnection(VpnStatus.disconnected); + final entered = Completer(); + final release = Completer(); + final finished = []; + var registered = 0; + var preparations = 0; + var starts = 0; + var stops = 0; + late ConnectionCoordinator coordinator; + coordinator = await _initialize( + ConnectionCoordinator( + database: db, + readRuntime: () async => host.runtime, + inspect: (_, {observedStatus}) async => host, + beginPreparation: () async => 'token-${++registered}', + finishPreparation: (token) async => finished.add(token), + prepare: (_, _) async { + preparations++; + expect( + registered, + preparations, + reason: 'Register before preparing', + ); + if (preparations == 1) { + entered.complete(); + await release.future; + } else { + coordinator.invalidatePreparations(['token-1']); + } + return runtime; + }, + start: (runtime) async { + starts++; + return host = HostConnection(VpnStatus.connected, runtime: runtime); + }, + stop: () async { + stops++; + return host = const HostConnection(VpnStatus.disconnected); + }, + ), + ); + final original = (await coordinator.configuration).encode(); + final rejected = expectLater( + coordinator.connect(), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'cancelled', + ), + ), + ); + await entered.future; + coordinator.invalidatePreparations(['token-1']); + release.complete(); + await rejected; + expect(starts, 0); + expect(stops, 0, reason: 'The native Stop was already processed'); + expect((await coordinator.configuration).encode(), original); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + await coordinator.connect(); + expect(starts, 1); + expect(stops, 0); + expect(coordinator.state.value.phase, ConnectionPhase.connected); + expect(finished, ['token-1', 'token-2']); + }, + ); + + test('native invalidation arriving before token registration returns is retained', () async { + final registration = Completer(); + final entered = Completer(); + final finished = []; + var preparations = 0; + var starts = 0; + var stops = 0; + final coordinator = await _initialize( + ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + beginPreparation: () { + entered.complete(); + return registration.future; + }, + finishPreparation: (token) async => finished.add(token), + prepare: (_, _) async { + preparations++; + return _runtime('must-not-prepare'); + }, + start: (_) async { + starts++; + return const HostConnection(VpnStatus.connected); + }, + stop: () async { + stops++; + return const HostConnection(VpnStatus.disconnected); + }, + ), + ); + final rejected = expectLater( + coordinator.connect(), + throwsA( + isA().having( + (e) => e.reason, + 'reason', + 'cancelled', + ), + ), + ); + await entered.future; + coordinator.invalidatePreparations(['unrelated', 'pending-registration']); + registration.complete('pending-registration'); + await rejected; + expect(preparations, 0); + expect(starts, 0); + expect(stops, 0); + expect(finished, ['pending-registration']); + }); + + test('native cancellation before its notification preserves a newer native connection', () async { + final prepared = _runtime('old-app-start'); + final newer = _runtime('new-native-start'); + var host = const HostConnection(VpnStatus.disconnected); + var stops = 0; + final coordinator = await _initialize( + ConnectionCoordinator( + database: db, + readRuntime: () async => host.runtime, + inspect: (_, {observedStatus}) async => host, + beginPreparation: () async => 'old-token', + finishPreparation: (_) async {}, + prepare: (_, _) async => prepared, + start: (_) async { + host = HostConnection(VpnStatus.connected, runtime: newer); + throw const NativePreparationCancelled(); + }, + stop: () async { + stops++; + return host = const HostConnection(VpnStatus.disconnected); + }, + ), + ); + final original = (await coordinator.configuration).encode(); + await expectLater( + coordinator.connect(), + throwsA(isA()), + ); + coordinator.invalidatePreparations(['old-token']); + expect(stops, 0); + expect(coordinator.state.value.phase, ConnectionPhase.connected); + expect(coordinator.state.value.runtime, newer); + expect((await coordinator.configuration).encode(), original); + }); + + test( + 'blocked native registration fails before any Dart preparation', + () async { + var preparations = 0; + var finishes = 0; + final coordinator = await _initialize( + ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + beginPreparation: () async => throw StateError('maintenance blocked'), + finishPreparation: (_) async => finishes++, + prepare: (_, _) async { + preparations++; + return _runtime('must-not-prepare'); + }, + start: (_) async => fail('Must not submit a blocked start'), + ), + ); + await expectLater(coordinator.connect(), throwsStateError); + expect(preparations, 0); + expect(finishes, 0); + }, + ); + for (final queuedApply in [false, true]) { test( 'Stop cancels ${queuedApply ? 'queued apply' : 'connect'} before preparation', diff --git a/test/service/connect/runtime_host_test.dart b/test/service/connect/runtime_host_test.dart index dc8b2b760..083b761f5 100644 --- a/test/service/connect/runtime_host_test.dart +++ b/test/service/connect/runtime_host_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/core/pigeon/model.dart'; @@ -24,6 +25,74 @@ void main() { addTearDown(() => directory.delete(recursive: true)); }); + test('iOS submits its token and request without a Dart file write', () async { + final runtime = _runtime(platform: ConnectionPlatform.ios); + final host = ConnectionRuntimeHost( + writeStartFile: (_) async => + fail('iOS writes belong to native admission'), + startPreparedVpn: (token, text) async { + expect(token, 'prepared-1'); + expect(jsonDecode(text), runtime.request.toJson()); + return NativeVpnCommandResult( + state: NativeVpnCommandState.success, + status: VpnStatus.connected, + ); + }, + ); + expect( + (await host.start(runtime, preparationToken: 'prepared-1')).runtime, + same(runtime), + ); + }); + + test( + 'a native Stop during prepared submission never writes from Dart', + () async { + final file = File(p.join(directory.path, 'start.json')); + await file.writeAsString('previous input'); + final entered = Completer(); + final release = Completer(); + final host = ConnectionRuntimeHost( + writeStartFile: (_) async => fail('The native commit owns this write'), + startPreparedVpn: (_, _) async { + entered.complete(); + await release.future; + throw PlatformException(code: 'apple-preparation-cancelled'); + }, + ); + final rejected = expectLater( + host.start( + _runtime(platform: ConnectionPlatform.ios), + preparationToken: 'retired', + ), + throwsA(isA()), + ); + await entered.future; + expect(await file.readAsString(), 'previous input'); + release.complete(); + await rejected; + expect(await file.readAsString(), 'previous input'); + }, + ); + + test('iOS inspection uses system connection time only in memory', () async { + final runtime = _runtime(platform: ConnectionPlatform.ios); + final saved = runtime.request.toJson(); + final connectedAt = runtime.startedAt.add(const Duration(minutes: 5)); + final host = ConnectionRuntimeHost( + readStatus: () async => VpnStatus.connected, + connectedAtMilliseconds: () async => connectedAt.millisecondsSinceEpoch, + ); + final observed = (await host.inspect(runtime)).runtime!; + expect( + observed.startedAt.millisecondsSinceEpoch, + connectedAt.millisecondsSinceEpoch, + ); + expect(observed.request, same(runtime.request)); + expect(runtime.request.toJson(), saved); + expect(observed.configuration, same(runtime.configuration)); + }); + test('cancelled file preparation never submits a native start', () async { final runtime = _runtime(platform: ConnectionPlatform.ios); final writing = Completer(); diff --git a/test/service/shared/apple_widget/service_test.dart b/test/service/shared/apple_widget/service_test.dart index 54ad5795f..0223eacd9 100644 --- a/test/service/shared/apple_widget/service_test.dart +++ b/test/service/shared/apple_widget/service_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -8,6 +9,70 @@ import 'package:onexray/service/shared/menu/short_cut/service.dart'; void main() { final binding = TestWidgetsFlutterBinding.ensureInitialized(); + test('cold native effects suppress auto-connect before cancellation and navigation', () async { + final events = StreamController.broadcast(sync: true); + addTearDown(events.close); + final effects = []; + final pending = [ + jsonEncode({ + 'version': 1, + 'suppressAutoConnect': true, + 'openConnect': true, + 'invalidatedPreparations': ['old-token'], + }), + ]; + final service = AppleWidgetService.forTesting( + events: events.stream, + takeAction: () async => pending.isEmpty ? null : pending.removeAt(0), + setLanguage: (_) async {}, + receive: (action) async => effects.add(action), + suppressAutoConnect: () => effects.add('suppress'), + invalidatePreparations: (tokens) => + effects.add('cancel:${tokens.single}'), + ); + addTearDown(service.dispose); + await service.initialize(); + expect(effects, ['suppress', 'cancel:old-token', 'openConnect']); + events.add(null); + await pumpEventQueue(); + expect(effects, ['suppress', 'cancel:old-token', 'openConnect']); + }); + + test( + 'a handled native Stop cancels tokens without sending another stop action', + () async { + final events = StreamController.broadcast(sync: true); + addTearDown(events.close); + final actions = []; + final invalidated = []; + final pending = []; + var suppressed = 0; + final service = AppleWidgetService.forTesting( + events: events.stream, + takeAction: () async => pending.isEmpty ? null : pending.removeAt(0), + setLanguage: (_) async {}, + receive: (action) async => actions.add(action), + suppressAutoConnect: () => suppressed++, + invalidatePreparations: invalidated.addAll, + ); + addTearDown(service.dispose); + await service.initialize(); + pending.add( + jsonEncode({ + 'version': 1, + 'suppressAutoConnect': true, + 'openConnect': false, + 'invalidatedPreparations': ['first'], + }), + ); + events.add(null); + await pumpEventQueue(); + expect(invalidated, ['first']); + expect(suppressed, 1); + expect(actions, isEmpty); + }, + ); + test( 'cold intent is consumed once and suppresses launch auto-connect', () async { diff --git a/test/service/shared/apple_widget/start_blocked_test.dart b/test/service/shared/apple_widget/start_blocked_test.dart new file mode 100644 index 000000000..fd7adbbfe --- /dev/null +++ b/test/service/shared/apple_widget/start_blocked_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/service/advanced/tunnel/android/automation/service.dart'; + +void main() { + test( + 'maintenance blocks and resumes the iOS native saved-start gate', + () async { + final apple = _AppleApi(); + final android = _AndroidApi(); + final service = AndroidAutomationService( + api: android, + supported: false, + appleApi: apple, + supportedIOS: true, + ); + await service.setStartBlocked(true); + await service.setStartBlocked(false); + await service.clear(); + expect(apple.blocked, [true, false]); + expect(android.blocked, isEmpty); + expect(android.clears, 0); + }, + ); + + test( + 'Android maintenance keeps its existing bridge and authorization', + () async { + final apple = _AppleApi(); + final android = _AndroidApi(); + final service = AndroidAutomationService( + api: android, + supported: true, + appleApi: apple, + supportedIOS: false, + ); + await service.setStartBlocked(true); + await service.setStartBlocked(false); + await service.clear(); + expect(android.blocked, [true, false]); + expect(android.clears, 1); + expect(apple.blocked, isEmpty); + }, + ); +} + +class _AppleApi extends AppleWidgetHostApi { + final blocked = []; + + @override + Future setStartBlocked(bool value) async => blocked.add(value); +} + +class _AndroidApi extends AndroidAutomationHostApi { + final blocked = []; + var clears = 0; + + @override + Future setStartBlocked(bool value) async => blocked.add(value); + + @override + Future clear() async => clears++; +} diff --git a/tool/test_apple_widget.sh b/tool/test_apple_widget.sh index 2761e03ea..3c5a3f8c3 100644 --- a/tool/test_apple_widget.sh +++ b/tool/test_apple_widget.sh @@ -13,6 +13,7 @@ xcrun swiftc \ -Xlinker -rpath -Xlinker "$platform/Developer/usr/lib" \ swift/iOS/AppleWidgetState.swift swift/iOS/AppleWidgetActionInbox.swift \ swift/iOS/AppleVpnStartAdmission.swift \ + swift/iOS/AppleWidgetVpnActions.swift \ test/native/AppleWidgetTests.swift \ -o "$evidence/apple-widget-tests" "$evidence/apple-widget-tests" From 1ce3da1536fa5dbebbfd2204eae538ef2f08bd5f Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 21:41:05 +0800 Subject: [PATCH 49/60] feat(ios): replace VPN controls with one state-aware button --- ios/widget/en.lproj/Widget.strings | 13 +++ ios/widget/fa.lproj/Widget.strings | 13 +++ ios/widget/ru.lproj/Widget.strings | 13 +++ ios/widget/widget.swift | 103 ++++++++++++++++-------- ios/widget/widgetBundle.swift | 3 +- ios/widget/zh-Hans.lproj/Widget.strings | 13 +++ ios/widget/zh-Hant.lproj/Widget.strings | 13 +++ swift/iOS/AppleWidgetPublisher.swift | 3 + 8 files changed, 140 insertions(+), 34 deletions(-) diff --git a/ios/widget/en.lproj/Widget.strings b/ios/widget/en.lproj/Widget.strings index af148ac45..311d81040 100644 --- a/ios/widget/en.lproj/Widget.strings +++ b/ios/widget/en.lproj/Widget.strings @@ -13,3 +13,16 @@ "Unknown" = "Unknown"; "Start" = "Start"; "Stop" = "Stop"; +"VPN" = "VPN"; +"VPN status and background actions using the last prepared configuration." = "VPN status and background actions using the last prepared configuration."; +"Start or stop VPN in the background using the last prepared configuration." = "Start or stop VPN in the background using the last prepared configuration."; +"Start or stop VPN" = "Start or stop VPN"; +"Status pending" = "Status pending"; +"Open OneXray to finish setup." = "Open OneXray to finish setup."; +"This VPN action must run in OneXray." = "This VPN action must run in OneXray."; +"OneXray cannot open from this system control right now." = "OneXray cannot open from this system control right now."; +"VPN action failed." = "VPN action failed."; +"The VPN start was cancelled." = "The VPN start was cancelled."; +"VPN start is temporarily unavailable while app data is being updated." = "VPN start is temporarily unavailable while app data is being updated."; +"The VPN is still stopping." = "The VPN is still stopping."; +"Unable to confirm the current VPN status." = "Unable to confirm the current VPN status."; diff --git a/ios/widget/fa.lproj/Widget.strings b/ios/widget/fa.lproj/Widget.strings index 98431113c..88efc67d2 100644 --- a/ios/widget/fa.lproj/Widget.strings +++ b/ios/widget/fa.lproj/Widget.strings @@ -13,3 +13,16 @@ "Unknown" = "نامشخص"; "Start" = "شروع"; "Stop" = "توقف"; +"VPN" = "VPN"; +"VPN status and background actions using the last prepared configuration." = "وضعیت VPN و اقدامات پس‌زمینه با آخرین پیکربندی آماده‌شده."; +"Start or stop VPN in the background using the last prepared configuration." = "شروع یا توقف VPN در پس‌زمینه با آخرین پیکربندی آماده‌شده."; +"Start or stop VPN" = "شروع یا توقف VPN"; +"Status pending" = "در انتظار تأیید وضعیت"; +"Open OneXray to finish setup." = "برای تکمیل تنظیمات، OneXray را باز کنید."; +"This VPN action must run in OneXray." = "این اقدام VPN باید در OneXray اجرا شود."; +"OneXray cannot open from this system control right now." = "فعلاً نمی‌توان OneXray را از این کنترل سیستمی باز کرد."; +"VPN action failed." = "اقدام VPN انجام نشد."; +"The VPN start was cancelled." = "راه‌اندازی VPN لغو شد."; +"VPN start is temporarily unavailable while app data is being updated." = "تا پایان به‌روزرسانی داده‌های برنامه، راه‌اندازی VPN موقتاً در دسترس نیست."; +"The VPN is still stopping." = "VPN هنوز در حال توقف است."; +"Unable to confirm the current VPN status." = "امکان تأیید وضعیت فعلی VPN وجود ندارد."; diff --git a/ios/widget/ru.lproj/Widget.strings b/ios/widget/ru.lproj/Widget.strings index e98ec0edc..89aa871cd 100644 --- a/ios/widget/ru.lproj/Widget.strings +++ b/ios/widget/ru.lproj/Widget.strings @@ -13,3 +13,16 @@ "Unknown" = "Неизвестно"; "Start" = "Старт"; "Stop" = "Стоп"; +"VPN" = "VPN"; +"VPN status and background actions using the last prepared configuration." = "Состояние VPN и фоновые действия с последней подготовленной конфигурацией."; +"Start or stop VPN in the background using the last prepared configuration." = "Фоновый запуск или остановка VPN с последней подготовленной конфигурацией."; +"Start or stop VPN" = "Запустить или остановить VPN"; +"Status pending" = "Состояние требует проверки"; +"Open OneXray to finish setup." = "Откройте OneXray для завершения настройки."; +"This VPN action must run in OneXray." = "Это действие VPN должно выполняться в OneXray."; +"OneXray cannot open from this system control right now." = "Сейчас невозможно открыть OneXray из этого системного элемента управления."; +"VPN action failed." = "Не удалось выполнить действие VPN."; +"The VPN start was cancelled." = "Запуск VPN отменён."; +"VPN start is temporarily unavailable while app data is being updated." = "Запуск VPN временно недоступен, пока обновляются данные приложения."; +"The VPN is still stopping." = "VPN всё ещё отключается."; +"Unable to confirm the current VPN status." = "Не удалось определить текущее состояние VPN."; diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index 088b1e1e8..1d9078ff3 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -54,52 +54,93 @@ struct ConnectionWidget: Widget { .widgetURL(connectionURL) } .configurationDisplayName(Text(verbatim: widgetText("Connection", language))) - .description(Text(verbatim: widgetText("VPN status and actions that open OneXray.", language))) + .description(Text(verbatim: widgetText( + AppleWidgetExecutionPolicy.usesBackgroundActions + ? "VPN status and background actions using the last prepared configuration." + : "VPN status and actions that open OneXray.", + language + ))) .supportedFamilies(connectionWidgetFamilies) } } -// Explicit commands never infer a target from the Widget's display snapshot. -// Both controls use the same foreground App delivery as the Home Screen buttons. @available(iOS 18.0, *) -struct StartVpnControl: ControlWidget { - var body: some ControlWidgetConfiguration { - let language = AppleWidgetStore.shared?.languageCode() ?? "en" - let title = LocalizedStringResource("Start VPN", table: "Widget", locale: Locale(identifier: language)) - return StaticControlConfiguration(kind: "net.yuandev.onexray.start-vpn") { - ControlWidgetButton(action: StartVpnWidgetIntent()) { - Label { - Text(title) - } icon: { - Image(systemName: ConnectionWidgetAction.start.symbol) - } - } - } - .displayName(title) - .description(LocalizedStringResource( - "Open OneXray to perform this action.", table: "Widget", locale: Locale(identifier: language) - )) +private struct VpnControlValue { + let status: AppleWidgetStatus + let languageCode: String +} + +@available(iOS 18.0, *) +private struct VpnControlValueProvider: ControlValueProvider { + var previewValue: VpnControlValue { + VpnControlValue(status: .unknown, languageCode: AppleWidgetStore.shared?.languageCode() ?? "en") + } + + func currentValue() async throws -> VpnControlValue { + let store = AppleWidgetStore.shared + // Providers stay in the Widget extension. Re-evaluate expiry whenever + // the system requests a value; this is not a live NetworkExtension read. + return VpnControlValue( + status: store?.snapshot()?.displayedStatus(at: Date()) ?? .unknown, + languageCode: store?.languageCode() ?? "en" + ) } } @available(iOS 18.0, *) -struct StopVpnControl: ControlWidget { +struct VpnControl: ControlWidget { var body: some ControlWidgetConfiguration { let language = AppleWidgetStore.shared?.languageCode() ?? "en" - let title = LocalizedStringResource("Stop VPN", table: "Widget", locale: Locale(identifier: language)) - return StaticControlConfiguration(kind: "net.yuandev.onexray.stop-vpn") { - ControlWidgetButton(action: StopVpnWidgetIntent()) { + return StaticControlConfiguration(kind: AppleWidgetStore.controlKind, provider: VpnControlValueProvider()) { value in + ControlWidgetButton(action: ToggleVpnWidgetIntent()) { Label { - Text(title) + Text("VPN") } icon: { - Image(systemName: ConnectionWidgetAction.stop.symbol) + Image(systemName: controlSymbol(value.status)) } + .environment(\.locale, Locale(identifier: value.languageCode)) + .environment(\.layoutDirection, value.languageCode == "fa" ? .rightToLeft : .leftToRight) + .controlWidgetStatus(Text(verbatim: widgetText(controlStatusKey(value.status), value.languageCode))) + .controlWidgetActionHint(Text(verbatim: widgetText("Start or stop VPN", value.languageCode))) + .accessibilityLabel(Text(verbatim: "VPN, \(widgetText(controlStatusKey(value.status), value.languageCode))")) } + // A button remains actionable for unknown and expired observations. + // The intent decides from current native state, never this snapshot. + .tint(value.status == .connected ? Color("WidgetSuccess") : nil) + } + .displayName(LocalizedStringResource("VPN", table: "Widget", locale: Locale(identifier: language))) + .description(controlDescription(language: language)) + } + + private func controlDescription(language: String) -> LocalizedStringResource { + if AppleWidgetExecutionPolicy.usesBackgroundActions { + return LocalizedStringResource( + "Start or stop VPN in the background using the last prepared configuration.", + table: "Widget", locale: Locale(identifier: language) + ) } - .displayName(title) - .description(LocalizedStringResource( + return LocalizedStringResource( "Open OneXray to perform this action.", table: "Widget", locale: Locale(identifier: language) - )) + ) + } + + private func controlStatusKey(_ status: AppleWidgetStatus) -> String { + switch status { + case .unknown: "Status pending" + case .disconnected: "Disconnected" + case .connecting: "Connecting" + case .connected: "Connected" + case .disconnecting: "Disconnecting" + } + } + + private func controlSymbol(_ status: AppleWidgetStatus) -> String { + switch status { + case .unknown: "questionmark.circle" + case .disconnected: "shield" + case .connecting, .disconnecting: "arrow.triangle.2.circlepath" + case .connected: "checkmark.shield" + } } } @@ -415,9 +456,7 @@ private struct ConnectionWidgetPalette { } private func widgetText(_ key: String, _ language: String) -> String { - let path = Bundle.main.path(forResource: AppleWidgetLanguage.normalized(language), ofType: "lproj") - let bundle = path.flatMap(Bundle.init(path:)) ?? Bundle.main - return bundle.localizedString(forKey: key, value: key, table: "Widget") + AppleWidgetLocalization.text(key, languageCode: language) } struct ConnectionWidgetPreviews: PreviewProvider { diff --git a/ios/widget/widgetBundle.swift b/ios/widget/widgetBundle.swift index 45b315665..ace4607dc 100644 --- a/ios/widget/widgetBundle.swift +++ b/ios/widget/widgetBundle.swift @@ -13,8 +13,7 @@ struct widgetBundle: WidgetBundle { var body: some Widget { ConnectionWidget() if #available(iOS 18.0, *) { - StartVpnControl() - StopVpnControl() + VpnControl() } } } diff --git a/ios/widget/zh-Hans.lproj/Widget.strings b/ios/widget/zh-Hans.lproj/Widget.strings index f9f96b3a7..886b6e8ca 100644 --- a/ios/widget/zh-Hans.lproj/Widget.strings +++ b/ios/widget/zh-Hans.lproj/Widget.strings @@ -13,3 +13,16 @@ "Unknown" = "未知"; "Start" = "启动"; "Stop" = "停止"; +"VPN" = "VPN"; +"VPN status and background actions using the last prepared configuration." = "查看 VPN 状态,使用上次已准备的配置在后台执行操作。"; +"Start or stop VPN in the background using the last prepared configuration." = "使用上次已准备的配置在后台启动或停止 VPN。"; +"Start or stop VPN" = "启动或停止 VPN"; +"Status pending" = "状态待确认"; +"Open OneXray to finish setup." = "打开 OneXray 完成配置。"; +"This VPN action must run in OneXray." = "此 VPN 操作必须在 OneXray 中执行。"; +"OneXray cannot open from this system control right now." = "当前无法从此系统控件打开 OneXray。"; +"VPN action failed." = "VPN 操作失败。"; +"The VPN start was cancelled." = "VPN 启动已取消。"; +"VPN start is temporarily unavailable while app data is being updated." = "App 数据更新期间暂时无法启动 VPN。"; +"The VPN is still stopping." = "VPN 仍在停止中。"; +"Unable to confirm the current VPN status." = "无法确认当前 VPN 状态。"; diff --git a/ios/widget/zh-Hant.lproj/Widget.strings b/ios/widget/zh-Hant.lproj/Widget.strings index fdfe9a58f..ddb0fb7d8 100644 --- a/ios/widget/zh-Hant.lproj/Widget.strings +++ b/ios/widget/zh-Hant.lproj/Widget.strings @@ -13,3 +13,16 @@ "Unknown" = "未知"; "Start" = "啟動"; "Stop" = "停止"; +"VPN" = "VPN"; +"VPN status and background actions using the last prepared configuration." = "查看 VPN 狀態,使用上次已準備的設定在背景執行操作。"; +"Start or stop VPN in the background using the last prepared configuration." = "使用上次已準備的設定在背景啟動或停止 VPN。"; +"Start or stop VPN" = "啟動或停止 VPN"; +"Status pending" = "狀態待確認"; +"Open OneXray to finish setup." = "開啟 OneXray 完成設定。"; +"This VPN action must run in OneXray." = "此 VPN 操作必須在 OneXray 中執行。"; +"OneXray cannot open from this system control right now." = "目前無法從此系統控制項開啟 OneXray。"; +"VPN action failed." = "VPN 操作失敗。"; +"The VPN start was cancelled." = "VPN 啟動已取消。"; +"VPN start is temporarily unavailable while app data is being updated." = "App 資料更新期間暫時無法啟動 VPN。"; +"The VPN is still stopping." = "VPN 仍在停止中。"; +"Unable to confirm the current VPN status." = "無法確認目前的 VPN 狀態。"; diff --git a/swift/iOS/AppleWidgetPublisher.swift b/swift/iOS/AppleWidgetPublisher.swift index 169c77d7d..9ab1a35cd 100644 --- a/swift/iOS/AppleWidgetPublisher.swift +++ b/swift/iOS/AppleWidgetPublisher.swift @@ -7,6 +7,9 @@ enum AppleWidgetPublisher { guard let store = AppleWidgetStore.shared, (try? store.writeStatus(status)) == true else { return } WidgetCenter.shared.reloadTimelines(ofKind: AppleWidgetStore.kind) + if #available(iOS 18.0, *) { + ControlCenter.shared.reloadControls(ofKind: AppleWidgetStore.controlKind) + } } static func setLanguage(_ code: String) throws { From 9ec56fb5edd05c25e9e4cc8c7009790ea10db343 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 21:57:44 +0800 Subject: [PATCH 50/60] fix(ios): preserve later widget toggles during status queries --- swift/App/Vpn.swift | 10 +++--- swift/iOS/AppleVpnStartAdmission.swift | 7 ++++ test/native/AppleWidgetTests.swift | 49 +++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/swift/App/Vpn.swift b/swift/App/Vpn.swift index bae8fcd83..b72b76045 100644 --- a/swift/App/Vpn.swift +++ b/swift/App/Vpn.swift @@ -431,21 +431,21 @@ class VPNManager { return result } - var widgetCommandGeneration: UInt64 { startAdmission.generation } + var widgetCommandGeneration: UInt64 { startAdmission.invalidationGeneration } func readWidgetVpnStatus() async throws -> AppleWidgetStatus { - let generation = startAdmission.generation + let generation = startAdmission.invalidationGeneration do { #if !targetEnvironment(simulator) let current = try await findVpn() - try startAdmission.check(generation) + try startAdmission.checkInvalidation(generation) vpn = current #endif if stopTask != nil { return .disconnecting } if startAdmission.hasCurrentDartPreparation { return .connecting } if let active = activeStartGeneration, startAdmission.isCurrent(active) { return .connecting } let status = try await readStatus() - try startAdmission.check(generation) + try startAdmission.checkInvalidation(generation) let result: AppleWidgetStatus switch status ?? .invalid { case .connected: result = .connected @@ -459,7 +459,7 @@ class VPNManager { } catch is CancellationError { throw CancellationError() } catch { - try startAdmission.check(generation) + try startAdmission.checkInvalidation(generation) AppleWidgetPublisher.publish(.unknown) throw error } diff --git a/swift/iOS/AppleVpnStartAdmission.swift b/swift/iOS/AppleVpnStartAdmission.swift index 2527a058d..8ec302ac2 100644 --- a/swift/iOS/AppleVpnStartAdmission.swift +++ b/swift/iOS/AppleVpnStartAdmission.swift @@ -13,6 +13,8 @@ final class AppleVpnStartAdmission { } private(set) var generation: UInt64 = 0 + // Queries may observe a newer Start; only Stop/maintenance retires the command. + private(set) var invalidationGeneration: UInt64 = 0 private var preparing: Set = [] private var drainWaiters: [CheckedContinuation] = [] private var dartPreparations: [String: UInt64] = [:] @@ -81,6 +83,10 @@ final class AppleVpnStartAdmission { guard isCurrent(token) else { throw CancellationError() } } + func checkInvalidation(_ token: UInt64) throws { + guard token == invalidationGeneration else { throw CancellationError() } + } + func finishPreparation(_ token: UInt64) { preparing.remove(token) guard preparing.isEmpty else { return } @@ -92,6 +98,7 @@ final class AppleVpnStartAdmission { @discardableResult func invalidate() -> [String] { generation &+= 1 + invalidationGeneration = generation let invalidated = dartPreparations.keys.sorted() dartPreparations.removeAll() return invalidated diff --git a/test/native/AppleWidgetTests.swift b/test/native/AppleWidgetTests.swift index 5f11222e5..01a9e04ee 100644 --- a/test/native/AppleWidgetTests.swift +++ b/test/native/AppleWidgetTests.swift @@ -244,7 +244,7 @@ final class AppleWidgetTests: XCTestCase { }, stop: { XCTFail("An old Toggle result must not stop a later run") return .completed - }, commandGeneration: { admission.generation }) + }, commandGeneration: { admission.invalidationGeneration }) let pending = Task { @MainActor in try await actions.perform(command) } var iterator = queries.makeAsyncIterator() _ = await iterator.next() @@ -261,6 +261,53 @@ final class AppleWidgetTests: XCTestCase { } } + @MainActor + func testSecondToggleQueryStopsTheStartAdmittedByFirstToggle() async throws { + defer { _ = AppleWidgetActionInbox.shared.takeAction() } + let admission = AppleVpnStartAdmission() + let (queries, enteredQuery) = AsyncStream.makeStream() + var replies: [CheckedContinuation] = [] + var starting = false + var starts = 0 + var stops = 0 + let actions = AppleWidgetVpnActions(readStatus: { + await withCheckedContinuation { continuation in + replies.append(continuation) + enteredQuery.yield(replies.count) + } + return starting ? .connecting : .disconnected + }, start: { + starts += 1 + let generation = admission.begin() + admission.finishPreparation(generation) + starting = true + return .inProgress + }, stop: { + stops += 1 + admission.invalidate() + starting = false + return .completed + }, commandGeneration: { admission.invalidationGeneration }) + var iterator = queries.makeAsyncIterator() + let first = Task { @MainActor in try await actions.perform(.toggle) } + _ = await iterator.next() + let second = Task { @MainActor in try await actions.perform(.toggle) } + _ = await iterator.next() + replies[0].resume() + let firstResult = try await first.value + XCTAssertEqual(firstResult, .inProgress) + XCTAssertEqual(admission.generation, 1) + XCTAssertEqual(admission.invalidationGeneration, 0) + replies[1].resume() + let secondResult = try await second.value + XCTAssertEqual(secondResult, .completed) + XCTAssertEqual(starts, 1) + XCTAssertEqual(stops, 1) + XCTAssertFalse(starting) + XCTAssertEqual(admission.invalidationGeneration, admission.generation) + enteredQuery.finish() + } + @MainActor func testStopDoesNotReadStartAvailabilityAndMissingStartRequestsForeground() async throws { defer { _ = AppleWidgetActionInbox.shared.takeAction() } From 9377817fb9dd20c2c55742facafc09e24af464f3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 22:01:30 +0800 Subject: [PATCH 51/60] feat(ios): combine home widget VPN actions into one button --- ios/widget/widget.swift | 67 +++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index 1d9078ff3..f20db844f 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -151,7 +151,18 @@ private struct ConnectionWidgetView: View { private let palette = ConnectionWidgetPalette() private var largeText: Bool { dynamicTypeSize >= .xxxLarge } - private var emphasizeStop: Bool { entry.status == .connected || entry.status == .connecting } + + private var toggleAction: ConnectionWidgetAction { + switch entry.status { + case .connected, .connecting: .stop + case .disconnected: .start + case .unknown, .disconnecting: .toggle + } + } + + private var toggleLabelKey: String { + entry.status == .disconnecting ? "Disconnecting" : toggleAction.labelKey + } private var isAccessory: Bool { family == .accessoryCircular || family == .accessoryRectangular @@ -210,7 +221,7 @@ private struct ConnectionWidgetView: View { .frame(maxWidth: .infinity) Spacer(minLength: 6) if #available(iOS 17.0, *) { - actions(compact: true, iconsOnly: compactHeight && dynamicTypeSize >= .xxLarge) + toggleButton(compact: true, iconsOnly: compactHeight && dynamicTypeSize >= .xxLarge) } else { openApp(compact: true) } @@ -242,7 +253,7 @@ private struct ConnectionWidgetView: View { .accessibilityHint(text("Open app")) Group { if #available(iOS 17.0, *) { - actions(compact: false) + toggleButton(compact: false) } else { openApp(compact: false) } @@ -309,41 +320,30 @@ private struct ConnectionWidgetView: View { } @available(iOS 17.0, *) - @ViewBuilder private func actions(compact: Bool, iconsOnly: Bool = false) -> some View { - if compact { - HStack(spacing: 8) { - startButton(compact: true, iconsOnly: iconsOnly) - stopButton(compact: true, iconsOnly: iconsOnly) - } - } else { - VStack(spacing: 8) { - startButton(compact: false, iconsOnly: iconsOnly) - stopButton(compact: false, iconsOnly: iconsOnly) - } + private func toggleButton(compact: Bool, iconsOnly: Bool = false) -> some View { + // The snapshot only controls presentation. The intent queries native + // state, so an old label never changes the command into a stale Start/Stop. + Button(intent: ToggleVpnWidgetIntent()) { + actionLabel( + toggleAction, compact: compact, iconsOnly: iconsOnly, + labelKey: entry.status == .disconnecting ? "Disconnecting" : nil + ) } + .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: toggleAction, prominent: true)) + .accessibilityLabel(text(toggleLabelKey)) + // Remain actionable while disconnecting; native execution returns + // inProgress without queuing another connection request. } - @available(iOS 17.0, *) - private func startButton(compact: Bool, iconsOnly: Bool) -> some View { - Button(intent: StartVpnWidgetIntent()) { actionLabel(.start, compact: compact, iconsOnly: iconsOnly) } - .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .start, prominent: !emphasizeStop)) - .accessibilityLabel(text("Start VPN")) - } - - @available(iOS 17.0, *) - private func stopButton(compact: Bool, iconsOnly: Bool) -> some View { - Button(intent: StopVpnWidgetIntent()) { actionLabel(.stop, compact: compact, iconsOnly: iconsOnly) } - .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .stop, prominent: emphasizeStop)) - .accessibilityLabel(text("Stop VPN")) - } - - private func actionLabel(_ action: ConnectionWidgetAction, compact: Bool, iconsOnly: Bool = false) -> some View { + private func actionLabel( + _ action: ConnectionWidgetAction, compact: Bool, iconsOnly: Bool = false, labelKey: String? = nil + ) -> some View { HStack(spacing: compact ? 3 : 4) { Image(systemName: action.symbol) .font(.system(size: largeText || iconsOnly ? 18 : 12, weight: .semibold)) if !largeText && !iconsOnly { - Text(text(compact ? action.compactKey : action.labelKey)) - .lineLimit(compact && action != .open ? 1 : 2) + Text(text(labelKey ?? (compact ? action.compactKey : action.labelKey))) + .lineLimit(compact && action != .open && action != .toggle ? 1 : 2) .minimumScaleFactor(0.92) } } @@ -382,12 +382,13 @@ private struct ConnectionWidgetView: View { } private enum ConnectionWidgetAction { - case start, stop, open + case start, stop, toggle, open var symbol: String { switch self { case .start: "play.fill" case .stop: "stop.fill" + case .toggle: "power" case .open: "arrow.up.forward" } } @@ -396,6 +397,7 @@ private enum ConnectionWidgetAction { switch self { case .start: "Start VPN" case .stop: "Stop VPN" + case .toggle: "Start or stop VPN" case .open: "Open app" } } @@ -404,6 +406,7 @@ private enum ConnectionWidgetAction { switch self { case .start: "Start" case .stop: "Stop" + case .toggle: "Start or stop VPN" case .open: "Open app" } } From 6cd93756684e4cf5d0d354b5b27b5adf1e5bbcc5 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 22:10:04 +0800 Subject: [PATCH 52/60] refactor(ios): redesign widgets around a single power control --- ios/widget/widget.swift | 187 +++++++++++++++++++--------------------- 1 file changed, 91 insertions(+), 96 deletions(-) diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index f20db844f..9e60b0cb9 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -147,6 +147,9 @@ struct VpnControl: ControlWidget { private struct ConnectionWidgetView: View { @Environment(\.widgetFamily) private var family @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .headline) private var statusLineHeight: CGFloat = 22 + @ScaledMetric(relativeTo: .caption) private var brandLineHeight: CGFloat = 14 + @ScaledMetric(relativeTo: .caption2) private var observationLineHeight: CGFloat = 12 let entry: ConnectionWidgetEntry private let palette = ConnectionWidgetPalette() @@ -209,22 +212,24 @@ private struct ConnectionWidgetView: View { private var smallContent: some View { GeometryReader { geometry in - let compactHeight = geometry.size.height < 124 - VStack(alignment: .leading, spacing: 0) { - if !largeText && !compactHeight { brand } - Spacer(minLength: 3) - VStack(spacing: 2) { - if !largeText { statusSymbol(size: geometry.size.height >= 136 ? 24 : 20) } + let showBrand = !largeText && geometry.size.height >= 44 + statusLineHeight + brandLineHeight + 12 + let showObservation = !largeText && entry.snapshot != nil + && geometry.size.height >= 56 + statusLineHeight + brandLineHeight + observationLineHeight + 14 + let reservedHeight = statusLineHeight + 10 + + (showBrand ? brandLineHeight + 4 : 0) + + (showObservation ? observationLineHeight + 3 : 0) + let diameter = min(64, max(44, geometry.size.height - reservedHeight)) + + VStack(spacing: 0) { + if showBrand { brand } + Spacer(minLength: 4) + powerControl(diameter: diameter) + Spacer(minLength: 6) + VStack(spacing: 3) { statusLabel(lines: 1) - observation(showPrefix: !largeText) + if showObservation { observation(showPrefix: false) } } .frame(maxWidth: .infinity) - Spacer(minLength: 6) - if #available(iOS 17.0, *) { - toggleButton(compact: true, iconsOnly: compactHeight && dynamicTypeSize >= .xxLarge) - } else { - openApp(compact: true) - } } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -232,30 +237,41 @@ private struct ConnectionWidgetView: View { private var mediumContent: some View { GeometryReader { geometry in - let actionWidth = largeText ? 64 : geometry.size.width * 0.44 + let actionWidth: CGFloat = geometry.size.width < 300 ? 88 : 104 + let diameter = min(72, max(44, geometry.size.height - (largeText ? 0 : 36))) HStack(spacing: 16) { Link(destination: connectionURL) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { if !largeText { brand } - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - if !largeText { statusSymbol(size: geometry.size.width < 310 ? 24 : 32) } - statusLabel(lines: 1) - .frame(maxWidth: .infinity, alignment: .leading) - } - observation(showPrefix: !largeText) + HStack(spacing: 8) { + Circle() + .fill(statusColor) + .frame(width: 6, height: 6) + .privacySensitive() + .accessibilityHidden(true) + statusLabel(font: .title2.weight(.semibold), lines: 2) + .frame(maxWidth: .infinity, alignment: .leading) + } + if !largeText && entry.snapshot != nil && geometry.size.height >= 100 { + observation(showPrefix: true) } } - .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityHint(text("Open app")) - Group { - if #available(iOS 17.0, *) { - toggleButton(compact: false) - } else { - openApp(compact: false) + + VStack(spacing: 8) { + powerControl(diameter: diameter) + if !largeText { + Text(text(powerCaptionKey)) + .font(.caption.weight(.medium)) + .foregroundStyle(palette.secondary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.75) + .accessibilityHidden(true) } } .frame(width: actionWidth) @@ -271,23 +287,15 @@ private struct ConnectionWidgetView: View { .lineLimit(1) } - private func statusLabel(lines: Int) -> some View { + private func statusLabel(font: Font = .headline, lines: Int) -> some View { Text(statusText) - .font(.headline) - .foregroundStyle(palette.foreground) + .font(font) + .foregroundStyle(entry.status == .connected ? palette.success : palette.foreground) .lineLimit(lines) .minimumScaleFactor(largeText ? 0.4 : 0.75) .privacySensitive() } - private func statusSymbol(size: CGFloat) -> some View { - Image(systemName: statusIcon) - .font(.system(size: size, weight: .regular)) - .foregroundStyle(statusColor) - .privacySensitive() - .accessibilityHidden(true) - } - private func observation(showPrefix: Bool) -> some View { Group { if let snapshot = entry.snapshot { @@ -311,43 +319,34 @@ private struct ConnectionWidgetView: View { .privacySensitive() } - private func openApp(compact: Bool) -> some View { - Link(destination: connectionURL) { - actionLabel(.open, compact: compact) - } - .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .open, prominent: true)) - .accessibilityLabel(text("Open app")) - } - - @available(iOS 17.0, *) - private func toggleButton(compact: Bool, iconsOnly: Bool = false) -> some View { - // The snapshot only controls presentation. The intent queries native - // state, so an old label never changes the command into a stale Start/Stop. - Button(intent: ToggleVpnWidgetIntent()) { - actionLabel( - toggleAction, compact: compact, iconsOnly: iconsOnly, - labelKey: entry.status == .disconnecting ? "Disconnecting" : nil - ) - } - .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: toggleAction, prominent: true)) - .accessibilityLabel(text(toggleLabelKey)) - // Remain actionable while disconnecting; native execution returns - // inProgress without queuing another connection request. - } - - private func actionLabel( - _ action: ConnectionWidgetAction, compact: Bool, iconsOnly: Bool = false, labelKey: String? = nil - ) -> some View { - HStack(spacing: compact ? 3 : 4) { - Image(systemName: action.symbol) - .font(.system(size: largeText || iconsOnly ? 18 : 12, weight: .semibold)) - if !largeText && !iconsOnly { - Text(text(labelKey ?? (compact ? action.compactKey : action.labelKey))) - .lineLimit(compact && action != .open && action != .toggle ? 1 : 2) - .minimumScaleFactor(0.92) + @ViewBuilder private func powerControl(diameter: CGFloat) -> some View { + if #available(iOS 17.0, *) { + // Snapshot state only changes presentation. Native execution reads + // current status, and disconnecting never queues a new connection. + Button(intent: ToggleVpnWidgetIntent()) { + powerSymbol(diameter: diameter) } + .buttonStyle(ConnectionWidgetPowerButtonStyle(palette: palette, action: toggleAction, diameter: diameter)) + .accessibilityLabel(text(toggleLabelKey)) + .accessibilityValue(statusText) + } else { + Link(destination: connectionURL) { powerSymbol(diameter: diameter) } + .buttonStyle(ConnectionWidgetPowerButtonStyle(palette: palette, action: .open, diameter: diameter)) + .accessibilityLabel(text("Open app")) } - .padding(.horizontal, compact ? 2 : 8) + } + + private func powerSymbol(diameter: CGFloat) -> some View { + Image(systemName: "power") + .font(.system(size: diameter * 0.4, weight: .medium)) + .accessibilityHidden(true) + } + + private var powerCaptionKey: String { + if #available(iOS 17.0, *) { + return entry.status == .disconnecting ? "Disconnecting" : toggleAction.compactKey + } + return "Open app" } private var statusText: String { @@ -384,15 +383,6 @@ private struct ConnectionWidgetView: View { private enum ConnectionWidgetAction { case start, stop, toggle, open - var symbol: String { - switch self { - case .start: "play.fill" - case .stop: "stop.fill" - case .toggle: "power" - case .open: "arrow.up.forward" - } - } - var labelKey: String { switch self { case .start: "Start VPN" @@ -412,34 +402,39 @@ private enum ConnectionWidgetAction { } } -private struct ConnectionWidgetButtonStyle: ButtonStyle { +private struct ConnectionWidgetPowerButtonStyle: ButtonStyle { let palette: ConnectionWidgetPalette let action: ConnectionWidgetAction - let prominent: Bool + let diameter: CGFloat private var foreground: Color { - prominent ? .white : (action == .stop ? palette.destructive : palette.primaryText) + switch action { + case .stop: palette.destructive + case .toggle: palette.secondary + case .start, .open: palette.primaryText + } } - private var fill: Color { - if prominent { return action == .stop ? palette.destructiveSolid : palette.primarySolid } - return action == .stop ? .clear : palette.accent + private var tint: Color { + switch action { + case .stop: palette.destructiveSolid + case .toggle: palette.secondary + case .start, .open: palette.primarySolid + } } func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.caption.weight(.semibold)) .foregroundStyle(foreground) - .frame(maxWidth: .infinity, minHeight: 44) + .frame(width: diameter, height: diameter) .background { - RoundedRectangle(cornerRadius: 8).fill(fill).widgetAccentable() + Circle().fill(tint.opacity(0.12)).widgetAccentable() } .overlay { - RoundedRectangle(cornerRadius: 8) - .strokeBorder(prominent ? .clear : palette.border, lineWidth: 1) + Circle().strokeBorder(tint.opacity(0.35), lineWidth: 1) } - .contentShape(RoundedRectangle(cornerRadius: 8)) - .opacity(configuration.isPressed ? 0.75 : 1) + .contentShape(Circle()) + .opacity(configuration.isPressed ? 0.7 : 1) } } From 94e043b116b42c1f383a0b6c9c045c512e5bbbe7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 22:27:08 +0800 Subject: [PATCH 53/60] docs(ios): describe background widget actions and validation --- docs/app.md | 43 +++++++++++++++++++++++++------------ docs/external-interfaces.md | 38 ++++++++++++++++++++++++-------- docs/validation.md | 33 ++++++++++++++++++++-------- 3 files changed, 82 insertions(+), 32 deletions(-) diff --git a/docs/app.md b/docs/app.md index fd1797e09..bd2d6b85d 100644 --- a/docs/app.md +++ b/docs/app.md @@ -350,23 +350,38 @@ Android 快捷设置磁贴单击沿用启停;长按通过 `QS_TILE_PREFERENCES iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMedium`、 `accessoryCircular`、`accessoryRectangular`,可添加位置由系统版本与设备决定。 -iOS/iPadOS 16 静态展示状态,点击打开连接页; -17 起主屏幕小号/中号 Widget 增加显式“启动 VPN”“停止 VPN”,通过前台 App Intent 打开 App。 -锁屏两种 accessory 只导航。18 起另提供“启动 VPN”“停止 VPN”两个控制中心动作按钮, -说明明确会打开 OneXray;两者复用同一前台 App Intent,按当前连接计划执行,不显示连接态或根据过期快照反向切换。 +主屏幕 Widget 保留 kind `net.yuandev.onexray.connection`;17 起小号/中号均提供一个启停按钮, +锁屏两种 accessory 始终只导航。18 起控制中心提供一个 kind 为 `net.yuandev.onexray.vpn` 的 VPN 按钮。 + +| 系统版本 | 执行方式 | +| --- | --- | +| iOS/iPadOS 16 | 静态展示,点击打开连接页。 | +| 17–25 | 主屏幕 Widget 默认在主 App 进程后台启停,控制中心从 18 起使用同一路径;缺少可用输入或需要授权时才请求前台继续。 | +| 26 | 保留主 App 执行路径,显式声明后台和动态前台模式。 | +| 27 及以上 | 进一步用 `.main` 明确限定主 App 执行;启停与按需前台继续的行为相同。 | + +主屏使用 `Button(intent: ToggleVpnWidgetIntent())`,控制中心使用 `ControlWidgetButton` 和同一切换 Intent, +不采用持久布尔开关或 `SetValueIntent`。主屏未连接时表达启动操作,已连接或正在连接时表达停止操作; +未知或过期时主屏显示“未知”、控制中心显示“待确认”,并保持可操作。点击时重新读取系统状态及本进程进行中的操作: +已连接或正在连接时停止,已断开时启动,正在断开时不追加启动。展示快照不决定动作,不对其直接取反。 控制中心动作名称跟随 App 有效语言,语言变化后请求 Controls 刷新。当前不发布可发现的 Siri/App Shortcuts 或 Live Activity。 主屏组件跟随系统浅深色,配色复用 App 色板,由 Widget 命名颜色资源统一管理; -系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号居中展示状态,底部并排显示简短启停文案; -中号将状态摘要与操作分列,摘要和旧系统的“打开 App”均进入连接页。 -启动使用蓝色,停止使用红色,已连接用绿色盾牌标识,未连接使用中性色。 -按钮强调跟随展示快照,两种操作始终可用,不因可能过期的状态禁用。 -操作保留 44pt 点击范围,最小尺寸与大字模式精简重复品牌、装饰及可见按钮文字,保留完整无障碍名称。 - -启停复用 App 的 `ConnectionCoordinator` 与当前选择、权限检查和错误反馈;不读取 saved-start, -Widget 扩展不在后台直接启停 VPN。已到达的冷启动输入先于偏好自动连接处理,Shell 就绪后执行; -重复同类操作合并;停止取消已有启动,等待必要的准备写入排空,不等待已提交启动的连接状态确认。公开链接仅导航,见 -[Widget 动作与链接](external-interfaces.md#ios-widget-前台动作与链接)。 +系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号顶部显示 OneXray,中心为圆形电源按钮, +底部显示状态,空间允许时补充观测时间;大字或高度不足时收起品牌与时间。 +中号左侧为品牌、状态与观测时间摘要,右侧固定窄区放圆形电源按钮及简短操作标签;摘要进入连接页。 +启动沿用蓝色、停止沿用红色,已连接状态使用绿色;配色与字体沿用现有资源和系统字号。 +圆形电源承载唯一启停操作,按钮强调与短标签跟随展示状态,不因可能过期的快照禁用;动作仍由新读取的原生状态决定。 +操作保留 44pt 点击范围,大字模式精简品牌、时间及可见短标签,完整启停名称通过无障碍标签保留。 + +启停由主 App 原生服务复用最近生成的 `run/start.json` 和已有系统 VPN 配置;不追随后来修改的 DB 选择, +不经 Dart 重新编译配置。停止不依赖启动文件。缺输入或需要授权时只打开连接页供用户处理, +不会排队按当前选择自动连接;各版本的前台继续均遵守此边界。 +原生动作抑制本次启动自动连接,并只取消匹配的旧 Dart 准备,不重复执行已经处理的原生停止。 +正常 App 连接仍经协调器准备,再由原生原子提交;协议与维护阻断见 +[Widget 动作与链接](external-interfaces.md#ios-widget-动作与链接)。 + +App 恢复运行展示时用系统 `connectedDate` 校准内存中的会话起始时间,不改写保存请求或配置缓存。 Widget 展示最近一次原生状态快照及观测时间,非实时状态。真机“已连接”只来自 NetworkExtension 状态观测, 提交启动请求或 Core 构造成功不代表已连接。快照超过 15 分钟、缺失、损坏或版本未知时按“未知”处理; diff --git a/docs/external-interfaces.md b/docs/external-interfaces.md index c7af9bdd2..261f71946 100644 --- a/docs/external-interfaces.md +++ b/docs/external-interfaces.md @@ -124,15 +124,35 @@ Widget/Tile 的缺输入打开 App 回退不用于广播。 异常退出遗留标记在下次 `ServiceManager` 存储/协调器就绪后解除,不依赖 Setup、不自动连接, 也不把此标记扩展成 VPN 状态缓存或普通保存/测速锁。 -## iOS Widget 前台动作与链接 - -iOS/iPadOS 17 起 Widget、18 起控制中心的显式启动/停止使用不公开发现的前台 App Intent;App 内存接收动作, -经原生桥交给既有快捷入口和连接协调器。冷启动与回前台均读取待处理动作,Shell 未就绪时保留最后一个动作, -并抑制本次偏好自动连接;消费后清除,同类在途动作合并。较晚到达的停止会取消待执行启动, -iOS 原生先排空配置准备写入再停止,不等待已提交启动的连接状态确认。 -输入仅限启动/停止,不接受配置参数、节点选择或通用 invoke,也不形成跨进程持久命令队列。 -它使用当前 App 选择,不复用 Android Widget/Tile 或 Apple 按需连接的 saved-start 路径。 -控制中心提供两个静态动作按钮,动作本身打开 App 后执行;不把点击按钮当作连接成功,也不从展示快照推断切换目标。 +## iOS Widget 动作与链接 + +主屏幕 Widget 自 iOS/iPadOS 17 使用单个 `Button(intent: ToggleVpnWidgetIntent())`;控制中心自 18 提供单个 VPN 按钮, +kind 为 `net.yuandev.onexray.vpn`,使用 `ControlWidgetButton` 和同一无参数切换动作,不采用 `SetValueIntent`。 +主屏未连接时显示启动,已连接或连接中显示停止;未知/过期时主屏显示未知、控制中心显示待确认,并保持可操作。iOS 16 与锁屏 accessory 仍只导航。 +这些 Intent 不公开为 Siri/App Shortcuts,`openAppWhenRun=false`;主屏幕自 17、控制中心自 18 起默认后台启停。 +Runner-only 的 `ForegroundContinuableIntent` conformance 提供主 App 进程执行路径;该协议自 16.4 可用, +26 弃用但未移除,Widget 扩展不编译此 conformance。26 起声明 `supportedModes=[.background,.foreground(.dynamic)]`, +27 起再用 `allowedExecutionTargets=.main` 明确限定主 App 执行。17–25 缺输入或需要授权时使用旧前台继续方法, +26 起使用 `continueInForeground`;只有系统接受继续后才导航。API 版本决定兼容路径,设备未测范围单独记录, +不以 27 或真机覆盖情况作为后台能力的额外门槛。 + +原生动作每次查所属系统 VPN 配置和实际状态,并结合本进程待启动/停止状态决定操作:已连接或连接中停止, +已断开时启动,断开中不追加启动。展示快照即使未知或过期也不决定启停目标,不对快照直接取反。 +并发 Toggle 查询不能使较后的停止意图被较早的查询或启动吞掉。启动复用最近生成的完整 `run/start.json` 与 App Group 资源, +并恢复保存请求中的系统 profile 路由/按需策略;不读取 DB 重新选节点,不重复配置验证、端口或依赖扫描。 +只检查保存输入可读取及启动必需字段,实际 Core/资源错误由既有启动链返回;停止不依赖启动文件。 +缺 profile、输入或需要授权时只请求前台处理,前台继续获系统接受后才导航连接页,不再排队启动当前 App 选择。 + +正常 App 连接在 Dart 准备前向 Runner 登记 preparation token,原生 `startPreparedVpn` 在同一 MainActor 临界段 +检查并消费 token、原子写入请求、准入启动;iOS 不再由 Dart 先写启动文件。Stop 淘汰旧 token, +过期提交使用 `apple-preparation-cancelled` 类型化错误返回;Dart 不重复停止已由原生处理的连接,也不影响之后的新启动。 +清理/恢复复用现有维护入口建立原生持久 START 阻断,失败不进入数据替换;正常启动就绪后解除异常遗留标记。 + +原生到 Dart 的 `takeAction` 返回消费一次的 JSON envelope:`version:1`、`suppressAutoConnect:bool`、 +`openConnect:bool`、`invalidatedPreparations:[String]`。它只累积启动抑制、导航和旧准备失效信息, +不重送 start/stop,不形成持久命令队列。Dart 先抑制本次偏好自动连接,再仅取消匹配当前 token 的旧任务, +最后处理导航;冷启动和回前台均读取,迟到旧 token 不取消新任务。系统 `connectedDate` 只校准内存运行时间, +不改写保存输入或缓存。 Widget 唯一公开 URL 为 `onexray://widget/connect`,只打开连接页,不启停 VPN。 带查询、fragment、端口、用户信息或其他路径的地址不进入 Widget 动作入口;启停不通过 URL 参数开放。 diff --git a/docs/validation.md b/docs/validation.md index 1e58f1bf8..a64250f2f 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -241,10 +241,14 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时 - Apple 文件备份:`bash tool/test_backup_file_store.sh` 编译生产文件存储实现,验证首字节内容探测、绑定不覆盖、替换写入、 新实例重新访问、解绑保留文件及大小限制。命令行使用普通 bookmark,持久沙盒授权需在签名 App 中核对。 - iOS Widget 原生:`bash tool/test_apple_widget.sh` 以纯 Swift 编译生产快照与动作收件箱, - 验证状态/语言独立原子更新、五语言归一化、缺失/损坏/未知版本与过期状态、早到动作只消费一次及保留最后动作。 - Dart 定向测试位于 `test/service/shared/apple_widget/` 与 `test/service/shared/menu/short_cut/`, - 覆盖冷启动抑制自动连接、运行中停止不被启动阻塞、重复动作、回前台读取、未知输入及导航 URL 边界。 - 这些测试不执行实际 App Intent、不证明 WidgetKit 展示或 NetworkExtension 状态。 + 检查状态/语言独立原子更新、五语言归一化、缺失/损坏/未知版本与过期状态,以及 envelope 消费、真实状态动作分流、 + 重复启动、Stop 淘汰 token、维护阻断与恢复。它使用可控原生状态回复,不执行实际 App Intent 或真实 VPN。 + Dart 定向测试位于 `test/service/shared/apple_widget/`、`test/service/shared/menu/short_cut/`、 + `test/service/connect/runtime_host_test.dart`、`test/service/connect/coordinator_test.dart` 和 + `test/service/settings/data_cleanup_test.dart`;检查冷启动抑制顺序、匹配 token 取消、登记回包前失效、 + 暂停准备/原生提交后的 Stop、新启动不受旧通知影响、类型化取消不重复停止、iOS 不先写文件、 + 系统连接时间只更新内存,以及旧快捷入口与公开导航 URL 边界。 + 这些自动化测试不证明 WidgetKit 展示、Intent 实际执行宿主或 NetworkExtension 状态。 - macOS SE 激活:`bash tool/test_system_extension_activation.sh` 编译生产 Swift, 验证待批准时重试、已安装时跳过、强制替换及并发请求合并;使用可控系统回复,不申请真实扩展授权或启动 VPN。 页面测试核对授权未完成时不连接、批准说明持续显示、取消与打开设置,以及设置打开失败的反馈。 @@ -265,6 +269,7 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时 Android 模拟器直接使用当前分支构建覆盖已经安装的 OneXray,保留已有数据,不另建测试包或 applicationId。 验证使用脱敏输入或临时响应替身,调用真实原生解析;完成后只清理本轮新增的测试数据。 +iOS 同样沿用正常 bundle ID、App Group 和已有真机,使用签名包覆盖安装;不另建验收 App 或新模拟器。 - Android UI 用模拟器验证;系统导入、导出和扫码可记录跳过。Widget 检查 4×2 最小尺寸、 缩放、旋转、主题、语言与启动/停止。通知和真实代理请求需运行 VPN 后验证。 @@ -277,14 +282,24 @@ Android 模拟器直接使用当前分支构建覆盖已经安装的 OneXray, - 完整配置适配在 Android 验证 Raw/提供方真实 VPN 启动、DNS 查询、受控网页响应、连续重连和隧道 DNS 设置变更。 iOS 模拟器只验证配置与 SOCKS 路径,不能验收真机 DNS、DoT 或 includeAllNetworks;这些项目需要 Apple 真机。 - iOS/iPadOS Widget 最低部署版本为 16.0;16 验证主屏幕小号/中号静态导航及两种锁屏 accessory。 - 在系统图库与实际主屏幕/锁屏验证系统支持的 family; - 17 起验证主屏幕小号/中号启动、停止实际前台打开 App,并使用当前选择而非最近生成的 saved-start。 - 18 起在控制中心图库添加两个显式动作,核对准确的打开 App 说明、启停交付与语言切换;不以按钮动画证明已连接。 - 覆盖冷启动、App 已运行/后台、重复点击和启动中停止,确认连接协调器、权限失败与页面/通知反馈。 + 在系统图库与实际主屏幕/锁屏验证系统支持的 family。17 起主屏幕单个启停按钮、18 起单个控制中心 VPN 按钮 + 均验证主 App 进程后台执行;17–25 验证 Runner-only `ForegroundContinuableIntent` 及旧前台继续方法, + 26 验证后台/动态前台模式与 `continueInForeground`,27 起额外验证 `.main` 执行目标。 + 正常启停均复用最近保存输入;故意让 DB 选择与已保存输入不同,确认不自动重编译当前选择,前台修复回退只导航。 + 覆盖 App 冷态、前台、后台,重复点击、连接中停止、断开中再点、缺输入/权限和实际启动失败; + 主屏未连接表达启动,已连接/连接中表达停止;未知/过期时主屏显示未知、控制中心显示待确认,且仍可点击。 + 两类按钮均新读系统状态和进行中操作,不从展示快照推断切换目标,断开中不追加启动。 + 两次 Toggle 查询重叠时,较后的关闭不能被较早的查询或启动吞掉。 + 已有 App Start 准备/提交时发原生 Stop,确认旧 Dart 工作不再提交或重复停止;随后新 Start 正常且不受迟到通知影响。 + 检查 Stop 后再次启动恢复保存的 profile 路由/按需策略,App 回前台会话时间与系统连接时间一致。 + 逐次确认真实代理 HTTP 响应和服务端收据;按钮动画、Widget 快照和系统已连接均不能代替网络证据。 核对五语言及系统语言匹配、波斯语 RTL、大字、明暗主题、可访问名称,以及未知/过期状态不会继续展示已连接, Widget 不泄露配置名、订阅、地址或流量。签名安装包须检查 Widget 扩展嵌入、独立 provisioning profile、 App/隧道扩展/Widget 的共享 App Group 权限。模拟器与无签名构建不能证明真机 NE 授权、锁屏交互或后台刷新, - App Intent 前台执行、WidgetKit 调度与系统限制须在对应真机签名渠道单独验收。 + App Intent 执行、WidgetKit 调度与系统限制须在对应真机签名渠道单独验收。 + 后台路径按官方 API availability 启用,未测范围不改变功能版本门槛。各阶段的设备结果、未测项、截图、网络收据和 + 临时数据恢复记录保存在工作区 `references/`;分别记录自动化、构建和各版本设备结果,不以旧 UI 或新版本编译通过 + 替代当前布局及旧系统的设备验收。 - Android 自动化需普通第三方 UID 的后台启停、冷进程、Token 撤销和清理/恢复阻断, 至少联调一款真实工具;shell 广播不等于工具联调。 - macOS 桌面 UI 使用 Flutter Debug 验证;VPN、Apple 授权、按需连接、SE 跨容器交接 From f37c3c54a2b1d9a93a8d05a3fe1173f2d089ca0e Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 22:30:29 +0800 Subject: [PATCH 54/60] refactor(ios): remove widget observation time --- docs/app.md | 9 +++--- ios/widget/en.lproj/Widget.strings | 2 -- ios/widget/fa.lproj/Widget.strings | 2 -- ios/widget/ru.lproj/Widget.strings | 2 -- ios/widget/widget.swift | 37 ++----------------------- ios/widget/zh-Hans.lproj/Widget.strings | 2 -- ios/widget/zh-Hant.lproj/Widget.strings | 2 -- 7 files changed, 7 insertions(+), 49 deletions(-) diff --git a/docs/app.md b/docs/app.md index bd2d6b85d..05bba15a2 100644 --- a/docs/app.md +++ b/docs/app.md @@ -368,11 +368,11 @@ iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMediu 主屏组件跟随系统浅深色,配色复用 App 色板,由 Widget 命名颜色资源统一管理; 系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号顶部显示 OneXray,中心为圆形电源按钮, -底部显示状态,空间允许时补充观测时间;大字或高度不足时收起品牌与时间。 -中号左侧为品牌、状态与观测时间摘要,右侧固定窄区放圆形电源按钮及简短操作标签;摘要进入连接页。 +底部显示状态;大字或高度不足时收起品牌。 +中号左侧为品牌与状态摘要,右侧固定窄区放圆形电源按钮及简短操作标签;摘要进入连接页。Widget 不展示观测时间。 启动沿用蓝色、停止沿用红色,已连接状态使用绿色;配色与字体沿用现有资源和系统字号。 圆形电源承载唯一启停操作,按钮强调与短标签跟随展示状态,不因可能过期的快照禁用;动作仍由新读取的原生状态决定。 -操作保留 44pt 点击范围,大字模式精简品牌、时间及可见短标签,完整启停名称通过无障碍标签保留。 +操作保留 44pt 点击范围,大字模式精简品牌及可见短标签,完整启停名称通过无障碍标签保留。 启停由主 App 原生服务复用最近生成的 `run/start.json` 和已有系统 VPN 配置;不追随后来修改的 DB 选择, 不经 Dart 重新编译配置。停止不依赖启动文件。缺输入或需要授权时只打开连接页供用户处理, @@ -383,7 +383,8 @@ iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMediu App 恢复运行展示时用系统 `connectedDate` 校准内存中的会话起始时间,不改写保存请求或配置缓存。 -Widget 展示最近一次原生状态快照及观测时间,非实时状态。真机“已连接”只来自 NetworkExtension 状态观测, +Widget 展示最近一次原生状态快照,非实时状态;`observedAt` 继续用于内部过期判断,不向用户显示。 +真机“已连接”只来自 NetworkExtension 状态观测, 提交启动请求或 Core 构造成功不代表已连接。快照超过 15 分钟、缺失、损坏或版本未知时按“未知”处理; WidgetKit 负责刷新调度,不承诺 App 退出后持续更新。展示不包含配置名称、订阅、服务器地址或流量。 语言跟随 App 有效语言,支持英语、简体中文、繁体中文、俄语、波斯语;系统语言偏好先按 App 规则匹配, diff --git a/ios/widget/en.lproj/Widget.strings b/ios/widget/en.lproj/Widget.strings index 311d81040..0df7aa056 100644 --- a/ios/widget/en.lproj/Widget.strings +++ b/ios/widget/en.lproj/Widget.strings @@ -2,8 +2,6 @@ "VPN status and actions that open OneXray." = "VPN status and actions that open OneXray."; "Open OneXray to perform this action." = "Open OneXray to perform this action."; "Open app" = "Open app"; -"Open app to refresh" = "Open app to refresh"; -"Observed" = "Observed"; "Start VPN" = "Start VPN"; "Stop VPN" = "Stop VPN"; "Disconnected" = "Disconnected"; diff --git a/ios/widget/fa.lproj/Widget.strings b/ios/widget/fa.lproj/Widget.strings index 88efc67d2..6ad6dccfb 100644 --- a/ios/widget/fa.lproj/Widget.strings +++ b/ios/widget/fa.lproj/Widget.strings @@ -2,8 +2,6 @@ "VPN status and actions that open OneXray." = "وضعیت VPN و اقداماتی که OneXray را باز می‌کنند."; "Open OneXray to perform this action." = "برای انجام این کار OneXray را باز کنید."; "Open app" = "باز کردن برنامه"; -"Open app to refresh" = "برای تازه‌سازی برنامه را باز کنید"; -"Observed" = "زمان مشاهده"; "Start VPN" = "شروع VPN"; "Stop VPN" = "توقف VPN"; "Disconnected" = "قطع است"; diff --git a/ios/widget/ru.lproj/Widget.strings b/ios/widget/ru.lproj/Widget.strings index 89aa871cd..4589f90a9 100644 --- a/ios/widget/ru.lproj/Widget.strings +++ b/ios/widget/ru.lproj/Widget.strings @@ -2,8 +2,6 @@ "VPN status and actions that open OneXray." = "Статус VPN и действия с открытием OneXray."; "Open OneXray to perform this action." = "Откройте OneXray для выполнения этого действия."; "Open app" = "Открыть приложение"; -"Open app to refresh" = "Откройте для обновления"; -"Observed" = "Обновлено"; "Start VPN" = "Старт VPN"; "Stop VPN" = "Стоп VPN"; "Disconnected" = "Не подключено"; diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift index 9e60b0cb9..afd072547 100644 --- a/ios/widget/widget.swift +++ b/ios/widget/widget.swift @@ -149,7 +149,6 @@ private struct ConnectionWidgetView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @ScaledMetric(relativeTo: .headline) private var statusLineHeight: CGFloat = 22 @ScaledMetric(relativeTo: .caption) private var brandLineHeight: CGFloat = 14 - @ScaledMetric(relativeTo: .caption2) private var observationLineHeight: CGFloat = 12 let entry: ConnectionWidgetEntry private let palette = ConnectionWidgetPalette() @@ -213,11 +212,8 @@ private struct ConnectionWidgetView: View { private var smallContent: some View { GeometryReader { geometry in let showBrand = !largeText && geometry.size.height >= 44 + statusLineHeight + brandLineHeight + 12 - let showObservation = !largeText && entry.snapshot != nil - && geometry.size.height >= 56 + statusLineHeight + brandLineHeight + observationLineHeight + 14 let reservedHeight = statusLineHeight + 10 + (showBrand ? brandLineHeight + 4 : 0) - + (showObservation ? observationLineHeight + 3 : 0) let diameter = min(64, max(44, geometry.size.height - reservedHeight)) VStack(spacing: 0) { @@ -225,11 +221,8 @@ private struct ConnectionWidgetView: View { Spacer(minLength: 4) powerControl(diameter: diameter) Spacer(minLength: 6) - VStack(spacing: 3) { - statusLabel(lines: 1) - if showObservation { observation(showPrefix: false) } - } - .frame(maxWidth: .infinity) + statusLabel(lines: 1) + .frame(maxWidth: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -252,9 +245,6 @@ private struct ConnectionWidgetView: View { statusLabel(font: .title2.weight(.semibold), lines: 2) .frame(maxWidth: .infinity, alignment: .leading) } - if !largeText && entry.snapshot != nil && geometry.size.height >= 100 { - observation(showPrefix: true) - } } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -296,29 +286,6 @@ private struct ConnectionWidgetView: View { .privacySensitive() } - private func observation(showPrefix: Bool) -> some View { - Group { - if let snapshot = entry.snapshot { - HStack(spacing: 3) { - if showPrefix { Text(text("Observed")).lineLimit(1) } - Text(snapshot.observedAt, style: .time) - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - .layoutPriority(1) - } - .accessibilityElement(children: .combine) - .accessibilityHint(showPrefix ? "" : text("Observed")) - } else { - Text(text("Open app to refresh")) - .lineLimit(1) - } - } - .font(.caption2) - .dynamicTypeSize(...DynamicTypeSize.xxxLarge) - .foregroundStyle(palette.secondary) - .privacySensitive() - } - @ViewBuilder private func powerControl(diameter: CGFloat) -> some View { if #available(iOS 17.0, *) { // Snapshot state only changes presentation. Native execution reads diff --git a/ios/widget/zh-Hans.lproj/Widget.strings b/ios/widget/zh-Hans.lproj/Widget.strings index 886b6e8ca..88bea1959 100644 --- a/ios/widget/zh-Hans.lproj/Widget.strings +++ b/ios/widget/zh-Hans.lproj/Widget.strings @@ -2,8 +2,6 @@ "VPN status and actions that open OneXray." = "查看 VPN 状态,打开 OneXray 执行操作。"; "Open OneXray to perform this action." = "打开 OneXray 执行此操作。"; "Open app" = "打开 App"; -"Open app to refresh" = "打开 App 刷新"; -"Observed" = "观测时间"; "Start VPN" = "启动 VPN"; "Stop VPN" = "停止 VPN"; "Disconnected" = "未连接"; diff --git a/ios/widget/zh-Hant.lproj/Widget.strings b/ios/widget/zh-Hant.lproj/Widget.strings index ddb0fb7d8..95003f77b 100644 --- a/ios/widget/zh-Hant.lproj/Widget.strings +++ b/ios/widget/zh-Hant.lproj/Widget.strings @@ -2,8 +2,6 @@ "VPN status and actions that open OneXray." = "查看 VPN 狀態,開啟 OneXray 執行操作。"; "Open OneXray to perform this action." = "開啟 OneXray 執行此操作。"; "Open app" = "開啟 App"; -"Open app to refresh" = "開啟 App 重新整理"; -"Observed" = "觀測時間"; "Start VPN" = "啓動 VPN"; "Stop VPN" = "停止 VPN"; "Disconnected" = "未連線"; From a06b4696afe728c58f49c732961ca018f2d10c1a Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 22:31:32 +0800 Subject: [PATCH 55/60] docs(license): require attribution for the provider exception --- LICENSE-EXCEPTION | 14 ++++++++++---- README.md | 2 ++ readme/README.ru.md | 2 ++ readme/README.zh_CN.md | 2 ++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/LICENSE-EXCEPTION b/LICENSE-EXCEPTION index dfc286bfb..cd6b10b5c 100644 --- a/LICENSE-EXCEPTION +++ b/LICENSE-EXCEPTION @@ -1,5 +1,5 @@ OneXray VPN Provider Exception -Version 1.1 +Version 1.2 1. Scope @@ -22,6 +22,11 @@ You may rely on this Exception only if all of the following apply: custom-configuration or subscription features, including the ability to import or use arbitrary third-party VPN or proxy configurations or subscription URLs. + d) Each customized client you distribute under closed-source terms + includes a clear, reasonably accessible acknowledgment in its user + interface that it is based on the OneXray project, together with a + link to https://github.com/YuanDevTeam/OneXray. An About, Credits or + Open-source Notices screen may be used for this acknowledgment. Configurations and subscriptions that you supply solely for access to your own VPN service are permitted. Branding the client for your service, @@ -29,9 +34,10 @@ distributing it only to your subscribers, or making your service the default does not satisfy these conditions if the client can also connect to third-party services. -For that purpose, you may use, copy, modify, build and distribute the -Covered Code and the resulting customized client under terms of your -choice, including proprietary, closed-source terms. You need not comply +For that purpose and subject to the conditions above, you may use, copy, +modify, build and distribute the Covered Code and the resulting +customized client under terms of your choice, including proprietary, +closed-source terms. You need not comply with GPLv3's conditions for the Covered Code in that permitted use, including its requirements to license the client or your modifications under GPLv3, disclose Corresponding Source or supply Installation diff --git a/README.md b/README.md index f82ed585f..463f7bbdb 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ VPN service providers may build and distribute dedicated, customized closed-sour To qualify, the client must not include general-purpose custom-configuration or subscription features, including the ability to import or use arbitrary third-party VPN or proxy configurations or subscription URLs. Configurations and subscriptions supplied by the provider solely for access to its own VPN service are permitted. Branding the client for the provider, distributing it only to the provider's subscribers, or making the provider's service the default is not sufficient if the client can also connect to third-party services. +Each closed-source client distributed under this exception must clearly acknowledge that it is based on the OneXray project and include a link to [OneXray](https://github.com/YuanDevTeam/OneXray) in a reasonably accessible part of its user interface, such as an About, Credits or Open-source Notices screen. + These conditions apply to eligibility for the exception. Other uses remain subject to the GPL or another applicable permission. Yuan Dev Team guarantees that it will not introduce direct third-party dependencies that require a qualifying customized client as a whole to be GPL-licensed or to disclose its source code under the GPL. Third-party licenses and their component-specific obligations still apply; the exception only covers rights Yuan Dev Team owns or is authorized to license. diff --git a/readme/README.ru.md b/readme/README.ru.md index 8bd33b060..6c96da073 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -110,6 +110,8 @@ OneXray распространяется под [GNU General Public License v3.0 Для применения исключения клиент не должен включать универсальные функции пользовательских конфигураций или подписок, в том числе возможность импортировать или использовать произвольные конфигурации сторонних VPN- или прокси-сервисов либо адреса их подписок. Конфигурации и подписки, предоставляемые провайдером исключительно для доступа к его собственному VPN-сервису, допускаются. Если клиент также позволяет подключаться к сторонним сервисам, одного собственного бренда, распространения только среди подписчиков провайдера или выбора его сервиса по умолчанию недостаточно. +Каждый клиент с закрытым исходным кодом, распространяемый на условиях этого исключения, должен содержать в легкодоступном разделе интерфейса, например «О приложении», «Благодарности» или «Лицензии открытого ПО», явное указание, что он основан на проекте OneXray, и ссылку на [проект OneXray](https://github.com/YuanDevTeam/OneXray). + Эти условия определяют применимость исключения. Для других способов использования действуют GPL или иное применимое разрешение. Yuan Dev Team гарантирует, что не будет добавлять прямые сторонние зависимости, требующие лицензировать соответствующий условиям исключения клиент в целом под GPL или раскрывать его исходный код по GPL. Лицензии сторонних компонентов и относящиеся к ним обязательства сохраняются; исключение охватывает только права, принадлежащие Yuan Dev Team или предоставленные ей для такого лицензирования. diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 9da8512c0..28a44ed40 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -110,6 +110,8 @@ VPN 服务提供商为自己的 VPN 服务打造并分发专用定制客户端 符合豁免条件的客户端不得包含通用自定义配置或通用订阅功能,包括导入或使用任意第三方 VPN、代理配置或订阅地址的能力。服务商提供的、仅用于接入其自有 VPN 服务的配置和订阅可以保留。若客户端仍可接入第三方服务,仅更换品牌、仅向自家订阅用户分发,或将自家服务设为默认,并不足以获得豁免。 +依此豁免分发的每个闭源客户端,必须在客户端界面中易于找到的位置(如“关于”“致谢”或“开源声明”页面)明确说明其基于 OneXray 项目,并提供 [OneXray 项目链接](https://github.com/YuanDevTeam/OneXray)。 + 以上条件仅用于确定是否适用豁免。其他用途仍须遵循 GPL 或另行适用的授权。 Yuan Dev Team 保证不引入要求符合豁免条件的自定义客户端整体按 GPL 授权或按 GPL 公开源码的直接第三方依赖。第三方许可证及其针对相应组件的义务仍然适用;豁免仅覆盖 Yuan Dev Team 拥有或有权授权的权利。 From 3ea61014af81ffe3af682b64af299ef4f5fad762 Mon Sep 17 00:00:00 2001 From: yiguo Date: Wed, 4 Nov 2026 23:34:13 +0800 Subject: [PATCH 56/60] refactor(ui): restore resource actions and remove retired flows --- docs/app.md | 10 +- docs/subscriptions-and-sharing.md | 4 + docs/validation.md | 2 + lib/l10n/app_en.arb | 69 --- lib/l10n/app_fa.arb | 19 - lib/l10n/app_ru.arb | 19 - lib/l10n/app_zh.arb | 19 - lib/l10n/app_zh_Hant.arb | 19 - lib/pages/advanced/controller.dart | 2 +- lib/pages/advanced/local_api/page.dart | 6 +- lib/pages/advanced/page.dart | 150 ------ lib/pages/advanced/tab_visibility.dart | 86 ---- lib/pages/advanced/tunnel/android/apps.dart | 5 +- .../tunnel/android/automation/page.dart | 3 +- lib/pages/advanced/tunnel/apple/page.dart | 1 - lib/pages/advanced/tunnel/apple/wifi.dart | 1 - lib/pages/advanced/tunnel/details.dart | 2 - lib/pages/advanced/tunnel/interface/page.dart | 1 - lib/pages/advanced/tunnel/widgets.dart | 6 +- lib/pages/advanced/xray/data_update/page.dart | 4 +- lib/pages/advanced/xray/diagnostics.dart | 9 +- lib/pages/advanced/xray/dialing/page.dart | 1 - lib/pages/advanced/xray/geodata/add.dart | 4 +- lib/pages/advanced/xray/geodata/detail.dart | 4 +- lib/pages/advanced/xray/geodata/page.dart | 6 +- lib/pages/advanced/xray/log/settings.dart | 4 +- lib/pages/advanced/xray/page.dart | 8 +- lib/pages/advanced/xray/ping/page.dart | 4 +- .../advanced/xray/runtime_code_view.dart | 2 +- lib/pages/connect/controller.dart | 231 +--------- lib/pages/connect/dialogs.dart | 160 ------- lib/pages/connect/json_editor/page.dart | 6 +- lib/pages/connect/page.dart | 9 +- .../connect/routing/custom/controller.dart | 100 ---- lib/pages/connect/routing/custom/page.dart | 10 +- .../connect/routing/custom/rule_page.dart | 2 +- .../connect/routing/custom/sections.dart | 5 - lib/pages/connect/routing/page.dart | 4 +- .../connect/routing/smart/exit_picker.dart | 3 +- lib/pages/connect/routing/smart/page.dart | 4 +- lib/pages/connect/routing/smart/regions.dart | 5 +- lib/pages/connect/view.dart | 12 +- lib/pages/main/advanced.dart | 8 +- lib/pages/main/navigation.dart | 1 - lib/pages/main/url.dart | 7 +- lib/pages/servers/catalog.dart | 3 - lib/pages/servers/controller.dart | 260 +---------- lib/pages/servers/details.dart | 33 +- lib/pages/servers/form_page.dart | 6 +- lib/pages/servers/import/page.dart | 4 +- lib/pages/servers/menus.dart | 70 --- lib/pages/servers/order/page.dart | 3 +- lib/pages/servers/page.dart | 31 +- lib/pages/servers/sources.dart | 203 --------- lib/pages/servers/view.dart | 410 ++++------------- lib/pages/settings/about/page.dart | 6 +- lib/pages/settings/app_icon/page.dart | 4 +- lib/pages/settings/backup/page.dart | 4 +- lib/pages/settings/desktop/page.dart | 2 - lib/pages/settings/language/page.dart | 4 +- lib/pages/settings/page.dart | 6 +- lib/pages/settings/theme/controller.dart | 53 --- lib/pages/settings/theme/page.dart | 82 ---- lib/pages/shared/widgets/page_app_bar.dart | 41 -- lib/pages/shared/widgets/settings_page.dart | 6 +- lib/service/connect/settings.dart | 3 - lib/service/servers/server.dart | 25 +- .../advanced/accessibility_layout_test.dart | 162 ++++--- .../pages/advanced/tunnel/root_page_test.dart | 3 +- test/pages/advanced/xray/dialing_test.dart | 1 - test/pages/advanced/xray/lan_proxy_test.dart | 1 - test/pages/advanced/xray/log_test.dart | 1 - test/pages/advanced/xray/uptime_test.dart | 135 ++++-- .../connect/configuration_sync_test.dart | 54 ++- test/pages/connect/controller_test.dart | 121 ++++- .../connect/routing/custom_layout_test.dart | 51 ++- .../routing/custom_page_parity_test.dart | 11 +- .../pages/connect/routing/custom_ui_test.dart | 95 ++-- .../routing_catalog_isolation_test.dart | 84 ++-- test/pages/connect/view_test.dart | 84 +++- test/pages/main/adaptive_shell_test.dart | 4 +- test/pages/main/page_app_bar_test.dart | 171 +++---- test/pages/servers/controller_test.dart | 130 ++---- .../servers/empty_source_details_test.dart | 42 +- test/pages/servers/provider_ui_test.dart | 2 +- .../restored_resource_actions_test.dart | 431 ++++++++++++++++++ test/pages/servers/sources_test.dart | 281 +++++------- test/pages/servers/view_test.dart | 189 ++++---- .../appearance_startup_update_test.dart | 68 +-- test/service/servers/server_test.dart | 3 +- 90 files changed, 1549 insertions(+), 2871 deletions(-) delete mode 100644 lib/pages/advanced/page.dart delete mode 100644 lib/pages/advanced/tab_visibility.dart delete mode 100644 lib/pages/settings/theme/controller.dart delete mode 100644 lib/pages/settings/theme/page.dart create mode 100644 test/pages/servers/restored_resource_actions_test.dart diff --git a/docs/app.md b/docs/app.md index 05bba15a2..30588655a 100644 --- a/docs/app.md +++ b/docs/app.md @@ -105,6 +105,8 @@ App 管理的普通模式选择自动、订阅、全部手动节点或固定节 配置类型、数量、节点链路和规则语义见 [Xray 配置](xray-configuration.md)。 页面展示原生连接状态、实时速率、本次上下行与连接时长;未知指标显示未知,不伪造零速率、历史图或单出口 IP。 本次会话为只读摘要,空库且没有进行中的连接时直接提供导入和恢复入口。 +“为什么这样连接”只解释实际运行快照,不预测尚未启动的计划。正常断开时不提供入口; +仍可断开的失败状态如有运行快照,继续按该会话的入口、最终出口和配置类型解释,不读取当前待用计划替代。 启停期间立即显示操作等待,不提前宣称已连接/断开;准备或连接阶段可取消,避免重复提交。 编辑正在使用的配置经确认重连;未启用资产或仅改名称不重连。 启动不等待连接前测速,也不要求已有成功延迟;活动、可读取的候选足量即可进入准备。 @@ -175,10 +177,11 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 [数据管理](data-management.md#数据显示顺序)及 [普通配置编译](xray-configuration.md#普通配置编译)。 普通节点显示 type=outbound、协议/传输/安全和延迟标签;成功延迟 ≤500 ms 为快、≤1000 ms 为慢,其余为可用。 未测与测速失败使用独立状态文案,不把失败诊断当作延迟数值或不可连接结论。 -提供方行显示 type=Remnawave,不展示普通延迟、不参加单 outbound 测速;来源组保持浏览及逐项固定选择入口。 +提供方行显示 type=Remnawave,不展示普通延迟、不参加单 outbound 测速;来源详情保持浏览及逐项固定选择入口。 原有自动选择在普通候选不足时可运行一份提供方配置,连接页按运行快照显示实际名称与提供方管理的路由; 断开后仍保留原选择意图,具体策略见 [配置合同](xray-configuration.md#普通配置编译)。 -完整配置可选择、收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。 +提供方完整配置可选择、收藏/取消收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。 +完整配置列表提供独立收藏筛选,列表与详情都可修改提供方收藏;个人 Raw 不支持收藏,不因切换资源标签继承普通节点的收藏筛选。 普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。 资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。 导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。 @@ -200,7 +203,8 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 订阅导入和刷新不预览、不二次确认;文件直接使用系统选择器,扫码仅 iOS/Android 显示。 套餐摘要和详情无数据时隐藏,不限制节点或连接;导入、双缓存、分享及套餐见 [订阅合同](subscriptions-and-sharing.md)。 -测速不锁定菜单;批量按钮可取消当前分组手动任务,不影响其它任务,队列边界见 [数据管理](data-management.md)。 +测速不锁定菜单;手动组与普通订阅来源详情的批量操作依次展示测速、取消、正在取消, +同一组在途任务不重复提交。取消只作用于本组手动任务,不停止 VPN、不影响其它组任务,队列边界见 [数据管理](data-management.md)。 手动单节点或 App 管理订阅分组在 VPN 已连接时提醒请求可能经过当前 VPN、延迟可能不准确,提供取消/继续; 每次操作只确认一次,多批次不重复。连接或断开中及仍可断开的失败状态同样提醒,普通准备/已断开直接测速。 等待确认不入队、不增加共享 loading,连续点击不叠弹窗;取消、关弹窗或离开页面不提交。 diff --git a/docs/subscriptions-and-sharing.md b/docs/subscriptions-and-sharing.md index 778318154..a2aa81288 100644 --- a/docs/subscriptions-and-sharing.md +++ b/docs/subscriptions-and-sharing.md @@ -23,6 +23,10 @@ App 下载统一默认使用 `v2rayN/ <完整 OneXray UA>`,包 更新保护准备中、待启动、运行中、固定选择、最终出口及收藏原行,保留它们的 ID 与配置原文,并插入全部新结果; 同名新旧条目允许并存,保护行不计入本轮导入数。保护解除后后续更新清理旧行,不原地覆盖运行配置。 普通活动节点进入自动测速队列,不等待测速;提供方完整配置不进入单 outbound 测速。 +普通节点和提供方完整配置均可在资源列表及详情收藏/取消收藏;完整配置的收藏筛选只显示提供方配置,不包含个人 Raw。 +收藏继续参与更新保护,取消后在下一次更新按既有引用规则清理,不修改当前运行或自动连接。 +订阅详情的批量测速显示本来源的进行/取消状态,同来源在途请求不重复提交;取消只停止本来源后续批次, +当前原生批次结束前保持等待,不停止 VPN、不取消其它来源或后台任务。 ### 面板与配置使用方式 diff --git a/docs/validation.md b/docs/validation.md index a64250f2f..99396903d 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -90,6 +90,8 @@ 使用真实布尔偏好关闭启动自动连接,现场始终不启动 VPN、不截图,以菜单/AX/窗口状态和日志记录。 - 搜索清除和零结果重置恢复列表,保留排序、收藏偏好;移动端空列表、空手动组和长列表均可进入来源管理。 服务器节点/完整配置/订阅三类计数和类型准确;打开资源或来源详情仅浏览,显式“使用”才提交连接选择。 + 提供方完整配置在列表与详情均可收藏/取消,旧收藏可见,完整配置收藏筛选与节点筛选独立;Raw 不增加收藏能力。 + 普通订阅详情覆盖测速、取消、正在取消及完成恢复;同来源不重复提交,取消不影响其它组请求或 VPN。 节点测速错误初始折叠,展开后可选择、复制原始诊断,后续成功移除错误,折叠不删除诊断。 主节点列表在无搜索、无收藏筛选且来源为全部来源或手动添加时可整理位置;订阅过滤下不提供手动整理。 单个非空手动组直接进入,多个非空组先选择完整范围;空组、单节点组和忙碌组不可选为整理范围。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cb8261b57..7d5ad9675 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -10,7 +10,6 @@ "tunnelSystemCapture": "System capture", "tunnelConnectionParameters": "Connection parameters", "tunnelCurrentPlatform": "Current platform", - "serversPageDescription": "Manage nodes, full configurations and subscription sources.", "subscriptionInformationTitle": "Subscription information", "shareContentTitle": "Share content", "settingsAppLanguage": "App language", @@ -19,9 +18,6 @@ "settingsManualConnection": "Connect manually", "settingsAppearanceAndLanguage": "Appearance & language", "settingsStartupAndSystem": "Startup & system", - "settingsOverviewDescription": "Appearance, startup and data on this device.", - "settingsDevicePreferenceDescription": "Changes apply only to this device.", - "backupPageDescription": "Move connection assets and VPN settings for this platform.", "pageTunnelDescription": "System capture, tunnel networking and platform policies.", "pageXrayDescription": "Core runtime, diagnostics and connection resources.", "pageRoutingDescription": "Choose traffic handling or manage routing profiles.", @@ -246,7 +242,6 @@ "buttonOK": "OK", "buttonCancel": "Cancel", "buttonOpenSettings": "Open system settings", - "buttonSave": "Save", "buttonSaveFailed": "Save failed", "settingsDefaultsRestored": "Defaults restored in the editor. Save to apply.", "@settingsDefaultsRestored": { @@ -1242,10 +1237,6 @@ "@prototypeChooseTrafficMethod": { "description": "Approved prototype copy: Choose a traffic method" }, - "prototypeTrafficMethodQuestion": "Which traffic should use the VPN?", - "@prototypeTrafficMethodQuestion": { - "description": "Approved prototype copy: Which traffic should use the VPN?" - }, "prototypeChooseNamedRoute": "Choose one named route", "@prototypeChooseNamedRoute": { "description": "Approved prototype copy: Choose one named route" @@ -1322,15 +1313,6 @@ "@prototypeCustomConnectionReason": { "description": "Approved prototype copy: Your custom rules decide which traffic uses the VPN, stays direct, or is blocked. Rules do not store server choices." }, - "prototypeNamedCustomConnectionReason": "{name} decides which traffic uses the VPN, stays direct, or is blocked. Rules do not store server choices.", - "@prototypeNamedCustomConnectionReason": { - "description": "Approved prototype copy: {name} decides which traffic uses the VPN, stays direct, or is blocked. Rules do not store server choices.", - "placeholders": { - "name": { - "type": "String" - } - } - }, "prototypeRunningEntriesReason": "This connection selected {entries}. Retesting does not change the current connection.", "@prototypeRunningEntriesReason": { "description": "Approved prototype copy: This connection selected {entries}. Retesting does not change the current connection.", @@ -1340,33 +1322,6 @@ } } }, - "prototypeMultipleEntriesReason": "OneXray selected {count} readable entry nodes. Cached successful delays only affect their order.", - "@prototypeMultipleEntriesReason": { - "description": "Approved prototype copy: OneXray selected {count} entry servers with the best speed and availability. New connections are distributed between them.", - "placeholders": { - "count": { - "type": "int" - } - } - }, - "prototypeFixedEntryReason": "You selected {server} directly.", - "@prototypeFixedEntryReason": { - "description": "Approved prototype copy: You selected {server} directly.", - "placeholders": { - "server": { - "type": "String" - } - } - }, - "prototypeAutomaticEntryReason": "Selected {server} using cached successful delays and a stable order. No test is required before connecting.", - "@prototypeAutomaticEntryReason": { - "description": "Approved prototype copy: {server} had the best recent speed and availability among eligible servers.", - "placeholders": { - "server": { - "type": "String" - } - } - }, "prototypeNoAnalyticsLocalLogs": "OneXray does not collect or upload analytics. Optional logs stay on this device.", "@prototypeNoAnalyticsLocalLogs": { "description": "Approved prototype copy: OneXray does not collect or upload analytics. Optional logs stay on this device." @@ -1811,10 +1766,6 @@ "@prototypeAppInformation": { "description": "Approved prototype copy: App information, documentation, and support." }, - "prototypeCrossPlatformXrayClient": "A cross-platform Xray client", - "@prototypeCrossPlatformXrayClient": { - "description": "Approved prototype copy: A cross-platform Xray client" - }, "prototypeAppVersion": "App version", "@prototypeAppVersion": { "description": "Approved prototype copy: App version" @@ -2680,10 +2631,6 @@ "@prototypeManageSources": { "description": "Approved prototype copy: Manage updates and sources" }, - "prototypeSourceUpdateGuard": "Updates replace the subscription only when at least one usable node is recognized.", - "@prototypeSourceUpdateGuard": { - "description": "Approved prototype copy: Updates replace the subscription only when at least one usable node is recognized." - }, "prototypeSourceUpdateHint": "Download and import usable nodes directly", "@prototypeSourceUpdateHint": { "description": "Approved prototype copy: Download and import usable nodes directly" @@ -2861,9 +2808,6 @@ "@buttonCancel": { "description": "Approved prototype copy: Cancel" }, - "@buttonSave": { - "description": "Approved prototype copy: Save" - }, "@sharePageCopyLink": { "description": "Approved prototype copy: Copy share link" }, @@ -2942,14 +2886,6 @@ "@prototypeShareServerHint": { "description": "Approved prototype copy: Share an importable server link" }, - "prototypeLocalOnly": "Local only", - "@prototypeLocalOnly": { - "description": "Approved prototype copy: Local only" - }, - "prototypeStoredOnThisDevice": "Stored on this device", - "@prototypeStoredOnThisDevice": { - "description": "Approved prototype copy: Stored on this device" - }, "prototypeUpdated": "Updated", "@prototypeUpdated": { "description": "Approved prototype copy: Updated" @@ -3099,7 +3035,6 @@ "providerRoutingNotice": "Uses provider routing. App routing resumes when you switch back to managed nodes.", "providerConfigurationCount": "{count, plural, =1 {1 configuration} other {{count} configurations}}", "@providerConfigurationCount": {"placeholders": {"count": {"type": "int"}}}, - "providerChooseConfiguration": "Choose configuration", "providerChooseConfigurationHint": "Select a configuration from this subscription to connect.", "providerViewConfiguration": "View JSON", "providerViewConfigurationHint": "View the original subscription JSON. Copy it to personal Raw to edit.", @@ -3207,7 +3142,6 @@ "connectionRememberedPlanUnavailable": "The previous plan is unavailable. Select a scope or node.", "clearConnectionResources": "Clear connection resources", "clearConnectionResourcesWarning": "Remove servers, subscriptions, routing configurations and saved connection choices. App preferences, permissions and the backup location are kept.", - "routingPageDescription": "Choose how traffic is routed, or edit your custom rules.", "routingStoredPreferenceNotice": "Complete configurations use their own routing. This selection applies when you return to ordinary servers.", "connectionRestoreOrdinary": "Use previous ordinary plan", "connectionPreparing": "Preparing connection", @@ -3243,7 +3177,6 @@ "xrayNotRunning": "Not running", "xrayLogsEnabled": "Recording enabled", "xrayLogsDisabled": "Recording disabled", - "tunnelDetailDescription": "View and manage how this device connects.", "pageXrayConfigurationDescription": "Manage configuration used by the core and connections.", "xrayLatencyTitle": "Latency probing", "xrayAutomaticSubscriptionUpdates": "Automatically update subscriptions", @@ -3260,11 +3193,9 @@ "routingNormalDescription": "Match rules in order; unmatched traffic uses VPN.", "routingOrderedRulesTitle": "Ordered rules", "routingEmptyRulesHint": "No rules yet. You can configure DNS and proxy connections before saving.", - "smartRoutingEditorDescription": "Edit direct, proxy, DNS, and traffic identification preferences.", "routingEntryCountLabel": "Entry node count", "routingSelectionModeLabel": "Selection method", "configurationTransferTitle": "Import, export, and share", - "routingAdvancedDescription": "Use App node slots to configure DNS and routing independently.", "configurationNameHint": "1–32 characters", "serverImportPageDescription": "Choose how to add subscriptions, nodes, or configurations.", "serverImportMethods": "Import method", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index eb099ff19..b0868d828 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -10,7 +10,6 @@ "tunnelSystemCapture": "کنترل سیستم", "tunnelConnectionParameters": "پارامترهای اتصال", "tunnelCurrentPlatform": "پلتفرم فعلی", - "serversPageDescription": "گره‌ها، پیکربندی‌های کامل و منابع اشتراک را مدیریت کنید.", "subscriptionInformationTitle": "اطلاعات اشتراک", "shareContentTitle": "محتوای اشتراک‌گذاری", "settingsAppLanguage": "زبان برنامه", @@ -19,9 +18,6 @@ "settingsManualConnection": "اتصال دستی", "settingsAppearanceAndLanguage": "ظاهر و زبان", "settingsStartupAndSystem": "راه‌اندازی و سیستم", - "settingsOverviewDescription": "ظاهر، راه‌اندازی و داده‌های این دستگاه.", - "settingsDevicePreferenceDescription": "تغییرات فقط در این دستگاه اعمال می‌شوند.", - "backupPageDescription": "انتقال منابع اتصال و تنظیمات VPN این پلتفرم.", "pageTunnelDescription": "کنترل سیستم، شبکه تونل و سیاست‌های اتصال پلتفرم.", "pageXrayDescription": "اجرای هسته، عیب‌یابی و منابع اتصال.", "pageRoutingDescription": "روش ترافیک را انتخاب کنید یا پیکربندی‌های مسیریابی را مدیریت کنید.", @@ -223,7 +219,6 @@ "buttonOK": "تأیید", "buttonCancel": "لغو", "buttonOpenSettings": "باز کردن تنظیمات سیستم", - "buttonSave": "ذخیره", "buttonSaveFailed": "ذخیره ناموفق بود", "settingsDefaultsRestored": "تنظیمات پیش‌فرض در ویرایشگر بازگردانده شد. برای اعمال، ذخیره کنید.", "buttonAddFailed": "افزودن ناموفق بود", @@ -510,7 +505,6 @@ "prototypeNoAvailableEntries": "هیچ سرور ورودی در دسترس نیست", "prototypeFinalExitEntryConflict": "خروجی نهایی نمی‌تواند هم‌زمان سرور ورودی باشد", "prototypeChooseTrafficMethod": "روش هدایت ترافیک را انتخاب کنید", - "prototypeTrafficMethodQuestion": "کدام ترافیک باید از VPN استفاده کند؟", "prototypeChooseNamedRoute": "یک طرح مسیریابی نام‌گذاری‌شده انتخاب کنید", "prototypeNoCustomRoutes": "هنوز طرح مسیریابی سفارشی وجود ندارد", "prototypeNewCustomRoute": "طرح مسیریابی سفارشی جدید", @@ -523,11 +517,7 @@ "prototypeSmartConnectionReason": "مسیریابی هوشمند، مناطق انتخاب‌شده را مستقیم نگه می‌دارد. ترافیک VPN از {entries} استفاده می‌کند.", "prototypeAllVpnConnectionReason": "همهٔ ترافیک اینترنت، به‌جز ترافیک ضروری سیستم، از {server} استفاده می‌کند.", "prototypeCustomConnectionReason": "قوانین سفارشی شما تعیین می‌کنند کدام ترافیک از VPN عبور کند، مستقیم متصل شود یا مسدود شود. انتخاب سرور در قوانین ذخیره نمی‌شود.", - "prototypeNamedCustomConnectionReason": "{name} تعیین می‌کند کدام ترافیک از VPN عبور کند، مستقیم متصل شود یا مسدود شود. انتخاب سرور در قوانین ذخیره نمی‌شود.", "prototypeRunningEntriesReason": "برای این اتصال، {entries} انتخاب شد. آزمایش دوباره، اتصال فعلی را تغییر نمی‌دهد.", - "prototypeMultipleEntriesReason": "OneXray تعداد {count} گرهٔ ورودی قابل‌خواندن را انتخاب کرد. تأخیرهای موفق ذخیره‌شده فقط ترتیب را تغییر می‌دهند.", - "prototypeFixedEntryReason": "شما {server} را مستقیماً انتخاب کردید.", - "prototypeAutomaticEntryReason": "{server} با تأخیرهای موفق ذخیره‌شده و ترتیب ثابت انتخاب شد. پیش از اتصال نیازی به آزمایش نیست.", "prototypeNoAnalyticsLocalLogs": "OneXray داده‌های تحلیلی را جمع‌آوری یا بارگذاری نمی‌کند. گزارش‌های اختیاری روی همین دستگاه می‌مانند.", "prototypeEditSubscription": "ویرایش اشتراک", "prototypeEditServer": "ویرایش سرور", @@ -631,7 +621,6 @@ "prototypeAbout": "درباره", "prototypeAboutOneXray": "دربارهٔ OneXray", "prototypeAppInformation": "اطلاعات برنامه، مستندات و پشتیبانی.", - "prototypeCrossPlatformXrayClient": "یک کارخواه چندسکویی Xray", "prototypeAppVersion": "نسخهٔ برنامه", "prototypeCheckAppUpdates": "بررسی به‌روزرسانی برنامه", "prototypeAboutUpdateAvailable": "دربارهٔ OneXray — به‌روزرسانی موجود است", @@ -842,7 +831,6 @@ "prototypeLocalCopySaved": "به‌عنوان سرور محلی ذخیره شد. به‌روزرسانی اشتراک آن را جایگزین نمی‌کند.", "prototypeUpdatesAndSources": "به‌روزرسانی‌ها و منابع", "prototypeManageSources": "مدیریت به‌روزرسانی‌ها و منابع", - "prototypeSourceUpdateGuard": "به‌روزرسانی فقط وقتی اشتراک را جایگزین می‌کند که حداقل یک گره قابل استفاده شناسایی شود.", "prototypeSourceUpdateHint": "دانلود و وارد کردن مستقیم گره‌های قابل استفاده", "prototypeNameRemoved": "{name} حذف شد", "prototypeDeletedServerSelectionNotice": "اگر این سرور اکنون انتخاب شده باشد یا به‌عنوان خروجی نهایی VPN استفاده شود، OneXray به انتخاب خودکار برمی‌گردد.", @@ -885,8 +873,6 @@ "prototypeAboutPrivacyNotice": "OneXray داده‌های استفاده، ترافیک یا مرور وب را جمع‌آوری یا بارگذاری نمی‌کند.", "prototypeEditServerHint": "ویرایش JSON خروجی Xray آن", "prototypeShareServerHint": "اشتراک‌گذاری پیوند قابل وارد کردن سرور", - "prototypeLocalOnly": "فقط محلی", - "prototypeStoredOnThisDevice": "ذخیره‌شده در همین دستگاه", "prototypeUpdated": "به‌روزرسانی شد", "prototypeEditSubscriptionHint": "تغییر نام، پیوند HTTPS یا کلیدهای Age آن", "prototypeShareSubscriptionHint": "اشتراک‌گذاری پیوند اشتراک آن", @@ -923,7 +909,6 @@ "providerConfigurationTitle": "پیکربندی ارائه‌دهنده", "providerRoutingNotice": "از مسیریابی ارائه‌دهنده استفاده می‌شود. با بازگشت به گره‌های عادی، تنظیمات برنامه دوباره اعمال می‌شوند.", "providerConfigurationCount": "{count} پیکربندی", - "providerChooseConfiguration": "انتخاب پیکربندی", "providerChooseConfigurationHint": "برای اتصال، یک پیکربندی از این اشتراک انتخاب کنید.", "providerViewConfiguration": "نمایش JSON", "providerViewConfigurationHint": "JSON اصلی اشتراک را ببینید. برای ویرایش، آن را به Raw شخصی کپی کنید.", @@ -1026,7 +1011,6 @@ "connectionRememberedPlanUnavailable": "برنامهٔ قبلی در دسترس نیست. یک محدوده یا گره انتخاب کنید.", "clearConnectionResources": "پاک کردن منابع اتصال", "clearConnectionResourcesWarning": "سرورها، اشتراک‌ها، پیکربندی‌های مسیر و انتخاب‌های اتصال حذف می‌شوند. تنظیمات برنامه، مجوزها و محل پشتیبان حفظ می‌شوند.", - "routingPageDescription": "روش مسیریابی ترافیک را انتخاب کنید یا قوانین خود را ویرایش کنید.", "routingStoredPreferenceNotice": "پیکربندی‌های کامل مسیرهای خود را دارند. این انتخاب پس از بازگشت به گره‌های عادی اعمال می‌شود.", "connectionRestoreOrdinary": "استفاده از برنامهٔ عادی قبلی", "connectionPreparing": "در حال آماده‌سازی اتصال", @@ -1062,7 +1046,6 @@ "xrayNotRunning": "در حال اجرا نیست", "xrayLogsEnabled": "ثبت رویداد فعال است", "xrayLogsDisabled": "ثبت رویداد غیرفعال است", - "tunnelDetailDescription": "روش اتصال این دستگاه را مشاهده و مدیریت کنید.", "pageXrayConfigurationDescription": "پیکربندی مورد استفاده هسته و اتصال‌ها را مدیریت کنید.", "xrayLatencyTitle": "بررسی تأخیر", "xrayAutomaticSubscriptionUpdates": "به‌روزرسانی خودکار اشتراک‌ها", @@ -1079,11 +1062,9 @@ "routingNormalDescription": "قوانین به‌ترتیب بررسی می‌شوند؛ ترافیک بدون تطبیق از VPN استفاده می‌کند.", "routingOrderedRulesTitle": "قوانین مرتب‌شده", "routingEmptyRulesHint": "هنوز قانونی وجود ندارد. می‌توانید پیش از ذخیره، DNS و اتصال پروکسی را تنظیم کنید.", - "smartRoutingEditorDescription": "تنظیمات اتصال مستقیم، پروکسی، DNS و شناسایی ترافیک را ویرایش کنید.", "routingEntryCountLabel": "تعداد گره‌های ورودی", "routingSelectionModeLabel": "روش انتخاب", "configurationTransferTitle": "وارد کردن، صادر کردن و اشتراک‌گذاری", - "routingAdvancedDescription": "با جایگاه‌های گره برنامه، DNS و مسیریابی را جداگانه تنظیم کنید.", "configurationNameHint": "۱ تا ۳۲ نویسه", "serverImportPageDescription": "روش افزودن اشتراک‌ها، گره‌ها یا پیکربندی‌ها را انتخاب کنید.", "serverImportMethods": "روش وارد کردن", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5bcbff03d..5d2434537 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -10,7 +10,6 @@ "tunnelSystemCapture": "Системный VPN", "tunnelConnectionParameters": "Параметры подключения", "tunnelCurrentPlatform": "Текущая платформа", - "serversPageDescription": "Управляйте узлами, полными конфигурациями и источниками подписок.", "subscriptionInformationTitle": "Информация о подписке", "shareContentTitle": "Содержимое для отправки", "settingsAppLanguage": "Язык приложения", @@ -19,9 +18,6 @@ "settingsManualConnection": "Подключение вручную", "settingsAppearanceAndLanguage": "Внешний вид и язык", "settingsStartupAndSystem": "Запуск и система", - "settingsOverviewDescription": "Внешний вид, запуск и данные этого устройства.", - "settingsDevicePreferenceDescription": "Изменения применяются только к этому устройству.", - "backupPageDescription": "Перенос ресурсов подключения и настроек VPN этой платформы.", "pageTunnelDescription": "Системный VPN, сеть туннеля и правила платформы.", "pageXrayDescription": "Работа ядра, диагностика и ресурсы подключения.", "pageRoutingDescription": "Выберите обработку трафика или управляйте профилями маршрутизации.", @@ -223,7 +219,6 @@ "buttonOK": "OK", "buttonCancel": "Отмена", "buttonOpenSettings": "Открыть настройки системы", - "buttonSave": "Сохранить", "buttonSaveFailed": "Сохранить ошибка", "settingsDefaultsRestored": "В редакторе восстановлены настройки по умолчанию. Сохраните, чтобы применить.", "buttonAddFailed": "Добавить ошибка", @@ -510,7 +505,6 @@ "prototypeNoAvailableEntries": "Нет доступных входных узлов", "prototypeFinalExitEntryConflict": "Конечный выход не может одновременно быть входным узлом", "prototypeChooseTrafficMethod": "Выберите маршрутизацию трафика", - "prototypeTrafficMethodQuestion": "Какой трафик должен идти через VPN?", "prototypeChooseNamedRoute": "Выберите схему маршрутизации", "prototypeNoCustomRoutes": "Пользовательских маршрутов пока нет", "prototypeNewCustomRoute": "Новый маршрут", @@ -523,11 +517,7 @@ "prototypeSmartConnectionReason": "Умная маршрутизация оставляет выбранные регионы напрямую. Трафик VPN проходит через {entries}.", "prototypeAllVpnConnectionReason": "Весь интернет-трафик проходит через {server}, кроме обязательных системных исключений.", "prototypeCustomConnectionReason": "Ваши правила определяют, какой трафик идёт через VPN, напрямую или блокируется. Выбор серверов в правилах не сохраняется.", - "prototypeNamedCustomConnectionReason": "{name} определяет, какой трафик идёт через VPN, напрямую или блокируется. Правила не сохраняют выбор серверов.", "prototypeRunningEntriesReason": "Для этого подключения выбраны {entries}. Повторная проверка не меняет текущее подключение.", - "prototypeMultipleEntriesReason": "OneXray выбрал {count} доступных для чтения входных узлов. Сохранённые успешные задержки влияют только на порядок.", - "prototypeFixedEntryReason": "Вы выбрали сервер {server} вручную.", - "prototypeAutomaticEntryReason": "{server} выбран по сохранённым успешным задержкам и стабильному порядку. Проверка перед подключением не требуется.", "prototypeNoAnalyticsLocalLogs": "OneXray не собирает и не передаёт аналитику. При включении журналы хранятся на этом устройстве.", "prototypeEditSubscription": "Изменить подписку", "prototypeEditServer": "Изменить сервер", @@ -631,7 +621,6 @@ "prototypeAbout": "О приложении", "prototypeAboutOneXray": "О OneXray", "prototypeAppInformation": "Информация о приложении, документация и поддержка.", - "prototypeCrossPlatformXrayClient": "Кроссплатформенный клиент Xray", "prototypeAppVersion": "Версия приложения", "prototypeCheckAppUpdates": "Проверить обновления приложения", "prototypeAboutUpdateAvailable": "О OneXray — доступно обновление", @@ -842,7 +831,6 @@ "prototypeLocalCopySaved": "Сохранена локальная копия сервера. Обновления подписки её не заменят.", "prototypeUpdatesAndSources": "Обновления и источники", "prototypeManageSources": "Обновления и источники", - "prototypeSourceUpdateGuard": "Подписка заменяется при обновлении, только если распознан хотя бы один пригодный узел.", "prototypeSourceUpdateHint": "Загрузить и сразу импортировать пригодные узлы", "prototypeNameRemoved": "Удалено: {name}", "prototypeDeletedServerSelectionNotice": "Если этот сервер выбран для подключения или конечного выхода VPN, OneXray вернётся к автовыбору.", @@ -885,8 +873,6 @@ "prototypeAboutPrivacyNotice": "OneXray не собирает и не передаёт сведения об использовании, трафике или посещённых сайтах.", "prototypeEditServerHint": "Изменить JSON исходящего подключения Xray", "prototypeShareServerHint": "Передать ссылку для импорта сервера", - "prototypeLocalOnly": "Только локально", - "prototypeStoredOnThisDevice": "Хранится на устройстве", "prototypeUpdated": "Обновлено", "prototypeEditSubscriptionHint": "Изменить название, ссылку HTTPS или ключи Age", "prototypeShareSubscriptionHint": "Передать ссылку подписки", @@ -923,7 +909,6 @@ "providerConfigurationTitle": "Конфигурация провайдера", "providerRoutingNotice": "Используется маршрутизация провайдера. Настройки приложения вернутся при выборе обычных узлов.", "providerConfigurationCount": "{count, plural, one {{count} конфигурация} few {{count} конфигурации} many {{count} конфигураций} other {{count} конфигурации}}", - "providerChooseConfiguration": "Выбрать конфигурацию", "providerChooseConfigurationHint": "Выберите одну конфигурацию этой подписки для подключения.", "providerViewConfiguration": "Просмотреть JSON", "providerViewConfigurationHint": "Просмотр исходного JSON подписки. Для редактирования создайте личную копию Raw.", @@ -1026,7 +1011,6 @@ "connectionRememberedPlanUnavailable": "Предыдущий план недоступен. Выберите область или узел.", "clearConnectionResources": "Удалить ресурсы подключения", "clearConnectionResourcesWarning": "Удалить серверы, подписки, маршруты и сохранённый выбор подключения. Настройки приложения, разрешения и расположение резервной копии сохранятся.", - "routingPageDescription": "Выберите способ маршрутизации или измените свои правила.", "routingStoredPreferenceNotice": "Полные конфигурации используют собственные маршруты. Этот выбор применяется после возврата к обычным узлам.", "connectionRestoreOrdinary": "Использовать предыдущий обычный план", "connectionPreparing": "Подготовка подключения", @@ -1062,7 +1046,6 @@ "xrayNotRunning": "Не запущено", "xrayLogsEnabled": "Запись включена", "xrayLogsDisabled": "Запись выключена", - "tunnelDetailDescription": "Просматривайте и настраивайте подключение этого устройства.", "pageXrayConfigurationDescription": "Настройка параметров ядра и подключений.", "xrayLatencyTitle": "Проверка задержки", "xrayAutomaticSubscriptionUpdates": "Автоматически обновлять подписки", @@ -1079,11 +1062,9 @@ "routingNormalDescription": "Правила проверяются по порядку; остальной трафик идёт через VPN.", "routingOrderedRulesTitle": "Упорядоченные правила", "routingEmptyRulesHint": "Правил пока нет. Перед сохранением можно настроить DNS и прокси.", - "smartRoutingEditorDescription": "Настройка прямого подключения, прокси, DNS и распознавания трафика.", "routingEntryCountLabel": "Число входных узлов", "routingSelectionModeLabel": "Метод выбора", "configurationTransferTitle": "Импорт, экспорт и обмен", - "routingAdvancedDescription": "Независимо настройте DNS и маршрутизацию с узлами приложения.", "configurationNameHint": "1–32 символа", "serverImportPageDescription": "Выберите способ добавления подписок, узлов или конфигураций.", "serverImportMethods": "Способ импорта", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 80a3ecd5c..3bfba03b7 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -10,7 +10,6 @@ "tunnelSystemCapture": "系统接管", "tunnelConnectionParameters": "连接参数", "tunnelCurrentPlatform": "当前平台", - "serversPageDescription": "管理节点、完整配置与订阅来源。", "subscriptionInformationTitle": "订阅信息", "shareContentTitle": "分享内容", "settingsAppLanguage": "App 语言", @@ -19,9 +18,6 @@ "settingsManualConnection": "手动连接", "settingsAppearanceAndLanguage": "外观与语言", "settingsStartupAndSystem": "启动与系统", - "settingsOverviewDescription": "外观、启动方式与此设备的数据。", - "settingsDevicePreferenceDescription": "修改仅应用于此设备。", - "backupPageDescription": "迁移连接资产与当前平台的 VPN 设置。", "pageTunnelDescription": "系统接管、隧道网络与平台连接策略。", "pageXrayDescription": "内核运行、诊断与连接资源。", "pageRoutingDescription": "选择流量方式,或独立管理路由配置。", @@ -223,7 +219,6 @@ "buttonOK": "确定", "buttonCancel": "取消", "buttonOpenSettings": "前往系统设置", - "buttonSave": "保存", "buttonSaveFailed": "保存失败", "settingsDefaultsRestored": "已恢复默认设置,请保存以应用。", "buttonAddFailed": "添加失败", @@ -510,7 +505,6 @@ "prototypeNoAvailableEntries": "没有可用的接入节点", "prototypeFinalExitEntryConflict": "最终出口不能同时作为接入服务器。", "prototypeChooseTrafficMethod": "选择流量方式", - "prototypeTrafficMethodQuestion": "哪些流量需要使用 VPN?", "prototypeChooseNamedRoute": "选择一个具名路由方案", "prototypeNoCustomRoutes": "还没有自定义路由", "prototypeNewCustomRoute": "新建自定义路由", @@ -523,11 +517,7 @@ "prototypeSmartConnectionReason": "智能路由让所选地区保持直连;VPN 流量通过 {entries}。", "prototypeAllVpnConnectionReason": "除系统必要流量外,所有互联网流量都通过 {server}。", "prototypeCustomConnectionReason": "自定义规则决定哪些流量使用 VPN、保持直连或被阻止;规则不保存服务器选择。", - "prototypeNamedCustomConnectionReason": "{name}决定哪些流量使用 VPN、保持直连或被阻止;规则不保存服务器选择。", "prototypeRunningEntriesReason": "本次连接选用了 {entries}。重新测速不会改变当前连接。", - "prototypeMultipleEntriesReason": "OneXray 选择了 {count} 个可读取的接入节点,已有成功延迟仅用于排序。", - "prototypeFixedEntryReason": "你直接选择了 {server}。", - "prototypeAutomaticEntryReason": "按已有成功延迟和稳定顺序选择了 {server},启动前不要求测速。", "prototypeNoAnalyticsLocalLogs": "OneXray 不收集或上传分析数据;可选日志只保留在本设备。", "prototypeEditSubscription": "编辑订阅", "prototypeEditServer": "编辑节点", @@ -631,7 +621,6 @@ "prototypeAbout": "关于", "prototypeAboutOneXray": "关于 OneXray", "prototypeAppInformation": "版本信息、使用文档与支持。", - "prototypeCrossPlatformXrayClient": "跨平台 Xray 客户端", "prototypeAppVersion": "App 版本", "prototypeCheckAppUpdates": "检查 App 更新", "prototypeAboutUpdateAvailable": "关于 OneXray,有可用更新", @@ -842,7 +831,6 @@ "prototypeLocalCopySaved": "已另存为本地节点,不受订阅更新影响。", "prototypeUpdatesAndSources": "更新与来源", "prototypeManageSources": "管理更新与来源", - "prototypeSourceUpdateGuard": "更新时识别到至少 1 个可用节点才会覆盖,否则保留原节点。", "prototypeSourceUpdateHint": "下载并直接导入可用节点", "prototypeNameRemoved": "{name} 已移除", "prototypeDeletedServerSelectionNotice": "如果该节点是当前位置或 VPN 最终出口,OneXray 将回退到自动选择。", @@ -885,8 +873,6 @@ "prototypeAboutPrivacyNotice": "OneXray 不收集或上传使用情况、流量或浏览数据。", "prototypeEditServerHint": "编辑 Xray outbound JSON", "prototypeShareServerHint": "分享可导入的节点链接", - "prototypeLocalOnly": "仅本地", - "prototypeStoredOnThisDevice": "仅存储在本机", "prototypeUpdated": "已更新", "prototypeEditSubscriptionHint": "修改名称、HTTPS 链接或 Age 密钥", "prototypeShareSubscriptionHint": "分享订阅链接", @@ -923,7 +909,6 @@ "providerConfigurationTitle": "提供方配置", "providerRoutingNotice": "使用提供方路由,切回普通节点后恢复 App 路由设置。", "providerConfigurationCount": "{count} 份配置", - "providerChooseConfiguration": "选择配置", "providerChooseConfigurationHint": "请选择此订阅中的某一份配置进行连接。", "providerViewConfiguration": "查看 JSON", "providerViewConfigurationHint": "查看订阅中的原始 JSON。需要编辑时,复制为个人 Raw。", @@ -1026,7 +1011,6 @@ "connectionRememberedPlanUnavailable": "上次的连接计划已不可用,请重新选择范围或节点。", "clearConnectionResources": "清除连接资源", "clearConnectionResourcesWarning": "删除服务器、订阅、路由配置和连接选择,保留 App 偏好、权限和备份位置。", - "routingPageDescription": "选择流量处理方式,或编辑自定义规则。", "routingStoredPreferenceNotice": "完整配置使用自带路由。这里的选择将在切换回普通节点后生效。", "connectionRestoreOrdinary": "使用上次普通连接计划", "connectionPreparing": "正在准备连接", @@ -1062,7 +1046,6 @@ "xrayNotRunning": "尚未运行", "xrayLogsEnabled": "记录已开启", "xrayLogsDisabled": "记录已关闭", - "tunnelDetailDescription": "查看并管理此设备的连接方式。", "pageXrayConfigurationDescription": "管理内核与连接使用的配置。", "xrayLatencyTitle": "延迟探测", "xrayAutomaticSubscriptionUpdates": "自动更新订阅", @@ -1079,11 +1062,9 @@ "routingNormalDescription": "按顺序匹配规则,未匹配流量使用 VPN。", "routingOrderedRulesTitle": "有序规则", "routingEmptyRulesHint": "还没有规则,可先配置 DNS 与代理连接后保存。", - "smartRoutingEditorDescription": "编辑直连、代理、DNS 与流量识别偏好。", "routingEntryCountLabel": "接入节点数量", "routingSelectionModeLabel": "选路方式", "configurationTransferTitle": "导入、导出与分享", - "routingAdvancedDescription": "使用 App 节点槽,独立配置 DNS 与路由。", "configurationNameHint": "1–32 个字符", "serverImportPageDescription": "选择一种方式添加订阅、节点或配置。", "serverImportMethods": "导入方式", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 1f0a2bc25..06fc43901 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -10,7 +10,6 @@ "tunnelSystemCapture": "系統接管", "tunnelConnectionParameters": "連線參數", "tunnelCurrentPlatform": "目前平台", - "serversPageDescription": "管理節點、完整設定與訂閱來源。", "subscriptionInformationTitle": "訂閱資訊", "shareContentTitle": "分享內容", "settingsAppLanguage": "App 語言", @@ -19,9 +18,6 @@ "settingsManualConnection": "手動連線", "settingsAppearanceAndLanguage": "外觀與語言", "settingsStartupAndSystem": "啟動與系統", - "settingsOverviewDescription": "外觀、啟動方式與此裝置的資料。", - "settingsDevicePreferenceDescription": "修改僅套用於此裝置。", - "backupPageDescription": "遷移連線資產與目前平台的 VPN 設定。", "pageTunnelDescription": "系統接管、通道網路與平台連線策略。", "pageXrayDescription": "核心執行、診斷與連線資源。", "pageRoutingDescription": "選擇流量方式,或獨立管理路由設定。", @@ -223,7 +219,6 @@ "buttonOK": "確定", "buttonCancel": "取消", "buttonOpenSettings": "前往系統設定", - "buttonSave": "儲存", "buttonSaveFailed": "保存失敗", "settingsDefaultsRestored": "已還原預設設定,請儲存以套用。", "buttonAddFailed": "添加失敗", @@ -510,7 +505,6 @@ "prototypeNoAvailableEntries": "沒有可用的接入節點", "prototypeFinalExitEntryConflict": "最終出口不能同時作為接入伺服器。", "prototypeChooseTrafficMethod": "選擇流量方式", - "prototypeTrafficMethodQuestion": "哪些流量需要使用 VPN?", "prototypeChooseNamedRoute": "選擇一個具名路由方案", "prototypeNoCustomRoutes": "還沒有自訂路由", "prototypeNewCustomRoute": "新增自訂路由", @@ -523,11 +517,7 @@ "prototypeSmartConnectionReason": "智慧路由讓所選地區保持直連;VPN 流量通過 {entries}。", "prototypeAllVpnConnectionReason": "除系統必要流量外,所有網際網路流量都通過 {server}。", "prototypeCustomConnectionReason": "自訂規則決定哪些流量使用 VPN、保持直連或被封鎖;規則不儲存伺服器選擇。", - "prototypeNamedCustomConnectionReason": "{name} 決定哪些流量使用 VPN、保持直連或遭到封鎖;規則不儲存伺服器選擇。", "prototypeRunningEntriesReason": "本次連線選用了 {entries}。重新測速不會改變目前連線。", - "prototypeMultipleEntriesReason": "OneXray 選擇了 {count} 個可讀取的接入節點,已有成功延遲僅用於排序。", - "prototypeFixedEntryReason": "你直接選擇了 {server}。", - "prototypeAutomaticEntryReason": "依已有成功延遲和穩定順序選擇了 {server},啟動前不要求測速。", "prototypeNoAnalyticsLocalLogs": "OneXray 不收集或上傳分析資料;選用記錄只保留在本裝置。", "prototypeEditSubscription": "編輯訂閱", "prototypeEditServer": "編輯節點", @@ -631,7 +621,6 @@ "prototypeAbout": "關於", "prototypeAboutOneXray": "關於 OneXray", "prototypeAppInformation": "版本資訊、使用文件與支援。", - "prototypeCrossPlatformXrayClient": "跨平台 Xray 用戶端", "prototypeAppVersion": "App 版本", "prototypeCheckAppUpdates": "檢查 App 更新", "prototypeAboutUpdateAvailable": "關於 OneXray,有可用更新", @@ -842,7 +831,6 @@ "prototypeLocalCopySaved": "已另存為本機節點,不受訂閱更新影響。", "prototypeUpdatesAndSources": "更新與來源", "prototypeManageSources": "管理更新與來源", - "prototypeSourceUpdateGuard": "更新時辨識到至少 1 個可用節點才會覆蓋,否則保留原節點。", "prototypeSourceUpdateHint": "下載並直接匯入可用節點", "prototypeNameRemoved": "{name} 已移除", "prototypeDeletedServerSelectionNotice": "如果該節點是目前位置或 VPN 最終出口,OneXray 將回退到自動選擇。", @@ -885,8 +873,6 @@ "prototypeAboutPrivacyNotice": "OneXray 不收集或上傳使用情況、流量或瀏覽資料。", "prototypeEditServerHint": "編輯 Xray outbound JSON", "prototypeShareServerHint": "分享可匯入的節點連結", - "prototypeLocalOnly": "僅本機", - "prototypeStoredOnThisDevice": "僅儲存在本機", "prototypeUpdated": "已更新", "prototypeEditSubscriptionHint": "修改名稱、HTTPS 連結或 Age 金鑰", "prototypeShareSubscriptionHint": "分享訂閱連結", @@ -923,7 +909,6 @@ "providerConfigurationTitle": "提供方設定", "providerRoutingNotice": "使用提供方路由,切回一般節點後恢復 App 路由設定。", "providerConfigurationCount": "{count} 份設定", - "providerChooseConfiguration": "選擇設定", "providerChooseConfigurationHint": "請選擇此訂閱中的某一份設定進行連線。", "providerViewConfiguration": "檢視 JSON", "providerViewConfigurationHint": "檢視訂閱中的原始 JSON。需要編輯時,複製為個人 Raw。", @@ -1026,7 +1011,6 @@ "connectionRememberedPlanUnavailable": "上次的連線計畫已無法使用,請重新選擇範圍或節點。", "clearConnectionResources": "清除連線資源", "clearConnectionResourcesWarning": "刪除伺服器、訂閱、路由設定和連線選擇,保留 App 偏好、權限和備份位置。", - "routingPageDescription": "選擇流量處理方式,或編輯自訂規則。", "routingStoredPreferenceNotice": "完整設定使用自帶路由。這裡的選擇將在切換回普通節點後生效。", "connectionRestoreOrdinary": "使用上次一般連線計畫", "connectionPreparing": "正在準備連線", @@ -1062,7 +1046,6 @@ "xrayNotRunning": "尚未執行", "xrayLogsEnabled": "記錄已開啟", "xrayLogsDisabled": "記錄已關閉", - "tunnelDetailDescription": "檢視並管理此裝置的連線方式。", "pageXrayConfigurationDescription": "管理核心與連線使用的設定。", "xrayLatencyTitle": "延遲探測", "xrayAutomaticSubscriptionUpdates": "自動更新訂閱", @@ -1079,11 +1062,9 @@ "routingNormalDescription": "依序比對規則,未符合的流量使用 VPN。", "routingOrderedRulesTitle": "有序規則", "routingEmptyRulesHint": "尚未設定規則,可先設定 DNS 與代理連線再儲存。", - "smartRoutingEditorDescription": "編輯直連、代理、DNS 與流量識別偏好。", "routingEntryCountLabel": "接入節點數量", "routingSelectionModeLabel": "選路方式", "configurationTransferTitle": "匯入、匯出與分享", - "routingAdvancedDescription": "使用 App 節點槽,獨立設定 DNS 與路由。", "configurationNameHint": "1–32 個字元", "serverImportPageDescription": "選擇一種方式新增訂閱、節點或設定。", "serverImportMethods": "匯入方式", diff --git a/lib/pages/advanced/controller.dart b/lib/pages/advanced/controller.dart index 047bed1db..a0cf0164f 100644 --- a/lib/pages/advanced/controller.dart +++ b/lib/pages/advanced/controller.dart @@ -27,7 +27,7 @@ class AdvancedPageState { }); } -/// Runtime facts shared by the Advanced tabs; platform edits use their own draft. +/// Runtime facts shared by Xray pages; platform edits use their own draft. class AdvancedController extends PageCubit with WidgetsBindingObserver { final ConnectionCoordinator coordinator; diff --git a/lib/pages/advanced/local_api/page.dart b/lib/pages/advanced/local_api/page.dart index 0280dad72..1ea7a996a 100644 --- a/lib/pages/advanced/local_api/page.dart +++ b/lib/pages/advanced/local_api/page.dart @@ -33,11 +33,7 @@ class LocalApiPage extends StatelessWidget { ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l.localApiTitle), - description: l.localApiSummary, - ), + appBar: PageAppBar(title: Text(l.localApiTitle)), bottomNavigationBar: PageActionBar( maxWidth: AppLayout.advancedMaxWidth, expandDesktop: false, diff --git a/lib/pages/advanced/page.dart b/lib/pages/advanced/page.dart deleted file mode 100644 index 74ff0e2a5..000000000 --- a/lib/pages/advanced/page.dart +++ /dev/null @@ -1,150 +0,0 @@ -import 'dart:math' as math; - -import 'package:material_ui/material_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/advanced/controller.dart'; -import 'package:onexray/pages/advanced/tab_visibility.dart'; -import 'package:onexray/pages/advanced/tunnel/controller.dart'; -import 'package:onexray/pages/advanced/tunnel/page.dart'; -import 'package:onexray/pages/advanced/tunnel/widgets.dart'; -import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; -import 'package:onexray/pages/theme/layout.dart'; -import 'package:onexray/pages/shared/widgets/setting_row.dart'; -import 'package:onexray/pages/shared/widgets/settings_page.dart'; - -class AdvancedPage extends StatelessWidget { - final WidgetBuilder? xrayBuilder; - final OpenTunnelPage? openTunnel; - const AdvancedPage({super.key, this.xrayBuilder, this.openTunnel}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => AdvancedController(), - child: AdvancedPageView( - tunnel: VpnTunnelPane(openTunnel: openTunnel), - xray: Builder(builder: xrayBuilder ?? _runtimeSummary), - ), - ); - } - - Widget _runtimeSummary(BuildContext context) => - BlocBuilder( - builder: (context, state) { - final l = AppLocalizations.of(context)!; - final controller = context.read(); - return AdvancedTabVisibility( - tabIndex: 1, - onChanged: controller.setVisible, - child: SettingsPageScroll( - child: SettingSection( - title: l.prototypeRuntimeStatus, - children: [ - SettingRow( - title: l.prototypeXrayCore, - value: controller.statusLabel(l), - leading: const Icon(LucideIcons.activity), - ), - PolicyValueRow( - title: l.prototypeVersion, - value: state.xrayVersion, - ), - PolicyValueRow(title: l.prototypeUptime, value: state.uptime), - ], - ), - ), - ); - }, - ); -} - -/// The fixed header and tabs share the same measured text space as their body. -class AdvancedPageView extends StatelessWidget { - final Widget tunnel; - final Widget xray; - - const AdvancedPageView({super.key, required this.tunnel, required this.xray}); - - @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final l = AppLocalizations.of(context)!; - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final padding = mobile ? AppSpacing.mobilePage : AppSpacing.page; - final theme = Theme.of(context); - final selectedStyle = theme.textTheme.titleSmall!.merge( - theme.tabBarTheme.labelStyle, - ); - final unselectedStyle = theme.textTheme.titleSmall!.merge( - theme.tabBarTheme.unselectedLabelStyle, - ); - final labels = [l.prototypeVpnTunnel, l.prototypeXrayRuntimeDiagnostics]; - final labelWidth = mobile - ? math.max(1.0, (constraints.maxWidth - padding * 2) / 2 - 24) - : double.infinity; - final painters = []; - for (final label in labels) { - for (final style in [selectedStyle, unselectedStyle]) { - final painter = TextPainter( - text: TextSpan(text: label, style: style), - textAlign: TextAlign.center, - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - locale: Localizations.localeOf(context), - )..layout(); - painters.add(painter); - } - } - // Keep tab labels on one line; scroll when either needs more than a cell. - final scrollable = - !mobile || painters.any((painter) => painter.width > labelWidth); - var tabHeight = 46.0; - for (final painter in painters) { - tabHeight = math.max(tabHeight, painter.height + 20); - painter.dispose(); - } - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: PageAppBar( - title: Text(l.prototypeAdvanced), - bottom: TabBar( - isScrollable: scrollable, - tabAlignment: scrollable ? TabAlignment.start : TabAlignment.fill, - padding: EdgeInsets.symmetric(horizontal: padding), - labelPadding: const EdgeInsets.symmetric(horizontal: 12), - labelStyle: selectedStyle, - unselectedLabelStyle: unselectedStyle, - tabs: [ - for (final label in labels) - Tab( - height: tabHeight, - child: Builder( - builder: (context) => Text( - label, - textAlign: TextAlign.center, - softWrap: false, - // TabBar merges its own defaults into the label style. - // Keep the measured metrics and inherit only its color. - style: selectedStyle.copyWith( - inherit: false, - color: DefaultTextStyle.of(context).style.color, - ), - ), - ), - ), - ], - ), - ), - body: SafeArea( - top: false, - child: TabBarView(children: [tunnel, xray]), - ), - ), - ); - }, - ); -} diff --git a/lib/pages/advanced/tab_visibility.dart b/lib/pages/advanced/tab_visibility.dart deleted file mode 100644 index d41c41860..000000000 --- a/lib/pages/advanced/tab_visibility.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:material_ui/material_ui.dart'; - -/// Reports visibility for a page inside an Advanced secondary tab. -/// -/// The native [TabController] is navigation state. Page business state remains -/// in Bloc; this widget only translates tab and route lifecycle into a boolean. -class AdvancedTabVisibility extends StatefulWidget { - const AdvancedTabVisibility({ - super.key, - required this.tabIndex, - required this.onChanged, - required this.child, - }); - - final int tabIndex; - final ValueChanged onChanged; - final Widget child; - - @override - State createState() => _AdvancedTabVisibilityState(); -} - -class _AdvancedTabVisibilityState extends State { - TabController? _tabs; - bool _routeVisible = false; - bool? _reported; - bool _scheduled = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final tabs = DefaultTabController.maybeOf(context); - if (_tabs != tabs) { - _tabs?.removeListener(_tabChanged); - _tabs = tabs; - _tabs?.addListener(_tabChanged); - } - _routeVisible = - TickerMode.valuesOf(context).enabled && - (ModalRoute.of(context)?.isCurrent ?? true); - _scheduleReport(); - } - - @override - void didUpdateWidget(AdvancedTabVisibility oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.onChanged != widget.onChanged) { - if (_reported == true) oldWidget.onChanged(false); - _reported = null; - } - _scheduleReport(); - } - - void _scheduleReport() { - if (_scheduled) return; - _scheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _scheduled = false; - if (!mounted) return; - _report(); - }); - } - - void _tabChanged() { - if (!mounted) return; - _report(); - } - - void _report() { - final visible = - _routeVisible && (_tabs == null || _tabs!.index == widget.tabIndex); - if (visible == _reported) return; - _reported = visible; - widget.onChanged(visible); - } - - @override - void dispose() { - _tabs?.removeListener(_tabChanged); - if (_reported == true) widget.onChanged(false); - super.dispose(); - } - - @override - Widget build(BuildContext context) => widget.child; -} diff --git a/lib/pages/advanced/tunnel/android/apps.dart b/lib/pages/advanced/tunnel/android/apps.dart index 90ca0b0b5..13938dc10 100644 --- a/lib/pages/advanced/tunnel/android/apps.dart +++ b/lib/pages/advanced/tunnel/android/apps.dart @@ -138,10 +138,7 @@ class AndroidAppsPage extends StatelessWidget { final rows = controller.visible; final missing = controller.missing; return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l.prototypeSelectApps), - ), + appBar: PageAppBar(title: Text(l.prototypeSelectApps)), body: SafeArea( child: ResponsiveContent( child: CustomScrollView( diff --git a/lib/pages/advanced/tunnel/android/automation/page.dart b/lib/pages/advanced/tunnel/android/automation/page.dart index 0af72977f..89dae718c 100644 --- a/lib/pages/advanced/tunnel/android/automation/page.dart +++ b/lib/pages/advanced/tunnel/android/automation/page.dart @@ -25,8 +25,7 @@ class AndroidAutomationPage extends StatelessWidget { final controller = context.read(); final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.automationTitle), actions: [ PageHelpButton( diff --git a/lib/pages/advanced/tunnel/apple/page.dart b/lib/pages/advanced/tunnel/apple/page.dart index cb86e57aa..b38f799d3 100644 --- a/lib/pages/advanced/tunnel/apple/page.dart +++ b/lib/pages/advanced/tunnel/apple/page.dart @@ -51,7 +51,6 @@ class AppleVpnPage extends StatelessWidget { final controller = context.read(); return PolicyDetailScaffold( title: l.prototypeAppleSystemVpn, - description: l.tunnelDetailDescription, help: [ l.prototypeAllowLocalNetworkHint, l.prototypeBypassCellularServicesHint, diff --git a/lib/pages/advanced/tunnel/apple/wifi.dart b/lib/pages/advanced/tunnel/apple/wifi.dart index 9fb1afbb2..d6c291b77 100644 --- a/lib/pages/advanced/tunnel/apple/wifi.dart +++ b/lib/pages/advanced/tunnel/apple/wifi.dart @@ -23,7 +23,6 @@ class AppleWifiPage extends StatelessWidget { final controller = context.read(); return PolicyDetailScaffold( title: l.prototypeWifiRules, - description: l.tunnelDetailDescription, controller: controller, canSave: !controller.wifiConflict, contentPadding: EdgeInsets.zero, diff --git a/lib/pages/advanced/tunnel/details.dart b/lib/pages/advanced/tunnel/details.dart index f082ed7f2..3f62607ac 100644 --- a/lib/pages/advanced/tunnel/details.dart +++ b/lib/pages/advanced/tunnel/details.dart @@ -44,7 +44,6 @@ class TunnelDnsPage extends StatelessWidget { ); return PolicyDetailScaffold( title: l.prototypeTunnelDns, - description: l.tunnelDetailDescription, controller: controller, help: [ l.tunnelDnsServerNameHint, @@ -121,7 +120,6 @@ class TunnelNetworkPage extends StatelessWidget { final l = AppLocalizations.of(context)!; return PolicyDetailScaffold( title: 'IPv6', - description: l.tunnelDetailDescription, controller: controller, canSave: controller.validationHint(l) == null, contentPadding: EdgeInsets.zero, diff --git a/lib/pages/advanced/tunnel/interface/page.dart b/lib/pages/advanced/tunnel/interface/page.dart index 8e5b347e3..220ac53c4 100644 --- a/lib/pages/advanced/tunnel/interface/page.dart +++ b/lib/pages/advanced/tunnel/interface/page.dart @@ -88,7 +88,6 @@ class OutboundInterfaceView extends StatelessWidget { final selected = controller.value['xrayOutboundInterfaceName'] as String; return PolicyDetailScaffold( title: l.prototypeXrayOutboundInterface, - description: l.tunnelDetailDescription, controller: controller, canSave: !controller.loading && !controller.failed && selected.isNotEmpty, diff --git a/lib/pages/advanced/tunnel/widgets.dart b/lib/pages/advanced/tunnel/widgets.dart index 3a61948c7..5ab6c2930 100644 --- a/lib/pages/advanced/tunnel/widgets.dart +++ b/lib/pages/advanced/tunnel/widgets.dart @@ -14,7 +14,6 @@ import 'package:onexray/pages/shared/widgets/settings_page.dart'; class PolicyDetailScaffold extends StatelessWidget { final String title; - final String? description; final PolicyEditorController controller; final Widget body; final bool canSave; @@ -24,7 +23,6 @@ class PolicyDetailScaffold extends StatelessWidget { const PolicyDetailScaffold({ super.key, required this.title, - this.description, required this.controller, required this.body, this.canSave = true, @@ -36,10 +34,8 @@ class PolicyDetailScaffold extends StatelessWidget { Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(title), - description: description, leading: BackButton(onPressed: () => controller.cancel(context)), actions: [ if (help.isNotEmpty) PageHelpButton(title: title, messages: help), diff --git a/lib/pages/advanced/xray/data_update/page.dart b/lib/pages/advanced/xray/data_update/page.dart index c1c286b2d..36dc09e62 100644 --- a/lib/pages/advanced/xray/data_update/page.dart +++ b/lib/pages/advanced/xray/data_update/page.dart @@ -31,10 +31,8 @@ class AutoUpdatePage extends StatelessWidget { ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeAutomaticUpdates), - description: l.pageXrayConfigurationDescription, actions: [ const AppActivityIndicator(pinging: false), PageHelpButton( diff --git a/lib/pages/advanced/xray/diagnostics.dart b/lib/pages/advanced/xray/diagnostics.dart index 3b44e0ef9..806961751 100644 --- a/lib/pages/advanced/xray/diagnostics.dart +++ b/lib/pages/advanced/xray/diagnostics.dart @@ -1,7 +1,7 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/advanced/tab_visibility.dart'; +import 'package:onexray/pages/main/page_visibility.dart'; import 'package:onexray/pages/advanced/xray/config/params.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; import 'package:onexray/pages/advanced/xray/log/params.dart'; @@ -39,14 +39,11 @@ class XrayDiagnosticsPage extends StatelessWidget { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; - return AdvancedTabVisibility( - tabIndex: 1, + return PageVisibility( onChanged: controller.reader.setVisible, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.xrayDiagnostics), - description: l.xrayDiagnosticsHint, actions: [ IconButton( tooltip: l.xrayRefreshStatus, diff --git a/lib/pages/advanced/xray/dialing/page.dart b/lib/pages/advanced/xray/dialing/page.dart index 1c37571c2..aacba0133 100644 --- a/lib/pages/advanced/xray/dialing/page.dart +++ b/lib/pages/advanced/xray/dialing/page.dart @@ -32,7 +32,6 @@ class ServerDialingPage extends StatelessWidget { : AppSpacing.advancedDesktopGutter(width); return PolicyDetailScaffold( title: l.serverDialingTitle, - description: l.pageXrayConfigurationDescription, controller: controller, canSave: state.draft != null, help: [ diff --git a/lib/pages/advanced/xray/geodata/add.dart b/lib/pages/advanced/xray/geodata/add.dart index 04063c84b..17c01e090 100644 --- a/lib/pages/advanced/xray/geodata/add.dart +++ b/lib/pages/advanced/xray/geodata/add.dart @@ -64,10 +64,8 @@ class GeoDataAddPage extends StatelessWidget { return PopScope( canPop: !state.formBusy, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeAddDataSource), - description: l.geodataAddDescription, leading: BackButton(onPressed: cancel), ), body: SafeArea( diff --git a/lib/pages/advanced/xray/geodata/detail.dart b/lib/pages/advanced/xray/geodata/detail.dart index c72530030..72b40870d 100644 --- a/lib/pages/advanced/xray/geodata/detail.dart +++ b/lib/pages/advanced/xray/geodata/detail.dart @@ -48,10 +48,8 @@ class _GeoDataFilePageState extends State { MediaQuery.sizeOf(context).width, ); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(file?.fileName ?? l.prototypeRoutingData), - description: l.pageXrayConfigurationDescription, actions: const [AppActivityIndicator(pinging: false)], ), body: SafeArea( diff --git a/lib/pages/advanced/xray/geodata/page.dart b/lib/pages/advanced/xray/geodata/page.dart index 5dad8e7b8..84d5dd685 100644 --- a/lib/pages/advanced/xray/geodata/page.dart +++ b/lib/pages/advanced/xray/geodata/page.dart @@ -42,11 +42,7 @@ class _GeoDataPageState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final palette = ColorManager.palette(context); return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l.prototypeRoutingData), - description: l.prototypeRoutingDataHint, - ), + appBar: PageAppBar(title: Text(l.prototypeRoutingData)), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: AppLayout.advancedMaxWidth, diff --git a/lib/pages/advanced/xray/log/settings.dart b/lib/pages/advanced/xray/log/settings.dart index c77635780..42859522d 100644 --- a/lib/pages/advanced/xray/log/settings.dart +++ b/lib/pages/advanced/xray/log/settings.dart @@ -53,10 +53,8 @@ class XrayLogSettingsPage extends StatelessWidget { return PopScope( canPop: !controller.busy, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeLogs), - description: l.pageXrayConfigurationDescription, leading: BackButton(onPressed: cancel), actions: [ PageHelpButton( diff --git a/lib/pages/advanced/xray/page.dart b/lib/pages/advanced/xray/page.dart index a64b34e74..e4a812a55 100644 --- a/lib/pages/advanced/xray/page.dart +++ b/lib/pages/advanced/xray/page.dart @@ -7,11 +7,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/advanced/tab_visibility.dart'; +import 'package:onexray/pages/main/page_visibility.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/advanced/xray/controller.dart'; import 'package:onexray/pages/advanced/xray/config/params.dart'; -import 'package:onexray/pages/advanced/xray/log/params.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -26,7 +25,6 @@ class XrayRuntimePage extends StatelessWidget { required this.onGeodata, required this.onUpdates, required this.onSpeedTest, - required this.onLog, required this.onConfig, this.onDiagnostics, this.onLogSettings, @@ -38,7 +36,6 @@ class XrayRuntimePage extends StatelessWidget { final void Function(BuildContext) onGeodata; final void Function(BuildContext) onUpdates; final void Function(BuildContext) onSpeedTest; - final void Function(BuildContext, LogFileViewerParams) onLog; final void Function(BuildContext, ConfigFileViewerParams) onConfig; final void Function(BuildContext)? onDiagnostics; final Future Function(BuildContext)? onLogSettings; @@ -59,8 +56,7 @@ class XrayRuntimePage extends StatelessWidget { final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); - return AdvancedTabVisibility( - tabIndex: 1, + return PageVisibility( onChanged: controller.reader.setVisible, child: Scaffold( body: controller.loading diff --git a/lib/pages/advanced/xray/ping/page.dart b/lib/pages/advanced/xray/ping/page.dart index b6d29e400..1f1b59856 100644 --- a/lib/pages/advanced/xray/ping/page.dart +++ b/lib/pages/advanced/xray/ping/page.dart @@ -33,10 +33,8 @@ class PingPage extends StatelessWidget { ? AppSpacing.mobilePage : AppSpacing.advancedDesktopGutter(width); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.xrayLatencyTitle), - description: l.pageXrayConfigurationDescription, actions: [ const AppActivityIndicator(downloading: false), PageHelpButton( diff --git a/lib/pages/advanced/xray/runtime_code_view.dart b/lib/pages/advanced/xray/runtime_code_view.dart index 853b3fbf5..3e64be17a 100644 --- a/lib/pages/advanced/xray/runtime_code_view.dart +++ b/lib/pages/advanced/xray/runtime_code_view.dart @@ -111,7 +111,7 @@ class RuntimeCodeScaffold extends StatelessWidget { ), ); return Scaffold( - appBar: PageAppBar.forPage(context, title: Text(title)), + appBar: PageAppBar(title: Text(title)), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: AppLayout.advancedMaxWidth, diff --git a/lib/pages/connect/controller.dart b/lib/pages/connect/controller.dart index 8718d1275..6332bd702 100644 --- a/lib/pages/connect/controller.dart +++ b/lib/pages/connect/controller.dart @@ -17,9 +17,6 @@ import 'package:onexray/pages/shared/page_cubit.dart'; import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/connect/target_picker.dart'; import 'package:onexray/service/connect/resolver.dart'; -import 'package:onexray/pages/theme/color.dart'; -import 'package:onexray/pages/theme/font.dart'; -import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/raw/editor.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; @@ -43,16 +40,13 @@ class ConnectPageState { ServerCatalog? catalog, List raws = const [], List customRoutes = const [], - this.expertView = false, this.ready = false, this.failed = false, this.pendingChange, - Set deletingRawIds = const {}, }) : configuration = configuration ?? ConnectionConfiguration(), catalog = catalog ?? ServerCatalog(), raws = List.unmodifiable(raws), - customRoutes = List.unmodifiable(customRoutes), - deletingRawIds = Set.unmodifiable(deletingRawIds); + customRoutes = List.unmodifiable(customRoutes); final ConnectionConfiguration configuration; final ConnectionView connectionView; @@ -62,11 +56,9 @@ class ConnectPageState { final List raws; final List customRoutes; List get sources => catalog.sources; - final bool expertView; final bool ready; final bool failed; final String? pendingChange; - final Set deletingRawIds; ConnectPageState copyWith({ ConnectionConfiguration? configuration, @@ -75,11 +67,9 @@ class ConnectPageState { ServerCatalog? catalog, List? raws, List? customRoutes, - bool? expertView, bool? ready, bool? failed, Object? pendingChange = _unchanged, - Set? deletingRawIds, }) => ConnectPageState( configuration: configuration ?? this.configuration, connectionView: connectionView ?? this.connectionView, @@ -87,13 +77,11 @@ class ConnectPageState { catalog: catalog ?? this.catalog, raws: raws ?? this.raws, customRoutes: customRoutes ?? this.customRoutes, - expertView: expertView ?? this.expertView, ready: ready ?? this.ready, failed: failed ?? this.failed, pendingChange: identical(pendingChange, _unchanged) ? this.pendingChange : pendingChange as String?, - deletingRawIds: deletingRawIds ?? this.deletingRawIds, ); bool sameContentAs(ConnectPageState other) => configuration == other.configuration && @@ -103,8 +91,6 @@ class ConnectPageState { customRoutes, other.customRoutes, ) && - const SetEquality().equals(deletingRawIds, other.deletingRawIds) && - expertView == other.expertView && ready == other.ready && failed == other.failed && pendingChange == other.pendingChange && @@ -134,30 +120,14 @@ class ConnectController extends PageCubit with ServerLabels { final ConnectionCoordinator coordinator; final Future Function() _requestPermission; final List> _subscriptions = []; - bool _viewInitialized = false; bool _pageVisible = false; @override ServerCatalog get catalog => state.catalog; ConnectionConfiguration get configuration => state.configuration; - set configuration(ConnectionConfiguration value) { - final previous = state.configuration.connection; - final next = value.connection; - final targetChanged = - previous.expert != next.expert || - previous.rawId != next.rawId || - previous.selection.kind != next.selection.kind || - previous.selection.id != next.selection.id; - // Raw browsing is local until a target is selected. Other pages can replace - // that target, while unrelated settings must leave the browsing draft open. - emit( - state.copyWith( - configuration: value, - expertView: targetChanged ? next.expert : state.expertView, - ), - ); - } + set configuration(ConnectionConfiguration value) => + emit(state.copyWith(configuration: value)); ConnectionView get connectionView => state.connectionView; List get servers => state.servers; @@ -171,8 +141,6 @@ class ConnectController extends PageCubit with ServerLabels { List get sources => state.sources; set sources(List value) => emit(state.copyWith(catalog: catalog.copyWith(sources: value))); - bool get expertView => state.expertView; - set expertView(bool value) => emit(state.copyWith(expertView: value)); bool get ready => state.ready; set ready(bool value) => emit(state.copyWith(ready: value)); bool get failed => state.failed; @@ -180,7 +148,6 @@ class ConnectController extends PageCubit with ServerLabels { String? get pendingChange => state.pendingChange; set pendingChange(String? value) => emit(state.copyWith(pendingChange: value)); - Set get deletingRawIds => state.deletingRawIds; void _connectionChanged() { if (isPageActive) { @@ -210,8 +177,6 @@ class ConnectController extends PageCubit with ServerLabels { try { if (!isPageActive) return; configuration = await coordinator.configuration; - if (!_viewInitialized) expertView = configuration.connection.expert; - _viewInitialized = true; if (_subscriptions.isEmpty) { _subscriptions.add( db.connectionConfigDao.watch().listen((row) { @@ -394,7 +359,7 @@ class ConnectController extends PageCubit with ServerLabels { ({int entryCount, String path})? get runningRoute { final view = connectionView; final runtime = view.runtime; - if (view.phase != ConnectionPhase.connected || + if (!view.canDisconnect || runtime == null || runtime.configuration.connection.expert || runtime.profile != null) { @@ -430,17 +395,6 @@ class ConnectController extends PageCubit with ServerLabels { return; } if (pendingChange != null) return; - if (!view.canDisconnect && expertView && !configuration.connection.expert) { - if (raws.isEmpty) { - await editRaw(context); - return; - } - ContextAlert.showToast( - context, - AppLocalizations.of(context)!.prototypeChooseRawConfiguration, - ); - return; - } // A command may await permission, status reads or the serial queue before // publishing a VPN phase. Acknowledge the tap without inventing that phase. pendingChange = 'connection'; @@ -468,14 +422,6 @@ class ConnectController extends PageCubit with ServerLabels { } } - Future toggleExpert(BuildContext context, bool value) async { - if (connectionView.busy || pendingChange != null) return; - if (!value && configuration.connection.expert) { - if (!await change(context, {'expert': false})) return; - } - expertView = value; - } - Future change( BuildContext context, Map values, { @@ -607,51 +553,6 @@ class ConnectController extends PageCubit with ServerLabels { } } - Future deleteRaw(BuildContext context, CoreConfigData row) async { - if (connectionView.busy || - pendingChange != null || - deletingRawIds.contains(row.id)) { - return; - } - emit(state.copyWith(deletingRawIds: {...deletingRawIds, row.id})); - try { - await run(context, () async { - var active = false; - final deleted = - await RawEditorService( - database: db, - coordinator: coordinator, - ).delete( - row, - confirm: (selected, reconnect, disconnect) async { - if (!context.mounted) return false; - active = selected; - final l10n = AppLocalizations.of(context)!; - return await showDestructiveConfirmationDialog( - context, - title: l10n.prototypeDeleteRawQuestion, - subtitle: row.name, - warning: disconnect - ? l10n.prototypeRawDeleteDisconnectNotice - : selected - ? l10n.prototypeActiveRawDeleteNotice - : l10n.prototypeRawDeleteNotice, - confirmLabel: disconnect - ? l10n.prototypeDeleteAndDisconnect - : reconnect - ? l10n.prototypeDeleteAndReconnect - : l10n.prototypeDelete, - ) && - context.mounted; - }, - ); - if (deleted && active) expertView = false; - }); - } finally { - emit(state.copyWith(deletingRawIds: {...deletingRawIds}..remove(row.id))); - } - } - Future addServers(BuildContext context) => context.pushScoped(AppPageDestination.serversImport); Future editRaw(BuildContext context, [int? id]) => @@ -701,70 +602,16 @@ class ConnectController extends PageCubit with ServerLabels { } } - Future showRawActions(BuildContext context, CoreConfigData row) async { - if (deletingRawIds.contains(row.id)) return; - final edit = await showConnectDialog(context, (dialogContext) { - final l = AppLocalizations.of(dialogContext)!; - final palette = ColorManager.palette(dialogContext); - return ConnectDialog( - title: row.name, - body: ListTileTheme( - data: AppTheme.actionListTile, - child: Column( - children: [ - ListTile( - leading: Icon( - LucideIcons.pencil, - color: palette.primary, - size: 20, - ), - title: Text( - l.prototypeEditRawJson, - style: AppTypography.dialogGroupTitle, - ), - trailing: const Icon(LucideIcons.chevronRightDir, size: 18), - onTap: () => Navigator.of(dialogContext).pop(true), - ), - Divider(height: 1, color: palette.border), - ListTile( - enabled: !connectionView.busy, - leading: Icon( - LucideIcons.trash2, - color: palette.destructive, - size: 20, - ), - title: Text( - l.prototypeDelete, - style: AppTypography.dialogGroupTitle.copyWith( - color: palette.destructive, - ), - ), - trailing: Icon( - LucideIcons.chevronRightDir, - color: palette.destructive, - size: 18, - ), - onTap: () => Navigator.of(dialogContext).pop(false), - ), - ], - ), - ), - ); - }); - if (!context.mounted || edit == null) return; - if (edit) { - await editRaw(context, row.id); - } else { - await deleteRaw(context, row); - } - } - Future chooseTrafficMethod(BuildContext context) => context.pushScoped(AppPageDestination.routing); Future showWhy(BuildContext context) async { + final view = connectionView; + final runtime = view.runtime; + if (!view.canDisconnect || runtime == null) return; + final settings = runtime.configuration.connection; final l = AppLocalizations.of(context)!; - if (providerConfiguration || configuration.connection.expert) { + if (runtime.profile != null || settings.expert) { await showConnectDialog( context, (dialogContext) => ConnectDialog( @@ -773,7 +620,7 @@ class ConnectController extends PageCubit with ServerLabels { padding: const EdgeInsets.all(22), child: ConnectExplanation( icon: LucideIcons.shieldCheck, - text: configuration.connection.expert + text: settings.expert ? l.prototypeRawManagedSettingsNotice : l.providerRoutingNotice, ), @@ -788,26 +635,9 @@ class ConnectController extends PageCubit with ServerLabels { ); return; } - final view = connectionView; - final connected = view.phase == ConnectionPhase.connected; - final runtime = connected ? view.runtime : null; - final settings = - runtime?.configuration.connection ?? configuration.connection; - final names = connected - ? (runtime?.entries ?? const []) - .map((entry) => entry.name) - .toList() - : _previewEntries(settings).map((row) => serverName(row)).toList(); + final names = runtime.entries.map((entry) => entry.name).toList(); final entryNames = names.join(' + '); - final exit = connected - ? runtime?.finalExit?.name - : servers - .where((row) => row.id == settings.finalExitId) - .map(serverName) - .firstOrNull; - final custom = customRoutes - .where((row) => row.id == settings.customId) - .firstOrNull; + final exit = runtime.finalExit?.name; final methodReason = names.isEmpty ? l.prototypeNoAvailableEntries : switch (settings.trafficMode) { @@ -816,21 +646,11 @@ class ConnectController extends PageCubit with ServerLabels { ? l.prototypeSmartConnectionReason(entryNames) : l.prototypeSmartConnectionChainReason(entryNames, exit), TrafficMode.allVpn => l.prototypeAllVpnConnectionReason(entryNames), - TrafficMode.custom => - custom == null - ? l.prototypeCustomConnectionReason - : l.prototypeNamedCustomConnectionReason(custom.name), + TrafficMode.custom => l.prototypeCustomConnectionReason, }; final selectionReason = names.isEmpty ? null - : connected - ? l.prototypeRunningEntriesReason(entryNames) - : names.length > 1 - ? l.prototypeMultipleEntriesReason(names.length) - : switch (settings.selection.kind) { - SelectionKind.server => l.prototypeFixedEntryReason(entryNames), - _ => l.prototypeAutomaticEntryReason(entryNames), - }; + : l.prototypeRunningEntriesReason(entryNames); await showConnectDialog( context, (dialogContext) => ConnectDialog( @@ -875,29 +695,6 @@ class ConnectController extends PageCubit with ServerLabels { ConnectionSettings _ordinarySettings(ConnectionSettings settings) => ConnectionSettings.fromJson({...settings.toJson(), 'expert': false}); - /// Explain the cached candidate order without probing or selecting a - /// runtime input. The actual connection still resolves through the coordinator. - List _previewEntries(ConnectionSettings settings) { - settings = _ordinarySettings(settings); - final count = _configuredEntryCount(settings); - if (count == null) return []; - final rows = servers.where((row) { - if (!catalog.outboundEligible(row) || - row.id <= 0 || - row.id == settings.finalExitId) { - return false; - } - final matches = switch (settings.selection.kind) { - SelectionKind.automatic => true, - SelectionKind.source => row.subId == settings.selection.id, - SelectionKind.server => row.id == settings.selection.id, - }; - if (!matches) return false; - return catalog.display(row).readable; - }).toList()..sort(ConnectionResolver.compareCandidates); - return rows.length < count ? [] : rows.take(count).toList(); - } - Future run(BuildContext context, Future Function() action) async { await runConnectionAction(context, coordinator, action); } diff --git a/lib/pages/connect/dialogs.dart b/lib/pages/connect/dialogs.dart index 0d99418ab..eed393ce9 100644 --- a/lib/pages/connect/dialogs.dart +++ b/lib/pages/connect/dialogs.dart @@ -7,7 +7,6 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; -import 'package:onexray/service/connect/settings.dart'; Future showConnectDialog(BuildContext context, WidgetBuilder builder) => showAppDialog(context, builder); @@ -172,14 +171,6 @@ class ConnectCallout extends StatelessWidget { } } -typedef ConnectTrafficChoice = ({TrafficMode mode, int? id, bool edit}); -typedef ConnectCustomChoice = ({ - int id, - String name, - int? ruleCount, - bool advanced, -}); - Future showCustomRoutingModeDialog(BuildContext context) => showAppDialog(context, (dialogContext) { final l = AppLocalizations.of(dialogContext)!; @@ -210,153 +201,16 @@ Future showCustomRoutingModeDialog(BuildContext context) => ); }); -class ConnectTrafficMethodDialog extends StatelessWidget { - const ConnectTrafficMethodDialog({ - super.key, - required this.current, - required this.customRoutes, - }); - final ConnectionSettings current; - final List customRoutes; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - void choose(TrafficMode mode, int? id, {bool edit = false}) => - Navigator.of(context).pop((mode: mode, id: id, edit: edit)); - final mobile = - MediaQuery.sizeOf(context).width < AppLayout.mobileBreakpoint; - return ConnectDialog( - title: l.prototypeChooseTrafficMethod, - subtitle: l.prototypeTrafficMethodQuestion, - body: Padding( - padding: EdgeInsets.fromLTRB( - mobile ? 14 : 20, - 18, - mobile ? 14 : 20, - 20, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _MethodOption( - title: l.prototypeSmartRoutingRecommended, - description: l.prototypeSmartRoutingDescription, - selected: current.trafficMode == TrafficMode.smart, - onPressed: () => choose(TrafficMode.smart, null), - onEdit: () => choose(TrafficMode.smart, null, edit: true), - ), - const SizedBox(height: 10), - _MethodOption( - title: l.prototypeAllViaVpn, - description: l.prototypeAllViaVpnDescription, - selected: current.trafficMode == TrafficMode.allVpn, - onPressed: () => choose(TrafficMode.allVpn, null), - ), - const SizedBox(height: 19), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l.prototypeCustomRouting, - style: AppTypography.dialogGroupTitle, - ), - const SizedBox(height: 3), - Text( - l.prototypeChooseNamedRoute, - style: AppTypography.dialogGroupMeta.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ), - ), - const SizedBox(width: 12), - Text( - '${customRoutes.length}/3', - textDirection: TextDirection.ltr, - style: AppTypography.dialogGroupMeta.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ), - ), - for (final route in customRoutes) ...[ - const SizedBox(height: 10), - _MethodOption( - title: route.name, - description: route.ruleCount == null - ? l.prototypeCannotReadCustomRoute - : [ - if (route.advanced) l.routingAdvancedJson, - l.prototypeRuleCount(route.ruleCount!), - ].join(' · '), - selected: - current.trafficMode == TrafficMode.custom && - current.customId == route.id, - onPressed: () => choose(TrafficMode.custom, route.id), - onEdit: () => choose(TrafficMode.custom, route.id, edit: true), - ), - ], - if (customRoutes.isEmpty || customRoutes.length >= 3) - Padding( - padding: const EdgeInsets.fromLTRB(2, 20, 2, 10), - child: Text( - customRoutes.isEmpty - ? l.prototypeNoCustomRoutes - : l.prototypeCustomRouteLimit, - style: AppTypography.dialogGroupMeta.copyWith( - color: palette.mutedForeground, - ), - ), - ), - if (customRoutes.length < 3) ...[ - const SizedBox(height: 10), - OutlinedButton.icon( - onPressed: () => choose(TrafficMode.custom, null, edit: true), - style: OutlinedButton.styleFrom( - foregroundColor: palette.primary, - textStyle: AppTypography.dialogAddAction, - minimumSize: const Size.fromHeight(42), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(color: palette.borderStrong), - shape: AppDashedBorder( - borderRadius: const BorderRadius.all( - Radius.circular(AppRadii.card), - ), - ), - ), - icon: const Icon(LucideIcons.plus, size: 17), - label: Text(l.prototypeNewCustomRoute), - ), - ], - ], - ), - ), - ); - } -} - class _MethodOption extends StatelessWidget { const _MethodOption({ required this.title, required this.description, required this.selected, required this.onPressed, - this.onEdit, }); final String title, description; final bool selected; final VoidCallback onPressed; - final VoidCallback? onEdit; @override Widget build(BuildContext context) { @@ -421,20 +275,6 @@ class _MethodOption extends StatelessWidget { ), ), const SizedBox(width: 10), - if (onEdit != null) ...[ - TextButton( - onPressed: onEdit, - style: TextButton.styleFrom( - foregroundColor: palette.primary, - minimumSize: const Size(0, 38), - padding: const EdgeInsets.symmetric(horizontal: 6), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: AppTypography.dialogOptionEdit, - ), - child: Text(AppLocalizations.of(context)!.prototypeEdit), - ), - const SizedBox(width: 10), - ], Icon( selected ? LucideIcons.circleCheck : LucideIcons.arrowRightDir, size: selected ? 21 : 18, diff --git a/lib/pages/connect/json_editor/page.dart b/lib/pages/connect/json_editor/page.dart index ddec32364..8925927ba 100644 --- a/lib/pages/connect/json_editor/page.dart +++ b/lib/pages/connect/json_editor/page.dart @@ -115,8 +115,7 @@ class _JsonConfigurationEditorState extends State { if (!didPop) controller.closePage(context); }, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( controller.advanced ? widget.configurationId == null @@ -126,9 +125,6 @@ class _JsonConfigurationEditorState extends State { ? l10n.prototypeAddRawJson : l10n.prototypeEditRawJson, ), - description: controller.advanced - ? l10n.routingAdvancedDescription - : null, leading: BackButton( onPressed: state.closeBlocked || diff --git a/lib/pages/connect/page.dart b/lib/pages/connect/page.dart index d513505e7..562d26555 100644 --- a/lib/pages/connect/page.dart +++ b/lib/pages/connect/page.dart @@ -39,8 +39,7 @@ class _ConnectPageState extends State { child: PageVisibility( onChanged: controller.setPageVisible, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(AppLocalizations.of(context)!.prototypeConnect), actions: const [AppActivityIndicator()], ), @@ -78,10 +77,9 @@ class _ConnectPageState extends State { TrafficReadout(view: view, desktop: desktop), ), hasServers: state.servers.isNotEmpty, - expert: state.expertView, + expert: state.configuration.connection.expert, raws: state.raws, pendingChange: state.pendingChange, - deletingRawIds: state.deletingRawIds, activeRawId: state.configuration.connection.expert ? state.configuration.connection.rawId : null, @@ -98,13 +96,10 @@ class _ConnectPageState extends State { !controller.providerConfiguration, onConnection: () => controller.connectionAction(context), onAddServers: () => controller.addServers(context), - onExpert: (value) => controller.toggleExpert(context, value), onServer: () => controller.chooseServer(context), onMethod: () => controller.chooseTrafficMethod(context), onWhy: () => controller.showWhy(context), onRawAdd: () => controller.editRaw(context), - onRawSelect: (row) => controller.selectRaw(context, row.id), - onRawActions: (row) => controller.showRawActions(context, row), ); }, ), diff --git a/lib/pages/connect/routing/custom/controller.dart b/lib/pages/connect/routing/custom/controller.dart index d94b55f07..c43c7e855 100644 --- a/lib/pages/connect/routing/custom/controller.dart +++ b/lib/pages/connect/routing/custom/controller.dart @@ -7,7 +7,6 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; -import 'package:onexray/pages/connect/routing/custom/rule_controller.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; import 'package:onexray/service/connect/routing/custom/editor.dart'; @@ -45,7 +44,6 @@ class CustomRoutingEditorState { final bool transferBusy; final bool saving; final bool deleting; - final bool inlineEditing; final String? error; CustomRoutingEditorState({ @@ -66,7 +64,6 @@ class CustomRoutingEditorState { this.transferBusy = false, this.saving = false, this.deleting = false, - this.inlineEditing = false, this.error, }) : dns = dns ?? @@ -103,7 +100,6 @@ class CustomRoutingEditorState { bool? transferBusy, bool? saving, bool? deleting, - bool? inlineEditing, Object? error = _unchangedCustomRoutingValue, }) => CustomRoutingEditorState( original: identical(original, _unchangedCustomRoutingValue) @@ -132,7 +128,6 @@ class CustomRoutingEditorState { transferBusy: transferBusy ?? this.transferBusy, saving: saving ?? this.saving, deleting: deleting ?? this.deleting, - inlineEditing: inlineEditing ?? this.inlineEditing, error: identical(error, _unchangedCustomRoutingValue) ? this.error : error as String?, @@ -144,9 +139,6 @@ class CustomRoutingEditorController final int? profileId; final CustomRoutingEditorService service; final name = TextEditingController(); - CustomRoutingRuleController? inlineRule; - Object? _inlineRuleKey; - StreamSubscription? _inlineRuleSubscription; late final ConfigurationTransferController transfer; late final StreamSubscription _transferSubscription; @@ -229,10 +221,8 @@ class CustomRoutingEditorController fragmentOutbound: value.fragmentOutbound, processing: false, transferBusy: transfer.state.busy, - inlineEditing: state.inlineEditing, ), ); - _syncInlineRule(); } catch (_) { if (context.mounted) { emit( @@ -304,62 +294,6 @@ class CustomRoutingEditorController error: null, ), ); - _syncInlineRule(); - } - - void setInlineEditing(bool value) { - if (state.inlineEditing == value) return; - _flushInlineRule(); - emit(state.copyWith(inlineEditing: value)); - _syncInlineRule(); - } - - void _flushInlineRule() { - final source = inlineRule; - final key = _inlineRuleKey; - final index = key == null ? -1 : state.ruleKeys.indexOf(key); - if (source == null || index < 0) return; - final rules = state.rules.toList(); - rules[index] = source.draftRule; - emit(state.copyWith(rules: rules)); - } - - void _syncInlineRule({bool refresh = false}) { - final key = state.inlineEditing ? state.selectedRuleKey : null; - if (_inlineRuleKey == key && !refresh) return; - final previous = inlineRule; - final previousSubscription = _inlineRuleSubscription; - _inlineRuleSubscription = null; - if (previousSubscription != null) { - unawaited(previousSubscription.cancel()); - } - _inlineRuleKey = key; - final index = key == null ? -1 : state.ruleKeys.indexOf(key); - final next = index < 0 - ? null - : CustomRoutingRuleController(rule: state.rules[index]); - inlineRule = next; - if (next != null) { - _inlineRuleSubscription = next.stream.listen( - (_) => _inlineRuleChanged(next), - ); - } - // Inputs from the previous rule unmount on the next frame. - if (previous != null) { - WidgetsBinding.instance.addPostFrameCallback( - (_) => unawaited(previous.close()), - ); - } - } - - void _inlineRuleChanged(CustomRoutingRuleController source) { - if (!identical(inlineRule, source)) return; - final key = _inlineRuleKey; - final index = key == null ? -1 : state.ruleKeys.indexOf(key); - if (!isPageActive || state.editingBlocked || index < 0) return; - final rules = state.rules.toList(); - rules[index] = source.draftRule; - emit(state.copyWith(rules: rules)); } /// Import tools stage their dependencies separately before calling this. @@ -380,11 +314,6 @@ class CustomRoutingEditorController emit(state.copyWith(entryCount: value)); } - void setDirectDnsAddress(String value) { - if (state.editingBlocked) return; - emit(state.copyWith(directDnsAddress: value, error: null)); - } - void setFakeDns(bool value) { if (state.editingBlocked) return; if (value && !state.sniffing.enabled) return; @@ -425,28 +354,6 @@ class CustomRoutingEditorController int? index, ]) async { if (!state.loaded || state.editingBlocked) return; - _flushInlineRule(); - if (state.inlineEditing) { - final rules = state.rules.toList(); - final keys = state.ruleKeys.toList(); - Object selected; - if (index == null) { - rules.add( - RoutingRuleState( - ruleTag: AppLocalizations.of(context)!.prototypeNewRule, - ), - ); - keys.add(Object()); - selected = keys.last; - } else { - selected = keys[index]; - } - emit( - state.copyWith(rules: rules, ruleKeys: keys, selectedRuleKey: selected), - ); - _syncInlineRule(); - return; - } final rule = index == null ? null : state.rules[index]; if (index != null) { emit(state.copyWith(selectedRuleKey: state.ruleKeys[index])); @@ -467,12 +374,10 @@ class CustomRoutingEditorController emit( state.copyWith(rules: rules, ruleKeys: keys, selectedRuleKey: selected), ); - _syncInlineRule(refresh: true); } void deleteRule(int index) { if (!state.loaded || state.editingBlocked) return; - _flushInlineRule(); final rules = state.rules.toList(); final keys = state.ruleKeys.toList(); final wasSelected = state.selectedRuleKey == keys[index]; @@ -485,12 +390,10 @@ class CustomRoutingEditorController emit( state.copyWith(rules: rules, ruleKeys: keys, selectedRuleKey: selected), ); - _syncInlineRule(); } void reorder(int from, int to) { if (!state.loaded || state.editingBlocked) return; - _flushInlineRule(); final rules = state.rules.toList(); final keys = state.ruleKeys.toList(); rules.insert(to, rules.removeAt(from)); @@ -541,7 +444,6 @@ class CustomRoutingEditorController Future save(BuildContext context) async { if (state.busy || state.original == null) return; - _flushInlineRule(); final l10n = AppLocalizations.of(context)!; if (nameError(l10n) != null) return; emit(state.copyWith(processing: true, saving: true, error: null)); @@ -655,8 +557,6 @@ class CustomRoutingEditorController @override Future disposePageResources() async { await _transferSubscription.cancel(); - await _inlineRuleSubscription?.cancel(); - await inlineRule?.close(); if (!state.saving) await transfer.close(); name.dispose(); } diff --git a/lib/pages/connect/routing/custom/page.dart b/lib/pages/connect/routing/custom/page.dart index 8d2fca074..eb9379e6c 100644 --- a/lib/pages/connect/routing/custom/page.dart +++ b/lib/pages/connect/routing/custom/page.dart @@ -7,7 +7,6 @@ import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/connect/routing/options_form.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/connect/routing/custom/controller.dart'; -import 'package:onexray/pages/connect/routing/custom/rule_page.dart'; import 'package:onexray/pages/connect/routing/custom/sections.dart'; import 'package:onexray/pages/connect/routing/widgets.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; @@ -50,7 +49,6 @@ class _CustomRoutingEditorPageState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - controller.setInlineEditing(false); if (!_started) { _started = true; controller.load(context); @@ -72,8 +70,7 @@ class _CustomRoutingEditorPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( widget.profileId == null ? l.routingNormalCreateTitle @@ -120,11 +117,6 @@ class _CustomRoutingEditorPageState extends State { _identity(context, mobile, state), CustomRoutingSections( rules: _rules(context, mobile, state), - editor: controller.inlineRule == null - ? null - : CustomRoutingRuleForm( - controller: controller.inlineRule!, - ), options: _proxySettings(context, state), dns: RoutingDnsSettingsForm( value: state.dns, diff --git a/lib/pages/connect/routing/custom/rule_page.dart b/lib/pages/connect/routing/custom/rule_page.dart index fd8def369..897a7ac3c 100644 --- a/lib/pages/connect/routing/custom/rule_page.dart +++ b/lib/pages/connect/routing/custom/rule_page.dart @@ -52,7 +52,7 @@ class _CustomRoutingRulePageState extends State { return BlocProvider.value( value: controller, child: Scaffold( - appBar: PageAppBar.forPage(context, title: Text(l.prototypeEditRule)), + appBar: PageAppBar(title: Text(l.prototypeEditRule)), body: SafeArea( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( diff --git a/lib/pages/connect/routing/custom/sections.dart b/lib/pages/connect/routing/custom/sections.dart index dfd9ab340..46389ad19 100644 --- a/lib/pages/connect/routing/custom/sections.dart +++ b/lib/pages/connect/routing/custom/sections.dart @@ -3,7 +3,6 @@ import 'package:material_ui/material_ui.dart'; /// Every section shares one content surface at all window widths. class CustomRoutingSections extends StatefulWidget { final Widget rules; - final Widget? editor; final Widget options; final Widget dns; final Widget sniffing; @@ -11,7 +10,6 @@ class CustomRoutingSections extends StatefulWidget { const CustomRoutingSections({ super.key, required this.rules, - this.editor, required this.options, required this.dns, required this.sniffing, @@ -24,7 +22,6 @@ class CustomRoutingSections extends StatefulWidget { class _CustomRoutingSectionsState extends State { // Reparent whole sections together with their input and disclosure state. final _rules = GlobalKey(); - final _editor = GlobalKey(); final _options = GlobalKey(); final _dns = GlobalKey(); final _sniffing = GlobalKey(); @@ -35,8 +32,6 @@ class _CustomRoutingSectionsState extends State { spacing: 20, children: [ KeyedSubtree(key: _rules, child: widget.rules), - if (widget.editor case final editor?) - KeyedSubtree(key: _editor, child: editor), KeyedSubtree(key: _options, child: widget.options), KeyedSubtree(key: _dns, child: widget.dns), KeyedSubtree(key: _sniffing, child: widget.sniffing), diff --git a/lib/pages/connect/routing/page.dart b/lib/pages/connect/routing/page.dart index 1916bc284..f04b1390a 100644 --- a/lib/pages/connect/routing/page.dart +++ b/lib/pages/connect/routing/page.dart @@ -44,10 +44,8 @@ class _RoutingPageState extends State { final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; final palette = ColorManager.palette(context); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.navigationRouting), - description: l.routingPageDescription, headingAction: IconButton( tooltip: l.prototypeNewCustomRoute, onPressed: !state.busy && state.profiles.length < 3 diff --git a/lib/pages/connect/routing/smart/exit_picker.dart b/lib/pages/connect/routing/smart/exit_picker.dart index 7c49a9ee7..a589cc9cc 100644 --- a/lib/pages/connect/routing/smart/exit_picker.dart +++ b/lib/pages/connect/routing/smart/exit_picker.dart @@ -62,8 +62,7 @@ class ServerExitPickerView extends StatelessWidget { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final groups = controller.selectionGroups(l); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeVpnFinalExit), actions: const [AppActivityIndicator()], ), diff --git a/lib/pages/connect/routing/smart/page.dart b/lib/pages/connect/routing/smart/page.dart index e35ad29c2..4c6997c45 100644 --- a/lib/pages/connect/routing/smart/page.dart +++ b/lib/pages/connect/routing/smart/page.dart @@ -56,10 +56,8 @@ class _SmartRoutingEditorPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeSmartRouting), - description: l.smartRoutingEditorDescription, leading: BackButton(onPressed: () => controller.cancel(context)), ), body: SafeArea( diff --git a/lib/pages/connect/routing/smart/regions.dart b/lib/pages/connect/routing/smart/regions.dart index 42613ef8d..edffb9305 100644 --- a/lib/pages/connect/routing/smart/regions.dart +++ b/lib/pages/connect/routing/smart/regions.dart @@ -161,10 +161,7 @@ class _DirectRegionsPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l.prototypeDirectRegions), - ), + appBar: PageAppBar(title: Text(l.prototypeDirectRegions)), body: SafeArea( child: ResponsiveContent( desktopMaxWidth: diff --git a/lib/pages/connect/view.dart b/lib/pages/connect/view.dart index c913db8df..bc1f143f8 100644 --- a/lib/pages/connect/view.dart +++ b/lib/pages/connect/view.dart @@ -23,7 +23,6 @@ class ConnectView extends StatelessWidget { required this.raws, required this.activeRawId, this.pendingChange, - this.deletingRawIds = const {}, required this.location, this.targetKind, this.runningPath, @@ -35,13 +34,10 @@ class ConnectView extends StatelessWidget { this.methodEnabled = true, required this.onConnection, required this.onAddServers, - required this.onExpert, required this.onServer, required this.onMethod, required this.onWhy, required this.onRawAdd, - required this.onRawSelect, - required this.onRawActions, }); final ConnectionView view; final Widget Function(bool desktop)? trafficBuilder; @@ -50,7 +46,6 @@ class ConnectView extends StatelessWidget { final List raws; final int? activeRawId; final String? pendingChange; - final Set deletingRawIds; final String location; final String? targetKind; final String? runningPath; @@ -64,8 +59,6 @@ class ConnectView extends StatelessWidget { onMethod, onWhy, onRawAdd; - final ValueChanged onExpert; - final ValueChanged onRawSelect, onRawActions; bool get _connectionPending => pendingChange == 'connection'; bool get _busy => view.busy || _connectionPending; @@ -219,10 +212,7 @@ class ConnectView extends StatelessWidget { ), ), ), - if (view.canDisconnect && - (runningPath != null || - view.runtime?.profile != null || - view.runtime?.configuration.connection.expert == true)) ...[ + if (view.canDisconnect && view.runtime != null) ...[ const SizedBox(height: 16), Card( margin: EdgeInsets.zero, diff --git a/lib/pages/main/advanced.dart b/lib/pages/main/advanced.dart index 9246e5937..2be0a4418 100644 --- a/lib/pages/main/advanced.dart +++ b/lib/pages/main/advanced.dart @@ -16,8 +16,7 @@ class TunnelRootPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(AppLocalizations.of(context)!.prototypeVpnTunnel), ), body: VpnTunnelPane( @@ -39,8 +38,7 @@ class XrayRootPage extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( AppLocalizations.of(context)!.prototypeXrayRuntimeDiagnostics, ), @@ -60,8 +58,6 @@ class XrayRootPage extends StatelessWidget { }, onServerDialing: (context) => context.pushScoped(AppPageDestination.serverDialing), - onLog: (context, params) => - context.pushScoped(AppPageDestination.logFile, extra: params), onConfig: (context, params) => context.pushScoped( AppPageDestination.configFileViewer, extra: params, diff --git a/lib/pages/main/navigation.dart b/lib/pages/main/navigation.dart index 687460003..a0ca3b7f7 100644 --- a/lib/pages/main/navigation.dart +++ b/lib/pages/main/navigation.dart @@ -79,7 +79,6 @@ enum AppPageDestination { backup("backup"), desktopSettings("desktop-settings"), appIcon("app-icon"), - theme("theme"), language("language"), aboutOneXray("about-onexray"), appUpdate("app-update"); diff --git a/lib/pages/main/url.dart b/lib/pages/main/url.dart index e8398590c..8e382eace 100644 --- a/lib/pages/main/url.dart +++ b/lib/pages/main/url.dart @@ -68,7 +68,6 @@ import 'package:onexray/pages/settings/app_icon/page.dart'; import 'package:onexray/pages/advanced/xray/data_update/page.dart'; import 'package:onexray/pages/settings/desktop/page.dart'; import 'package:onexray/pages/settings/language/page.dart'; -import 'package:onexray/pages/settings/theme/page.dart'; import 'package:onexray/pages/servers/subscription/params.dart'; import 'package:onexray/pages/theme/theme.dart'; @@ -484,7 +483,6 @@ final _pageRoutes = <_PageRoute>[ (_, _) => const DesktopSettingsPage(), ), _route(AppPageDestination.appIcon, (_, _) => const AppIconPage()), - _route(AppPageDestination.theme, (_, _) => const ThemePage()), _route(AppPageDestination.language, (_, _) => const LanguagePage()), _route(AppPageDestination.aboutOneXray, (_, _) => const AboutOneXrayPage()), _route( @@ -521,8 +519,7 @@ class _InvalidRoutePage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( AppLocalizations.of(context)!.prototypeTemporarilyUnavailable, ), @@ -583,7 +580,7 @@ class _LogRouteGateState extends State<_LogRouteGate> { } final l = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar.forPage(context, title: Text(l.prototypeLogs)), + appBar: PageAppBar(title: Text(l.prototypeLogs)), body: SafeArea( child: Center( child: snapshot.connectionState != ConnectionState.done diff --git a/lib/pages/servers/catalog.dart b/lib/pages/servers/catalog.dart index 11087b3f0..54b13f747 100644 --- a/lib/pages/servers/catalog.dart +++ b/lib/pages/servers/catalog.dart @@ -80,9 +80,6 @@ mixin ServerGroups on ServerLabels { ].any((value) => value.toLowerCase().contains(query)); } - List favorites(AppLocalizations l) => - servers.where((row) => row.favorite && matches(l, row)).toList(); - List groups(AppLocalizations l, {bool filter = true}) { final query = filter ? this.query.trim().toLowerCase() : ''; final result = []; diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index 807c3ffb3..92ee190ea 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -17,26 +17,21 @@ import 'package:onexray/service/servers/sort.dart'; import 'package:onexray/pages/servers/order/controller.dart'; import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/service/shared/failure.dart'; -import 'package:onexray/service/connect/routing/custom/service.dart'; -import 'package:onexray/service/connect/routing/custom/configuration.dart'; import 'package:onexray/pages/connect/dialogs.dart'; import 'package:onexray/pages/shared/share/params.dart'; import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/servers/subscription/format_confirmation.dart'; -import 'package:onexray/pages/servers/menus.dart'; import 'package:onexray/pages/servers/raw_copy_dialog.dart'; import 'package:onexray/pages/servers/sources.dart'; import 'package:onexray/pages/servers/subscription/params.dart'; -import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/adaptive_dialog.dart'; import 'package:onexray/service/servers/server.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/connect/raw/editor.dart'; -import 'package:onexray/service/connect/resolver.dart'; import 'package:onexray/service/shared/ping/service.dart'; import 'package:onexray/service/servers/subscription/service.dart'; import 'package:onexray/service/servers/subscription/failure.dart'; @@ -46,7 +41,7 @@ export 'catalog.dart'; enum ServerAction { edit, test, copy, share, move, delete } -enum SourceAction { update, test, edit, share, delete } +enum SourceAction { update, edit, share, delete } class ServerGroupParams { final ServersController controller; @@ -63,11 +58,9 @@ class ServersPageState { ConnectionConfiguration? configuration, this.connectionView = const ConnectionView(), ServerCatalog? catalog, - List customRoutes = const [], this.ready = false, this.failed = false, this.failure, - this.activeServerGroupId, this.serverSearchQuery = '', Set pendingServerActions = const {}, Map serverTests = const {}, @@ -78,7 +71,6 @@ class ServersPageState { List rawConfigurations = const [], }) : configuration = configuration ?? ConnectionConfiguration(), catalog = catalog ?? ServerCatalog(), - customRoutes = List.unmodifiable(customRoutes), pendingServerActions = Set.unmodifiable(pendingServerActions), serverTests = Map.unmodifiable(serverTests), favoritingServerIds = Set.unmodifiable(favoritingServerIds), @@ -90,12 +82,10 @@ class ServersPageState { List get servers => catalog.servers; List get sources => catalog.sources; final ServerCatalog catalog; - final List customRoutes; final bool ready; final bool failed; final Object? failure; - final String? activeServerGroupId; final String serverSearchQuery; final Set pendingServerActions; final Map serverTests; @@ -109,11 +99,9 @@ class ServersPageState { ConnectionConfiguration? configuration, ConnectionView? connectionView, ServerCatalog? catalog, - List? customRoutes, bool? ready, bool? failed, Object? failure, - Object? activeServerGroupId = _unchanged, String? serverSearchQuery, Set? pendingServerActions, Map? serverTests, @@ -126,13 +114,9 @@ class ServersPageState { configuration: configuration ?? this.configuration, connectionView: connectionView ?? this.connectionView, catalog: catalog ?? this.catalog, - customRoutes: customRoutes ?? this.customRoutes, ready: ready ?? this.ready, failed: failed ?? this.failed, failure: failed == false ? null : failure ?? this.failure, - activeServerGroupId: identical(activeServerGroupId, _unchanged) - ? this.activeServerGroupId - : activeServerGroupId as String?, serverSearchQuery: serverSearchQuery ?? this.serverSearchQuery, pendingServerActions: pendingServerActions ?? this.pendingServerActions, serverTests: serverTests ?? this.serverTests, @@ -180,10 +164,7 @@ class ServersController extends PageCubit ConnectionConfiguration get configuration => state.configuration; set configuration(ConnectionConfiguration value) => emit(state.copyWith(configuration: value)); - List get customRoutes => state.customRoutes; List get rawConfigurations => state.rawConfigurations; - set customRoutes(List value) => - emit(state.copyWith(customRoutes: value)); ConnectionView get connectionView => state.connectionView; bool get ready => state.ready; set ready(bool value) => emit(state.copyWith(ready: value)); @@ -222,20 +203,6 @@ class ServersController extends PageCubit ); }, onError: _readFailed), ); - _subscriptions.add( - db.routingProfileDao.allRowsStream.listen((rows) { - final readable = []; - for (final row in rows) { - try { - readable.add(CustomRoutingService.readConfiguration(row)); - } catch (_) { - // Keep the auxiliary entry-count catalog separate from row - // diagnostics and from genuine database stream failures. - } - } - customRoutes = readable; - }, onError: _readFailed), - ); } ready = true; } catch (error) { @@ -276,9 +243,6 @@ class ServersController extends PageCubit final PingService _ping; bool _confirmingTest = false; final search = TextEditingController(); - String? get activeGroupId => state.activeServerGroupId; - set activeGroupId(String? value) => - emit(state.copyWith(activeServerGroupId: value)); Set get _pending => state.pendingServerActions; Set get favoritingIds => state.favoritingServerIds; ServerSelection? get selecting => state.selectingServers; @@ -305,14 +269,13 @@ class ServersController extends PageCubit servers.any( (row) => row.subId == id && _pending.contains('server:${row.id}'), ); - bool testingGroup(ServerGroup group) => - state.serverTests.values.any((test) => test.groupId == group.id); - bool cancellingGroup(ServerGroup group) => state.serverTests.values - .where((test) => test.groupId == group.id) - .every((test) => test.cancelling); - bool selectingGroup(ServerSelection value) => - selecting != null && - jsonEncode(selecting!.toJson()) == jsonEncode(value.toJson()); + bool testingGroup(String groupId) => + state.serverTests.values.any((test) => test.groupId == groupId); + bool cancellingGroup(String groupId) => + testingGroup(groupId) && + state.serverTests.values + .where((test) => test.groupId == groupId) + .every((test) => test.cancelling); void _searchChanged() => searchChanged(search.text); void searchChanged(String value) { @@ -321,180 +284,19 @@ class ServersController extends PageCubit } } - int entryCount(ServerSelection selection) { - final connection = ConnectionSettings.fromJson({ - ...configuration.connection.toJson(), - 'expert': false, - 'selection': selection.toJson(), - }); - int? customCount; - if (connection.trafficMode == TrafficMode.custom) { - final profile = customRoutes - .where((profile) => profile.id == connection.customId) - .firstOrNull; - if (profile == null) return 0; - customCount = profile.entryCount; - } - return connection.requiredEntries(customEntryCount: customCount); - } - bool providerSource(SubscriptionData source) => source.mode == SubscriptionMode.providerManaged.name; - bool providerGroup(ServerGroup group) => - group.source != null && providerSource(group.source!); - bool providerRow(CoreConfigData row) => catalog.display(row).type == CoreConfigType.Remnawave.name; bool canTest(CoreConfigData row) => catalog.outboundEligible(row); - bool canUse(ServerGroup group) { - final selection = group.selection; - if (providerGroup(group) || selection == null) return false; - return canUseSelection(selection, group.rows); - } - - bool canUseSelection( - ServerSelection selection, - Iterable rows, - ) { - final count = entryCount(selection); - final finalExit = configuration.connection.trafficMode == TrafficMode.smart - ? configuration.connection.smart.finalExitId - : null; - return count > 0 && - rows - .where( - (row) => - row.id != finalExit && ServerAssetService.selectable(row), - ) - .take(count) - .length >= - count; - } - bool selected(ServerSelection selection) => !configuration.connection.expert && jsonEncode(configuration.connection.selection.toJson()) == jsonEncode(selection.toJson()); - // Display only: running identities come from the active runtime. Offline - // previews use the resolver's readable-candidate order, never start a probe - // or change a connection plan. - ({List names, CoreConfigData? first}) get _displaySelection { - final settings = configuration.connection; - if (settings.expert) return (names: [], first: null); - final view = connectionView; - if (view.phase == ConnectionPhase.connected) { - final runtime = view.runtime; - if (runtime == null || runtime.configuration.connection.expert) { - return (names: [], first: null); - } - if (runtime.profile != null) { - return ( - names: [runtime.profile!.name], - first: servers - .where((row) => row.id == runtime.profile!.id) - .firstOrNull, - ); - } - final entries = runtime.entries; - return ( - names: entries.map((entry) => entry.name).toList(), - first: servers - .where((row) => row.id == entries.firstOrNull?.id) - .firstOrNull, - ); - } - final selection = settings.selection; - if (selection.kind == SelectionKind.server) { - final profile = servers - .where((row) => row.id == selection.id && providerRow(row)) - .firstOrNull; - if (profile != null && catalog.selectable(profile)) { - return (names: [serverName(profile)], first: profile); - } - } - final count = entryCount(selection); - final rows = servers.where((row) { - if (row.id == settings.finalExitId || - !catalog.outboundEligible(row) || - !ServerAssetService.selectable(row)) { - return false; - } - return switch (selection.kind) { - SelectionKind.automatic => true, - SelectionKind.source => row.subId == selection.id, - SelectionKind.server => row.id == selection.id, - }; - }).toList()..sort(ConnectionResolver.compareCandidates); - if (count <= 0 || rows.length < count) return (names: [], first: null); - return ( - names: rows.take(count).map(serverName).toList(), - first: rows.first, - ); - } - - String? automaticResult(AppLocalizations l) { - if (!selected(const ServerSelection.automatic())) return null; - final display = _displaySelection; - if (display.names.isEmpty) return null; - final row = display.first; - return l.prototypeCurrentServerLatency( - display.names.first, - row != null && ServerAssetService.healthy(row) ? row.delay : '—', - ); - } - - ({String title, String detail})? currentSelectionSummary(AppLocalizations l) { - final settings = configuration.connection; - final selection = settings.selection; - if (settings.expert || selection.kind == SelectionKind.automatic) { - return null; - } - final display = _displaySelection; - final row = - display.first ?? - servers.where((row) => row.id == selection.id).firstOrNull; - final title = switch (selection.kind) { - SelectionKind.automatic => l.prototypeAutomaticSelection, - SelectionKind.source => - selection.id == 0 - ? l.prototypeManualAdditions - : sources - .where((source) => source.id == selection.id) - .firstOrNull - ?.name ?? - l.prototypeTemporarilyUnavailable, - SelectionKind.server => - display.names.firstOrNull ?? - (row == null ? l.prototypeTemporarilyUnavailable : serverName(row)), - }; - return ( - title: title, - detail: selection.kind == SelectionKind.server - ? row == null - ? '' - : sourceName(l, row) - : '${l.prototypeAutomaticSelection}${display.names.isEmpty ? '' : ' · ${display.names.join(' + ')}'}', - ); - } - - String? get currentGroupId { - final selection = configuration.connection.selection; - final first = _displaySelection.first; - final id = selection.kind == SelectionKind.source - ? selection.id - : first?.subId ?? connectionView.runtime?.profile?.sourceId; - if (id == 0 && first != null) { - return first.manualGroupId == null - ? 'manual:ungrouped' - : 'manual:${first.manualGroupId}'; - } - return id == null || id == 0 ? null : 'subscription:$id'; - } - bool canChoose(CoreConfigData row) => catalog.selectable(row) && !exitConflict(row); @@ -522,9 +324,6 @@ class ServersController extends PageCubit : {}; String summary(AppLocalizations l, ServerGroup group) { - if (providerGroup(group)) { - return l.providerConfigurationCount(group.rows.length); - } var available = 0; int? fastest; for (final row in group.rows) { @@ -559,12 +358,7 @@ class ServersController extends PageCubit return null; } - Future browse( - BuildContext context, - ServerGroup group, { - required bool mobile, - }) async { - activeGroupId = group.id; + Future browse(BuildContext context, ServerGroup group) async { await context.pushScoped( AppPageDestination.serverGroup, extra: ServerGroupParams(this, group.id), @@ -699,9 +493,12 @@ class ServersController extends PageCubit String? groupId, }) async { final ids = rows.where(canTest).map((row) => row.id).toSet(); - if (ids.isEmpty) return; + if (ids.isEmpty || (groupId != null && testingGroup(groupId))) return; final confirmed = await _confirmManualTest(context); - if (!confirmed || !context.mounted || !_testPageVisible(context)) { + if (!confirmed || + !context.mounted || + !_testPageVisible(context) || + (groupId != null && testingGroup(groupId))) { return; } await run(context, () async { @@ -943,8 +740,7 @@ class ServersController extends PageCubit ); if (name == null || !context.mounted || !isPageActive) return; await perform(context, () async { - final id = await manualGroupService.create(name); - activeGroupId = 'manual:$id'; + await manualGroupService.create(name); if (context.mounted) ContextAlert.settingsSaved(context); }); } @@ -1050,7 +846,6 @@ class ServersController extends PageCubit ServerGroup group, bool closePage, ) { - if (activeGroupId == group.id) activeGroupId = 'manual:ungrouped'; if (!context.mounted) return; ContextAlert.showToast( context, @@ -1131,13 +926,6 @@ class ServersController extends PageCubit ); } }, sourceId: source.id); - case SourceAction.test: - if (providerSource(source)) return; - await test( - context, - servers.where((row) => row.subId == source.id), - groupId: 'subscription:${source.id}', - ); case SourceAction.edit: await context.pushScoped( AppPageDestination.subscriptionEdit, @@ -1199,24 +987,6 @@ class ServersController extends PageCubit ); } - Future openSources(BuildContext context) async { - final source = await showAppDialog( - context, - (_) => ServerSourcesDialog(controller: this), - desktopMaxWidth: AppLayout.sourcesDialogWidth, - ); - if (source == null || !context.mounted || !isPageActive) return; - final action = await showSourceActionsMenu( - context, - name: source.name, - count: sourceCount(source.id), - provider: providerSource(source), - ); - if (action != null && context.mounted && isPageActive) { - await sourceAction(context, source, action); - } - } - Future openServerHelp(BuildContext context) async { final add = await showAppDialog( context, diff --git a/lib/pages/servers/details.dart b/lib/pages/servers/details.dart index b781238a4..bb9e27489 100644 --- a/lib/pages/servers/details.dart +++ b/lib/pages/servers/details.dart @@ -67,7 +67,7 @@ class ServerResourcePage extends StatelessWidget { final busy = row != null && (controller.busy || controller.serverBusy(row)); return Scaffold( - appBar: PageAppBar.forPage(context, title: Text(name)), + appBar: PageAppBar(title: Text(name)), body: SafeArea( child: ResponsiveContent( child: ListView( @@ -232,7 +232,7 @@ class ServerResourcePage extends StatelessWidget { ServerAction.copy, ), ), - if (!raw && !provider) + if (!raw) SettingRow( title: row.favorite ? l.prototypeRemoveFavorite @@ -312,13 +312,15 @@ class ServerSourcePage extends StatelessWidget { .firstOrNull; final rows = state.servers.where((row) => row.subId == sourceId).toList(); final busy = controller.sourceBusy(sourceId); + final testGroupId = 'subscription:$sourceId'; + final testing = controller.testingGroup(testGroupId); + final cancelling = controller.cancellingGroup(testGroupId); final error = state.sourceErrors[sourceId]; final package = source == null ? null : SubscriptionUserInfo.fromSubscription(source); return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(source?.name ?? l.prototypeSubscriptions), ), body: SafeArea( @@ -468,14 +470,23 @@ class ServerSourcePage extends StatelessWidget { children: [ if (!controller.providerSource(source)) SettingRow( - title: l.prototypeTestServers, - enabled: !busy && rows.isNotEmpty, + key: ValueKey('source-test-action-$sourceId'), + title: testing + ? cancelling + ? l.prototypePleaseWait + : l.prototypeCancel + : l.prototypeTestServers, + enabled: testing + ? !cancelling + : !busy && rows.isNotEmpty, leading: const Icon(LucideIcons.activity, size: 18), - onTap: () => controller.test( - context, - rows, - groupId: 'subscription:$sourceId', - ), + onTap: testing + ? () => controller.cancelTest(testGroupId) + : () => controller.test( + context, + rows, + groupId: testGroupId, + ), ), SettingRow( title: l.prototypeShareSubscription, diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart index b85e02219..792ced04c 100644 --- a/lib/pages/servers/form_page.dart +++ b/lib/pages/servers/form_page.dart @@ -39,7 +39,6 @@ class ServerFormPage extends StatelessWidget { const ServerFormPage({ super.key, required this.title, - this.subtitle, required this.body, this.actions = const [], this.expandBody = false, @@ -47,7 +46,6 @@ class ServerFormPage extends StatelessWidget { this.onClose, }); final String title; - final String? subtitle; final Widget body; final List actions; @@ -64,10 +62,8 @@ class ServerFormPage extends StatelessWidget { child: Focus( autofocus: true, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(title), - description: subtitle, leading: BackButton(onPressed: onBack ?? onClose), actions: [ if (onBack != null && onClose != null) diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index 5fec5c950..dc0699240 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -68,7 +68,6 @@ class _ServersImportPageState extends State { canPop: state.canClose, child: ServerFormPage( title: l10n.prototypeAddServersToOneXray, - subtitle: l10n.serverImportPageDescription, onClose: () => controller.closeFlow(context), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -737,8 +736,7 @@ class ServerImportScannerPage extends StatelessWidget { value: controller, child: BlocBuilder( builder: (context, state) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(AppLocalizations.of(context)!.prototypeScanQrCode), actions: [ IconButton( diff --git a/lib/pages/servers/menus.dart b/lib/pages/servers/menus.dart index f113de23c..046bc9654 100644 --- a/lib/pages/servers/menus.dart +++ b/lib/pages/servers/menus.dart @@ -22,16 +22,6 @@ Future showServerActionsMenu( ), ); -Future showSourceActionsMenu( - BuildContext context, { - required String name, - required int count, - bool provider = false, -}) => showAppDialog( - context, - (_) => SourceActionsMenu(name: name, count: count, provider: provider), -); - /// Selects an action only; the caller keeps the controller's existing workflow. class ServerActionsMenu extends StatelessWidget { const ServerActionsMenu({ @@ -101,66 +91,6 @@ class ServerActionsMenu extends StatelessWidget { } } -class SourceActionsMenu extends StatelessWidget { - const SourceActionsMenu({ - super.key, - required this.name, - required this.count, - this.provider = false, - }); - - final String name; - final int count; - final bool provider; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - return AppDialog( - title: name, - subtitle: provider - ? l.providerConfigurationCount(count) - : l.prototypeServerCount(count), - body: Column( - children: [ - _menuRow( - context, - SourceAction.update, - LucideIcons.refreshCw, - l.prototypeCheckForUpdates, - ), - if (!provider) - _menuRow( - context, - SourceAction.test, - LucideIcons.refreshCw, - l.prototypeTestServers, - ), - _menuRow( - context, - SourceAction.edit, - LucideIcons.pencil, - l.prototypeEditSubscription, - ), - _menuRow( - context, - SourceAction.share, - LucideIcons.share2, - l.prototypeShareSubscription, - ), - _menuRow( - context, - SourceAction.delete, - LucideIcons.trash2, - l.prototypeDelete, - destructive: true, - ), - ], - ), - ); - } -} - Widget _menuRow( BuildContext context, T action, diff --git a/lib/pages/servers/order/page.dart b/lib/pages/servers/order/page.dart index 58e3edddd..f40561c23 100644 --- a/lib/pages/servers/order/page.dart +++ b/lib/pages/servers/order/page.dart @@ -29,8 +29,7 @@ class ManualOrderPage extends StatelessWidget { return PopScope( canPop: !state.busy, child: Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( params.title, maxLines: 1, diff --git a/lib/pages/servers/page.dart b/lib/pages/servers/page.dart index 40bdf96c5..9d04c28b6 100644 --- a/lib/pages/servers/page.dart +++ b/lib/pages/servers/page.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/service/shared/failure.dart'; -import 'package:onexray/service/connect/settings.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -50,10 +49,8 @@ class _ServersPageState extends State { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final page = Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(l.prototypeServers), - description: l.serversPageDescription, actions: [ if (!mobile) IconButton( @@ -100,32 +97,12 @@ class ServerGroupPage extends StatelessWidget { child: BlocBuilder( builder: (context, _) { final l = AppLocalizations.of(context)!; - var group = controller + final group = controller .groups(l, filter: false) - .where((row) => row.id == params.groupId) + .where((row) => row.manual && row.id == params.groupId) .firstOrNull; - if (group == null) { - final source = controller.sources - .where( - (source) => - params.groupId == 'subscription:${source.id}' && - controller.sourceCount(source.id) == 0, - ) - .firstOrNull; - if (source != null) { - group = ServerGroup( - id: params.groupId, - name: source.name, - source: source, - selection: ServerSelection.source(source.id), - rows: const [], - visibleRows: const [], - ); - } - } return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text( group?.name ?? l.prototypeServers, maxLines: 1, diff --git a/lib/pages/servers/sources.dart b/lib/pages/servers/sources.dart index 6a6af1acf..89d854414 100644 --- a/lib/pages/servers/sources.dart +++ b/lib/pages/servers/sources.dart @@ -182,84 +182,6 @@ class ServerSourcesView extends StatelessWidget { ); } -class ServerSourcesDialog extends StatelessWidget { - const ServerSourcesDialog({super.key, required this.controller}); - - final ServersController controller; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - return AppDialog( - title: l.prototypeManageSources, - subtitle: l.prototypeSourceUpdateGuard, - body: BlocBuilder( - bloc: controller, - builder: (context, _) { - final material = MaterialLocalizations.of(context); - final localCount = controller.sourceCount(0); - String checkedAt(SubscriptionData source) => - controller.sourceCheckedLabel(l, source.timestamp) ?? - '${material.formatMediumDate(source.timestamp.toLocal())} ' - '${material.formatTimeOfDay(TimeOfDay.fromDateTime(source.timestamp.toLocal()))}'; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Align( - alignment: AlignmentDirectional.centerEnd, - child: AppActivityIndicator(pinging: false), - ), - if (controller.sources.isEmpty && localCount == 0) - Padding( - padding: const EdgeInsets.all(24), - child: Text(l.prototypeNoServersYet), - ), - if (localCount > 0) - _SourceRow( - name: l.prototypeManualAdditions, - detail: - '${l.prototypeServerCount(localCount)} · ${l.prototypeLocalOnly}', - status: l.prototypeStoredOnThisDevice, - showDivider: controller.sources.isNotEmpty, - ), - for (final source in controller.sources) - _SourceRow( - name: source.name, - package: SubscriptionUserInfo.fromSubscription(source), - detail: - '${controller.providerSource(source) ? l.providerConfigurationCount(controller.sourceCount(source.id)) : l.prototypeServerCount(controller.sourceCount(source.id))} · ' - '${checkedAt(source)}', - status: - controller.sourceErrors[source.id] ?? - l.prototypeUpdated, - failed: controller.sourceErrors.containsKey(source.id), - showDivider: source != controller.sources.last, - subscription: true, - busy: controller.sourceBusy(source.id), - onMore: controller.sourceBusy(source.id) - ? null - : () => - Navigator.of(context) - .pop(source), - onUpdate: controller.sourceBusy(source.id) - ? null - : () => controller.sourceAction( - context, - source, - SourceAction.update, - ), - ), - ], - ), - ); - }, - ), - ); - } -} - class ServerHelpDialog extends StatelessWidget { const ServerHelpDialog({super.key, required this.canScanQr}); @@ -391,128 +313,3 @@ class SourceUpdateErrorDialog extends StatelessWidget { ); } } - -class _SourceRow extends StatelessWidget { - const _SourceRow({ - required this.name, - required this.detail, - required this.status, - required this.showDivider, - this.subscription = false, - this.failed = false, - this.busy = false, - this.onMore, - this.onUpdate, - this.package, - }); - - final String name; - final String detail; - final String status; - final bool showDivider; - final bool subscription; - final bool failed; - final bool busy; - final VoidCallback? onMore; - final VoidCallback? onUpdate; - final SubscriptionUserInfo? package; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - final palette = ColorManager.palette(context); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final statusLabel = Text( - status, - style: AppTypography.serverSelectionHealth.copyWith( - color: failed ? palette.destructive : palette.running, - ), - ); - final actionStyle = IconButton.styleFrom( - foregroundColor: palette.mutedStrong, - iconSize: 18, - padding: EdgeInsets.zero, - minimumSize: Size.square( - AppLayout.touchExtent(36, platform: Theme.of(context).platform), - ), - fixedSize: Size.square( - AppLayout.touchExtent(36, platform: Theme.of(context).platform), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); - return Container( - constraints: const BoxConstraints(minHeight: 76), - padding: EdgeInsets.symmetric(horizontal: mobile ? 14 : 18, vertical: 11), - decoration: BoxDecoration( - border: showDivider - ? Border(bottom: BorderSide(color: palette.border)) - : null, - ), - child: Row( - children: [ - AppActivityBuilder( - builder: (context, activity) => - subscription && busy && activity.downloading - ? const ButtonProgressIndicator(size: 20) - : Icon(LucideIcons.link2, size: 20, color: palette.primary), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: AppTypography.serverMenuTitle.copyWith( - color: palette.foreground, - ), - ), - const SizedBox(height: 4), - Text( - detail, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTypography.serverMenuHint.copyWith( - color: palette.mutedForeground, - ), - ), - if (package != null) ...[ - const SizedBox(height: 4), - SubscriptionPackageSummary(info: package!), - ], - if (mobile) ...[const SizedBox(height: 10), statusLabel], - ], - ), - ), - if (!mobile) ...[const SizedBox(width: 10), statusLabel], - if (subscription) ...[ - if (!mobile) ...[ - const SizedBox(width: 10), - IconButton( - tooltip: l.prototypeCheckForUpdates, - style: actionStyle, - onPressed: onUpdate, - icon: AppActivityBuilder( - builder: (context, activity) => busy && activity.downloading - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.refreshCw), - ), - ), - ], - const SizedBox(width: 10), - IconButton( - tooltip: '${l.prototypeMoreActions}: $name', - style: actionStyle, - onPressed: onMore, - icon: busy && mobile - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.ellipsis), - ), - ], - ], - ), - ); - } -} diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index 1f9f59a8c..c51134c9d 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -9,7 +9,6 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/pages/servers/menus.dart'; import 'package:onexray/pages/servers/sort.dart'; -import 'package:onexray/pages/servers/subscription/user_info.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; @@ -18,7 +17,6 @@ import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/connect/coordinator.dart'; -import 'package:onexray/service/servers/subscription/user_info.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/sources.dart'; @@ -47,6 +45,7 @@ class _ServerBrowserState extends State { _ServerBrowserTab tab = _ServerBrowserTab.nodes; int sourceId = -1; bool favoritesOnly = false; + bool completeFavoritesOnly = false; int get filterSourceId => sourceId > 0 && !controller.sources.any((source) => source.id == sourceId) ? -1 @@ -212,13 +211,14 @@ class _ServerBrowserState extends State { MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final gutter = mobile ? AppSpacing.mobilePage : AppSpacing.page; final gap = mobile ? 12.0 : 16.0; + final favoriteFilter = complete ? completeFavoritesOnly : favoritesOnly; final ordinary = controller.servers .where((row) => controller.providerRow(row) == complete) .where(_matchesSource) .where((row) => controller.matches(l, row)) - .where((row) => complete || !favoritesOnly || row.favorite) + .where((row) => !favoriteFilter || row.favorite) .toList(); - final raw = complete + final raw = complete && !favoriteFilter ? controller.rawConfigurations.where(_matchesRaw).toList() : []; final count = ordinary.length + raw.length; @@ -226,7 +226,7 @@ class _ServerBrowserState extends State { final filtered = controller.query.trim().isNotEmpty || validSource != -1 || - (!complete && favoritesOnly); + favoriteFilter; final showManualTools = !complete && controller.query.trim().isEmpty && @@ -288,13 +288,6 @@ class _ServerBrowserState extends State { ServerSortButton(controller: controller), if (showManualTools) ManualServerOrderButton(controller: controller), - IconButton( - tooltip: l.prototypeFavorites, - isSelected: favoritesOnly, - onPressed: () => - setState(() => favoritesOnly = !favoritesOnly), - icon: const Icon(LucideIcons.star, size: 18), - ), const AppActivityIndicator( key: ValueKey('server-probe-activity'), downloading: false, @@ -323,6 +316,19 @@ class _ServerBrowserState extends State { icon: const Icon(LucideIcons.plus, size: 17), label: Text(l.prototypeAddRawJson), ), + IconButton( + key: const ValueKey('server-favorites-filter'), + tooltip: l.prototypeFavorites, + isSelected: favoriteFilter, + onPressed: () => setState(() { + if (complete) { + completeFavoritesOnly = !completeFavoritesOnly; + } else { + favoritesOnly = !favoritesOnly; + } + }), + icon: const Icon(LucideIcons.star, size: 18), + ), ], ), if (showManualTools) ...[ @@ -381,6 +387,7 @@ class _ServerBrowserState extends State { setState(() { sourceId = -1; favoritesOnly = false; + completeFavoritesOnly = false; }); }, child: Text(l.prototypeClear), @@ -489,11 +496,7 @@ class ServerManualGroupsPage extends StatelessWidget { .where((group) => group.manual) .toList(); return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l.manualGroupsTitle), - description: l.manualGroupsDescription, - ), + appBar: PageAppBar(title: Text(l.manualGroupsTitle)), body: SafeArea( child: ResponsiveContent( child: ListView( @@ -572,8 +575,7 @@ class ServerManualGroupsPage extends StatelessWidget { size: 18, ), ), - onTap: () => - controller.browse(context, group, mobile: true), + onTap: () => controller.browse(context, group), ), ], ), @@ -635,13 +637,11 @@ class ServerGroupView extends StatelessWidget { final ServersController controller; final ServerGroup group; final bool groupPage; - final bool embedded; const ServerGroupView({ super.key, required this.controller, required this.group, this.groupPage = false, - this.embedded = false, }); @override @@ -649,9 +649,6 @@ class ServerGroupView extends StatelessWidget { final mobile = MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; final card = _card(context, mobile); - if (embedded) { - return card; - } return Padding( padding: mobile ? const EdgeInsets.fromLTRB(16, 16, 16, 28) @@ -668,10 +665,7 @@ class ServerGroupView extends StatelessWidget { Widget _card(BuildContext context, bool mobile) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - final package = group.source == null - ? null - : SubscriptionUserInfo.fromSubscription(group.source!); - final headerCount = package == null ? 1 : 2; + const headerCount = 1; return Material( color: palette.card, shape: RoundedRectangleBorder( @@ -683,7 +677,7 @@ class ServerGroupView extends StatelessWidget { padding: const EdgeInsets.all(1), child: ListView.builder( key: PageStorageKey('servers:${group.id}'), - primary: !embedded, + primary: true, padding: EdgeInsets.zero, itemCount: headerCount + @@ -724,44 +718,13 @@ class ServerGroupView extends StatelessWidget { ), ); } - if (index == 1 && package != null) { - return SubscriptionPackageDetails(info: package); - } if (group.visibleRows.isEmpty) { return Padding( padding: const EdgeInsets.symmetric( horizontal: 18, vertical: 28, ), - child: group.source != null && group.rows.isEmpty - ? Column( - children: [ - Text( - controller.providerGroup(group) - ? l.subscriptionModeProviderManaged - : l.subscriptionModeAppManaged, - textAlign: TextAlign.center, - style: AppTypography.rowTitle, - ), - const SizedBox(height: 8), - Text( - l.subscriptionModeNoCacheTitle, - textAlign: TextAlign.center, - style: AppTypography.rowValue.copyWith( - color: palette.mutedForeground, - ), - ), - const SizedBox(height: 8), - Text( - l.subscriptionModeNeedsRefresh, - textAlign: TextAlign.center, - style: AppTypography.serverDetail.copyWith( - color: palette.mutedForeground, - ), - ), - ], - ) - : group.manual && group.rows.isEmpty + child: group.rows.isEmpty ? Column( children: [ Text( @@ -824,26 +787,13 @@ class ServerGroupView extends StatelessWidget { color: palette.mutedForeground, ), ), - if (controller.providerGroup(group)) ...[ - const SizedBox(height: 4), - Text( - l.providerChooseConfigurationHint, - style: AppTypography.serverDetail.copyWith( - color: palette.mutedForeground, - ), - ), - ], ], ), ); return Row( mainAxisSize: mobile ? MainAxisSize.max : MainAxisSize.min, children: [ - Icon( - group.source == null ? LucideIcons.folder : LucideIcons.rss, - size: 20, - color: palette.mutedForeground, - ), + Icon(LucideIcons.folder, size: 20, color: palette.mutedForeground), const SizedBox(width: 13), if (mobile) Expanded(child: label) else Flexible(child: label), ], @@ -853,63 +803,65 @@ class ServerGroupView extends StatelessWidget { Widget _actions(BuildContext context, bool mobile) { final l = AppLocalizations.of(context)!; final palette = ColorManager.palette(context); - final testing = controller.testingGroup(group); - final cancelling = controller.cancellingGroup(group); + final testing = controller.testingGroup(group.id); + final cancelling = controller.cancellingGroup(group.id); return Wrap( alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (!controller.providerGroup(group)) - OutlinedButton( - onPressed: testing - ? cancelling - ? null - : () => controller.cancelTest(group.id) - : group.rows.isEmpty || group.rows.any(controller.serverBusy) - ? null - : () => controller.test(context, group.rows, groupId: group.id), - style: OutlinedButton.styleFrom( - minimumSize: Size( - 0, - AppLayout.touchExtent( - mobile - ? AppLayout.mobileButtonMinHeight - : AppLayout.buttonMinHeight, - platform: Theme.of(context).platform, - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 15), - foregroundColor: palette.foreground, - backgroundColor: palette.card, - side: BorderSide(color: palette.borderStrong), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.control), + OutlinedButton( + onPressed: testing + ? cancelling + ? null + : () => controller.cancelTest(group.id) + : group.rows.isEmpty || group.rows.any(controller.serverBusy) + ? null + : () => controller.test(context, group.rows, groupId: group.id), + style: OutlinedButton.styleFrom( + minimumSize: Size( + 0, + AppLayout.touchExtent( + mobile + ? AppLayout.mobileButtonMinHeight + : AppLayout.buttonMinHeight, + platform: Theme.of(context).platform, ), - textStyle: AppTypography.serverGroupAction, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.standard, ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppActivityBuilder( - builder: (context, activity) => activity.pinging - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.refreshCw, size: 16), - ), - const SizedBox(width: 8), - Text( + padding: const EdgeInsets.symmetric(horizontal: 15), + foregroundColor: palette.foreground, + backgroundColor: palette.card, + side: BorderSide(color: palette.borderStrong), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadii.control), + ), + textStyle: AppTypography.serverGroupAction, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.standard, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppActivityBuilder( + builder: (context, activity) => activity.pinging + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.refreshCw, size: 16), + ), + const SizedBox(width: 8), + Flexible( + child: Text( testing ? cancelling ? l.prototypePleaseWait : l.prototypeCancel : l.prototypeTestServers, + textAlign: TextAlign.center, ), - ], - ), + ), + ], ), + ), if (group.manual && group.rows.length > 1) Tooltip( message: controller.query.trim().isNotEmpty @@ -925,8 +877,6 @@ class ServerGroupView extends StatelessWidget { label: Text(l.manualServerOrder), ), ), - if (!controller.providerGroup(group) && group.selection != null) - ServerUseButton(controller: controller, group: group), if (group.manualGroupId != null) IconButton( tooltip: '${l.prototypeMoreActions}: ${group.name}', @@ -939,159 +889,11 @@ class ServerGroupView extends StatelessWidget { ), icon: const Icon(LucideIcons.ellipsis, size: 18), ), - if (group.source != null) - SizedBox( - width: 36, - child: SourceMenu(controller: controller, source: group.source!), - ), ], ); } } -class ServerUseButton extends StatelessWidget { - final ServersController controller; - final ServerGroup group; - final bool inline; - const ServerUseButton({ - super.key, - required this.controller, - required this.group, - this.inline = false, - }); - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - if (controller.providerGroup(group)) { - final VoidCallback? browse = group.visibleRows.isEmpty - ? null - : () => controller.browse( - context, - group, - mobile: - MediaQuery.sizeOf(context).width <= - AppLayout.mobileBreakpoint, - ); - if (inline) { - return IconButton( - tooltip: l.providerChooseConfiguration, - onPressed: browse, - icon: const Icon(LucideIcons.list, size: 18), - ); - } - return OutlinedButton.icon( - onPressed: browse, - icon: const Icon(LucideIcons.list, size: 16), - label: Text(l.providerChooseConfiguration), - ); - } - final selection = group.selection; - if (selection == null) return const SizedBox.shrink(); - final count = controller.entryCount(selection); - final enabled = controller.canUse(group); - final selected = controller.selected(selection); - final VoidCallback? choose = controller.busy || !enabled - ? null - : () => controller.choose(context, selection); - final mobile = - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint; - final palette = ColorManager.palette(context); - return Semantics( - label: '${l.prototypeUseEntryServers(count)}: ${group.name}', - button: true, - selected: selected, - enabled: choose != null, - onTap: choose, - excludeSemantics: true, - child: Tooltip( - message: enabled - ? l.prototypeUseEntryServers(count) - : l.prototypeNotEnoughServers, - child: Opacity( - opacity: choose == null ? .52 : 1, - child: OutlinedButton( - onPressed: choose, - style: OutlinedButton.styleFrom( - minimumSize: Size( - 0, - AppLayout.touchExtent( - mobile - ? AppLayout.mobileButtonMinHeight - : inline - ? 32 - : 36, - platform: Theme.of(context).platform, - ), - ), - padding: EdgeInsets.symmetric( - horizontal: !mobile && inline ? 7 : 9, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.standard, - foregroundColor: selected ? palette.primary : palette.foreground, - backgroundColor: selected - ? palette.selectedSurface - : palette.card, - side: BorderSide( - color: selected - ? Color.lerp(palette.border, palette.primary, .48)! - : inline - ? palette.border - : palette.borderStrong, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadii.control), - ), - textStyle: inline - ? AppTypography.serverUseLabel - : AppTypography.serverGroupAction, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (controller.selectingGroup(selection)) - const ButtonProgressIndicator() - else - Icon( - selected ? LucideIcons.circleCheck : LucideIcons.zap, - size: inline ? 15 : 16, - ), - const SizedBox(width: 6), - Text(l.prototypeUse), - const SizedBox(width: 6), - Container( - constraints: const BoxConstraints(minWidth: 20), - height: 20, - alignment: Alignment.center, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadii.pill), - color: selected - ? palette.primarySolid - : palette.surfaceHover, - ), - child: Text( - '$count', - style: - (inline - ? AppTypography.serverUseCount - : AppTypography.serverGroupUseCount) - .copyWith( - color: selected - ? palette.primaryForeground - : palette.mutedStrong, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - class ServerNodeRow extends StatelessWidget { final ServersController controller; final CoreConfigData row; @@ -1271,23 +1073,19 @@ class ServerNodeRow extends StatelessWidget { color: p.primary, ), ), - if (!controller.providerRow(row)) - IconButton( - tooltip: row.favorite - ? l.prototypeRemoveFavorite - : l.prototypeAddFavorite, - onPressed: controller.serverBusy(row) - ? null - : () => controller.toggleFavorite(context, row), - isSelected: row.favorite, - icon: controller.favoritingIds.contains(row.id) - ? const ButtonProgressIndicator() - : const Icon(LucideIcons.star, size: 17), - selectedIcon: const Icon( - LucideIcons.star600, - size: 17, - ), - ), + IconButton( + tooltip: row.favorite + ? l.prototypeRemoveFavorite + : l.prototypeAddFavorite, + onPressed: controller.serverBusy(row) + ? null + : () => controller.toggleFavorite(context, row), + isSelected: row.favorite, + icon: controller.favoritingIds.contains(row.id) + ? const ButtonProgressIndicator() + : const Icon(LucideIcons.star, size: 17), + selectedIcon: const Icon(LucideIcons.star600, size: 17), + ), ServerMenu(controller: controller, row: row), ], ), @@ -1347,47 +1145,3 @@ class ServerMenu extends StatelessWidget { ); } } - -class SourceMenu extends StatelessWidget { - final ServersController controller; - final SubscriptionData source; - const SourceMenu({super.key, required this.controller, required this.source}); - - static Future open( - BuildContext context, - ServersController controller, - SubscriptionData source, - ) async { - final action = await showSourceActionsMenu( - context, - name: source.name, - count: controller.sourceCount(source.id), - provider: controller.providerSource(source), - ); - if (context.mounted && action != null) { - await controller.sourceAction(context, source, action); - } - } - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context)!; - return IconButton( - tooltip: '${l.prototypeMoreActions}: ${source.name}', - onPressed: controller.sourceBusy(source.id) - ? null - : () => open(context, controller, source), - style: IconButton.styleFrom( - foregroundColor: ColorManager.palette(context).mutedStrong, - iconSize: 18, - padding: EdgeInsets.zero, - minimumSize: Size.square( - AppLayout.touchExtent(36, platform: Theme.of(context).platform), - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - shape: const RoundedRectangleBorder(), - ), - icon: const Icon(LucideIcons.ellipsis), - ); - } -} diff --git a/lib/pages/settings/about/page.dart b/lib/pages/settings/about/page.dart index a4b653d08..9e0b74ee3 100644 --- a/lib/pages/settings/about/page.dart +++ b/lib/pages/settings/about/page.dart @@ -25,11 +25,7 @@ class AboutOneXrayPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final controller = context.read(); return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l10n.prototypeAboutOneXray), - description: l10n.prototypeCrossPlatformXrayClient, - ), + appBar: PageAppBar(title: Text(l10n.prototypeAboutOneXray)), body: SafeArea( child: SettingsPageScroll( desktopMaxWidth: AppLayout.routingMaxWidth, diff --git a/lib/pages/settings/app_icon/page.dart b/lib/pages/settings/app_icon/page.dart index d53509711..555124132 100644 --- a/lib/pages/settings/app_icon/page.dart +++ b/lib/pages/settings/app_icon/page.dart @@ -27,9 +27,7 @@ class AppIconPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final useDockIconLabel = AppPlatform.isMacOS; return Scaffold( - appBar: PageAppBar.forPage( - context, - description: l10n.settingsDevicePreferenceDescription, + appBar: PageAppBar( title: Text(l10n.prototypeAppIcon), leading: BackButton(onPressed: () => controller.cancel(context)), ), diff --git a/lib/pages/settings/backup/page.dart b/lib/pages/settings/backup/page.dart index 85707b6f4..9c02cca18 100644 --- a/lib/pages/settings/backup/page.dart +++ b/lib/pages/settings/backup/page.dart @@ -48,9 +48,7 @@ class BackupPage extends StatelessWidget { .add_Hm(); final busy = state.busy; return Scaffold( - appBar: PageAppBar.forPage( - context, - description: l.backupPageDescription, + appBar: PageAppBar( title: Text(l.backupTitle), actions: [ PageHelpButton( diff --git a/lib/pages/settings/desktop/page.dart b/lib/pages/settings/desktop/page.dart index 6c71c5363..b815b56b7 100644 --- a/lib/pages/settings/desktop/page.dart +++ b/lib/pages/settings/desktop/page.dart @@ -36,7 +36,6 @@ class DesktopSettingsPage extends StatelessWidget { if (!(showDesktopOptions ?? AppPlatform.isDesktop)) { return SettingsPageScaffold( title: l.prototypeStartup, - description: l.settingsDevicePreferenceDescription, body: SettingsPageScroll( padding: EdgeInsets.zero, child: Padding( @@ -58,7 +57,6 @@ class DesktopSettingsPage extends StatelessWidget { final controller = context.read(); return SettingsPageScaffold( title: l.prototypeStartup, - description: l.settingsDevicePreferenceDescription, body: DesktopSettingsView( state: state, connectOnLaunch: connect, diff --git a/lib/pages/settings/language/page.dart b/lib/pages/settings/language/page.dart index 5fc0b9cda..f551a0308 100644 --- a/lib/pages/settings/language/page.dart +++ b/lib/pages/settings/language/page.dart @@ -25,9 +25,7 @@ class LanguagePage extends StatelessWidget { final controller = context.read(); final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: PageAppBar.forPage( - context, - description: l10n.settingsDevicePreferenceDescription, + appBar: PageAppBar( title: Text(l10n.prototypeLanguage), leading: BackButton(onPressed: () => controller.cancel(context)), ), diff --git a/lib/pages/settings/page.dart b/lib/pages/settings/page.dart index a3df62366..9106b91ad 100644 --- a/lib/pages/settings/page.dart +++ b/lib/pages/settings/page.dart @@ -32,11 +32,7 @@ class SettingsPage extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final controller = context.read(); return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l10n.prototypeSettings), - description: l10n.settingsOverviewDescription, - ), + appBar: PageAppBar(title: Text(l10n.prototypeSettings)), body: SafeArea( child: BlocBuilder( builder: (context, preferences) { diff --git a/lib/pages/settings/theme/controller.dart b/lib/pages/settings/theme/controller.dart deleted file mode 100644 index 07e279d83..000000000 --- a/lib/pages/settings/theme/controller.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:onexray/service/shared/failure.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/shared/alert.dart'; -import 'package:onexray/pages/shared/page_cubit.dart'; -import 'package:go_router/go_router.dart'; -import 'package:onexray/service/shared/event_bus/enum.dart'; -import 'package:onexray/service/shared/event_bus/service.dart'; - -class ThemePageState { - final ThemeCode themeCode; - - const ThemePageState({this.themeCode = ThemeCode.system}); - - ThemePageState copyWith({ThemeCode? themeCode}) { - return ThemePageState(themeCode: themeCode ?? this.themeCode); - } -} - -class ThemeController extends PageCubit { - ThemeController() : super(const ThemePageState()) { - _readData(); - } - - Future _readData() async { - final eventBus = AppEventBus.instance; - emit(state.copyWith(themeCode: eventBus.state.themeCode)); - } - - void updateThemeCode(ThemeCode? value) { - if (value != null) { - emit(state.copyWith(themeCode: value)); - } - } - - Future save(BuildContext context) async { - final l = AppLocalizations.of(context)!; - try { - await AppEventBus.instance.updateThemeCode(state.themeCode); - if (context.mounted) { - ContextAlert.showToast(context, l.prototypeSettingsSaved); - if (ModalRoute.of(context)?.isCurrent == true) context.pop(); - } - } catch (error) { - if (context.mounted) { - ContextAlert.showToast( - context, - appFailureMessage(l, error, operation: l.buttonSaveFailed), - ); - } - } - } -} diff --git a/lib/pages/settings/theme/page.dart b/lib/pages/settings/theme/page.dart deleted file mode 100644 index d2300d0d3..000000000 --- a/lib/pages/settings/theme/page.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:material_ui/material_ui.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/settings/theme/controller.dart'; -import 'package:onexray/pages/shared/widgets/setting_row.dart'; -import 'package:onexray/pages/shared/widgets/settings_page.dart'; -import 'package:onexray/service/shared/event_bus/enum.dart'; -import 'package:onexray/pages/settings/widgets.dart'; -import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; -import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class ThemePage extends StatelessWidget { - const ThemePage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (_) => ThemeController(), - child: BlocBuilder( - builder: (context, state) { - final controller = context.read(); - final l10n = AppLocalizations.of(context)!; - return Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(l10n.themePageTitle), - description: l10n.settingsDevicePreferenceDescription, - ), - body: SafeArea( - child: ThemeChoiceView( - selected: state.themeCode, - onSelected: controller.updateThemeCode, - ), - ), - bottomNavigationBar: PageActionBar( - children: [ - ShadButton.outline( - onPressed: () => Navigator.maybePop(context), - child: Text(l10n.prototypeCancel), - ), - ShadButton( - onPressed: () => controller.save(context), - child: Text(l10n.buttonSave), - ), - ], - ), - ); - }, - ), - ); - } -} - -class ThemeChoiceView extends StatelessWidget { - final ThemeCode selected; - final ValueChanged onSelected; - - const ThemeChoiceView({ - super.key, - required this.selected, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return SettingsPageScroll( - padding: EdgeInsets.zero, - child: Padding( - padding: settingsContentPadding(context), - child: SettingSection( - title: l10n.themePageTitle, - padding: EdgeInsets.zero, - children: [ - SettingsThemeOptions(selected: selected, onSelected: onSelected), - ], - ), - ), - ); - } -} diff --git a/lib/pages/shared/widgets/page_app_bar.dart b/lib/pages/shared/widgets/page_app_bar.dart index feadedf6f..a1b7eae83 100644 --- a/lib/pages/shared/widgets/page_app_bar.dart +++ b/lib/pages/shared/widgets/page_app_bar.dart @@ -12,55 +12,14 @@ class PageAppBar extends StatelessWidget implements PreferredSizeWidget { const PageAppBar({ super.key, required this.title, - this.leading, - this.actions, - this.bottom, - }) : description = null, - headingAction = null, - inlineHeadingAction = false; - - const PageAppBar._({ - super.key, - required this.title, - this.description, this.headingAction, - this.inlineHeadingAction = false, this.leading, this.actions, this.bottom, }); - /// Retains the page API while placing its title and actions in one AppBar. - /// Page introductions no longer add a separate heading below the toolbar. - factory PageAppBar.forPage( - BuildContext context, { - Key? key, - required Widget title, - String? description, - Widget? headingAction, - bool inlineHeadingAction = false, - Widget? leading, - List? actions, - PreferredSizeWidget? bottom, - }) => PageAppBar._( - key: key, - title: title, - description: description, - headingAction: headingAction, - inlineHeadingAction: inlineHeadingAction, - leading: leading, - actions: actions, - bottom: bottom, - ); - final Widget title; - - /// Kept for source compatibility; repeated page introductions are not drawn. - final String? description; final Widget? headingAction; - - /// Kept for source compatibility; all actions now share the standard toolbar. - final bool inlineHeadingAction; final Widget? leading; final List? actions; final PreferredSizeWidget? bottom; diff --git a/lib/pages/shared/widgets/settings_page.dart b/lib/pages/shared/widgets/settings_page.dart index 321d4e0d2..6f82af60b 100644 --- a/lib/pages/shared/widgets/settings_page.dart +++ b/lib/pages/shared/widgets/settings_page.dart @@ -13,7 +13,6 @@ import 'package:shadcn_ui/shadcn_ui.dart'; class SettingsPageScaffold extends StatelessWidget { final String title; - final String? description; final Widget body; final VoidCallback? onSave; final bool saveLoading; @@ -23,7 +22,6 @@ class SettingsPageScaffold extends StatelessWidget { const SettingsPageScaffold({ super.key, required this.title, - this.description, required this.body, this.onSave, this.saveLoading = false, @@ -34,10 +32,8 @@ class SettingsPageScaffold extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(title), - description: description, actions: [ ...actions, if (onSave != null || saveLoading) _saveButton(context), diff --git a/lib/service/connect/settings.dart b/lib/service/connect/settings.dart index b78ea046f..14a0b3029 100644 --- a/lib/service/connect/settings.dart +++ b/lib/service/connect/settings.dart @@ -118,9 +118,6 @@ class SmartRoutingSettings { sniffing.validate(fakeDns: fakeDns); selection.validate(); } - - String get effectiveDirectDnsAddress => - directDns ? directDnsAddress.trim() : RoutingDnsSettings.defaultAddress; } /// Retains the normal selection while Raw is active. The coordinator stores diff --git a/lib/service/servers/server.dart b/lib/service/servers/server.dart index a3d4a7f86..8bf9ecfbb 100644 --- a/lib/service/servers/server.dart +++ b/lib/service/servers/server.dart @@ -80,9 +80,9 @@ class ServerAssetService { _subscriptions = subscriptions ?? SubscriptionService(), _schedule = schedule ?? PingService().schedulePingConfigIds; - Stream> watch() => db.coreConfigDao.watchServers(); + Stream> _watch() => db.coreConfigDao.watchServers(); - Future> rows() => + Future> _rows() => (db.select(db.coreConfig)..where( (row) => row.type.isIn([ CoreConfigType.outbound.name, @@ -91,10 +91,6 @@ class ServerAssetService { )) .get(); - static bool measured(CoreConfigData row) => - row.type == CoreConfigType.outbound.name && - row.delay != PingDelayConstants.unknown; - static bool healthy(CoreConfigData row) => row.type == CoreConfigType.outbound.name && PingDelayConstants.isSuccessful(row.delay); @@ -111,15 +107,6 @@ class ServerAssetService { } } - static String protocolLabel(CoreConfigData row) { - if (row.type != CoreConfigType.outbound.name) return ''; - try { - return outboundProtocolLabel(readOutboundFromDbData(row)); - } on FormatException { - return ''; - } - } - Future load(int id) async { final row = await db.coreConfigDao.searchRow(id); if (row == null || row.type != 'outbound') { @@ -312,7 +299,7 @@ class ServerAssetService { throw const FormatException('Manual group no longer exists'); } final current = await coordinator.readForEditing(); - final all = await rows(); + final all = await _rows(); final removed = manualGroupId != null ? all .where( @@ -418,7 +405,7 @@ class ServerAssetService { throw const FormatException('Server became active before deletion'); } if (preview.sourceId case final id?) { - final currentIds = (await rows()) + final currentIds = (await _rows()) .where((row) => row.subId == id) .map((row) => row.id) .toSet(); @@ -432,7 +419,7 @@ class ServerAssetService { } else { if (preview.manualGroupId case final groupId?) { final group = await db.manualServerGroupDao.searchRow(groupId); - final currentIds = (await rows()) + final currentIds = (await _rows()) .where( (row) => ManualServerGroupService.isManual(row) && @@ -472,7 +459,7 @@ class ServerAssetService { ConnectionPreparation( db: db, resolver: ConnectionResolver( - rows: () => watch().map( + rows: () => _watch().map( (rows) => rows.where((row) => !removed.contains(row.id)).toList(), ), ), diff --git a/test/pages/advanced/accessibility_layout_test.dart b/test/pages/advanced/accessibility_layout_test.dart index 852919143..268921b05 100644 --- a/test/pages/advanced/accessibility_layout_test.dart +++ b/test/pages/advanced/accessibility_layout_test.dart @@ -1,15 +1,21 @@ import 'package:drift/native.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/pages/advanced/page.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:onexray/pages/main/adaptive_shell.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; import 'package:onexray/pages/advanced/tunnel/controller.dart'; import 'package:onexray/pages/advanced/tunnel/page.dart'; import 'package:onexray/pages/advanced/tunnel/widgets.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/service/advanced/policy_editor.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; @@ -76,68 +82,116 @@ void _expectCompleteText(WidgetTester tester, Finder finder) { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // Font geometry matters here: Ahem makes long Cyrillic labels one glyph + // per line. Use the same bundled font family as the production shell. + final font = FontLoader(AppFontFamily.sans) + ..addFont(rootBundle.load('packages/shadcn_ui/fonts/Geist[wght].ttf')); + await font.load(); + }); + for (final locale in [const Locale('zh'), const Locale('ru')]) { for (final scale in [1.0, 3.2]) { testWidgets( - 'fixed Advanced tabs fit $locale at $scale on a small phone', + 'tunnel and Xray primary navigation stays reachable in $locale at $scale', (tester) async { _size(tester, const Size(375, 667)); - await tester.pumpWidget( - _app( - const AdvancedPageView( - tunnel: SingleChildScrollView( - child: Column( - children: [ - Text('tunnel-body-start'), - SizedBox(height: 1000), - Text('tunnel-body-end'), - ], - ), + final bus = AppEventBus(); + final router = GoRouter( + initialLocation: AppPrimaryDestination.tunnel.rootPath, + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => AdaptiveMainShell( + navigationShell: shell, + hasActiveModal: (_) => false, + initializeServices: (_) async {}, ), - xray: Text('xray-body'), + branches: [ + for (final primary in AppPrimaryDestination.values) + StatefulShellBranch( + routes: [ + GoRoute( + path: primary.rootPath, + builder: (_, _) => SingleChildScrollView( + child: Column( + children: [ + Text('${primary.name}-body-start'), + const SizedBox(height: 1000), + Text('${primary.name}-body-end'), + ], + ), + ), + ), + ], + ), + ], ), - locale: locale, - scale: scale, - ), + ], ); - await tester.pumpAndSettle(); - final l = AppLocalizations.of( - tester.element(find.byType(AdvancedPageView)), - )!; - final tabs = find.byType(TabBar); - final tabBounds = tester.getRect(tabs); - for (final label in [ - l.prototypeVpnTunnel, - l.prototypeXrayRuntimeDiagnostics, - ]) { - _expectCompleteText(tester, find.text(label)); - final labelBounds = tester.getRect(find.text(label)); - expect(labelBounds.top, greaterThanOrEqualTo(tabBounds.top)); - expect(labelBounds.bottom, lessThanOrEqualTo(tabBounds.bottom)); + try { + await tester.pumpWidget( + BlocProvider.value( + value: bus, + child: MaterialApp.router( + routerConfig: router, + theme: AppTheme.material(Brightness.light, mobile: true), + locale: locale, + localizationsDelegates: + AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(scale)), + child: ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: true), + child: child!, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final l = AppLocalizations.of( + tester.element(find.byType(AdaptiveMainShell)), + )!; + expect(find.byType(TabBar), findsNothing); + final navigation = find.byKey( + const ValueKey('primary-mobile-navigation'), + ); + final navigationBounds = tester.getRect(navigation); + for (final (destination, label) in [ + (AppPrimaryDestination.tunnel, l.prototypeVpnTunnel), + (AppPrimaryDestination.xray, 'Xray'), + ]) { + final item = find.byKey( + ValueKey('primary-navigation-${destination.name}'), + ); + expect(tester.widget(item).properties.label, label); + expect(navigationBounds.contains(tester.getCenter(item)), isTrue); + await tester.tap(item); + await tester.pumpAndSettle(); + expect( + find.text('${destination.name}-body-start'), + findsOneWidget, + ); + expect( + tester.widget(item).properties.selected, + isTrue, + ); + await tester.drag( + find.byType(SingleChildScrollView).first, + const Offset(0, -180), + ); + await tester.pumpAndSettle(); + expect(tester.getRect(navigation), navigationBounds); + } + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + router.dispose(); + await bus.close(); } - expect( - tester.getTopLeft(find.text('tunnel-body-start')).dy, - greaterThanOrEqualTo(tabBounds.bottom), - ); - await tester.drag( - find - .ancestor( - of: find.text('tunnel-body-start'), - matching: find.byType(SingleChildScrollView), - ) - .first, - const Offset(0, -180), - ); - await tester.pumpAndSettle(); - expect(tester.getRect(tabs), tabBounds); - await tester.ensureVisible( - find.text(l.prototypeXrayRuntimeDiagnostics), - ); - await tester.tap(find.text(l.prototypeXrayRuntimeDiagnostics)); - await tester.pumpAndSettle(); - expect(find.text('xray-body'), findsOneWidget); - expect(tester.takeException(), isNull); - await tester.pumpWidget(const SizedBox()); }, ); } diff --git a/test/pages/advanced/tunnel/root_page_test.dart b/test/pages/advanced/tunnel/root_page_test.dart index 55852c580..24dcc7321 100644 --- a/test/pages/advanced/tunnel/root_page_test.dart +++ b/test/pages/advanced/tunnel/root_page_test.dart @@ -80,8 +80,7 @@ void main() { _app( Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(AppLocalizations.of(context)!.prototypeVpnTunnel), ), body: VpnTunnelPane( diff --git a/test/pages/advanced/xray/dialing_test.dart b/test/pages/advanced/xray/dialing_test.dart index 76fec2ba4..58e31ed26 100644 --- a/test/pages/advanced/xray/dialing_test.dart +++ b/test/pages/advanced/xray/dialing_test.dart @@ -277,7 +277,6 @@ void main() { onGeodata: (_) {}, onUpdates: (_) {}, onSpeedTest: (_) {}, - onLog: (_, _) {}, onConfig: (_, _) {}, onServerDialing: (context) { opened = true; diff --git a/test/pages/advanced/xray/lan_proxy_test.dart b/test/pages/advanced/xray/lan_proxy_test.dart index 7b1b2e9cf..6355c4512 100644 --- a/test/pages/advanced/xray/lan_proxy_test.dart +++ b/test/pages/advanced/xray/lan_proxy_test.dart @@ -202,7 +202,6 @@ void main() { onGeodata: (_) {}, onUpdates: (_) {}, onSpeedTest: (_) {}, - onLog: (_, _) {}, onConfig: (_, _) {}, onLanProxy: (_) async {}, ), diff --git a/test/pages/advanced/xray/log_test.dart b/test/pages/advanced/xray/log_test.dart index 946f6d65c..11015365a 100644 --- a/test/pages/advanced/xray/log_test.dart +++ b/test/pages/advanced/xray/log_test.dart @@ -62,7 +62,6 @@ void main() { onGeodata: (_) {}, onUpdates: (_) {}, onSpeedTest: (_) {}, - onLog: (_, _) {}, onConfig: (_, _) {}, onLogSettings: (_) async => null, ), diff --git a/test/pages/advanced/xray/uptime_test.dart b/test/pages/advanced/xray/uptime_test.dart index 28dcb25be..56265df19 100644 --- a/test/pages/advanced/xray/uptime_test.dart +++ b/test/pages/advanced/xray/uptime_test.dart @@ -3,7 +3,16 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/pages/advanced/controller.dart'; -import 'package:onexray/pages/advanced/tab_visibility.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/main/adaptive_shell.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/main/page_visibility.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'dart:convert'; @@ -101,9 +110,11 @@ void main() { }, ); - testWidgets('xray uptime stops after switching to the tunnel tab', ( + testWidgets('xray uptime follows primary navigation and covered routes', ( tester, ) async { + await tester.binding.setSurfaceSize(const Size(1160, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); final db = AppDatabase.forTesting(NativeDatabase.memory()); final coordinator = ConnectionCoordinator(database: db); var now = DateTime(2026, 9, 3); @@ -115,42 +126,96 @@ void main() { coordinator: coordinator, now: () => now, ); - late BuildContext tabsContext; - await tester.pumpWidget( - MaterialApp( - home: DefaultTabController( - length: 2, - initialIndex: 1, - child: Builder( - builder: (context) { - tabsContext = context; - return AdvancedTabVisibility( - tabIndex: 1, - onChanged: controller.setVisible, - child: const SizedBox(), - ); - }, + final bus = AppEventBus(); + final router = GoRouter( + initialLocation: AppPrimaryDestination.xray.rootPath, + routes: [ + StatefulShellRoute.indexedStack( + builder: (_, _, shell) => AdaptiveMainShell( + navigationShell: shell, + hasActiveModal: (_) => false, + initializeServices: (_) async {}, ), + branches: [ + for (final primary in AppPrimaryDestination.values) + StatefulShellBranch( + routes: [ + GoRoute( + path: primary.rootPath, + builder: (_, _) => primary == AppPrimaryDestination.xray + ? PageVisibility( + onChanged: controller.setVisible, + child: const Text('xray-content'), + ) + : Text('${primary.name}-content'), + routes: [ + GoRoute( + path: AppPageDestination.xrayDiagnostics.segment, + builder: (_, _) => const Text('diagnostics-content'), + ), + ], + ), + ], + ), + ], ), - ), + ], ); - await tester.pump(); - expect(controller.state.uptime, '0:01:00'); - - now = now.add(const Duration(seconds: 1)); - await tester.pump(const Duration(seconds: 1)); - expect(controller.state.uptime, '0:01:01'); - - DefaultTabController.of(tabsContext).animateTo(0, duration: Duration.zero); - await tester.pump(); - now = now.add(const Duration(seconds: 3)); - await tester.pump(const Duration(seconds: 3)); - expect(controller.state.uptime, '0:01:01'); - - await tester.pumpWidget(const SizedBox()); - await controller.close(); - coordinator.dispose(); - await db.close(); + try { + await tester.pumpWidget( + BlocProvider.value( + value: bus, + child: MaterialApp.router( + routerConfig: router, + theme: AppTheme.material(Brightness.light, mobile: false), + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light, mobile: false), + child: child!, + ), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.state.uptime, '0:01:00'); + + now = now.add(const Duration(seconds: 1)); + await tester.pump(const Duration(seconds: 1)); + expect(controller.state.uptime, '0:01:01'); + + await tester.tap(find.byKey(const ValueKey('primary-navigation-tunnel'))); + await tester.pumpAndSettle(); + now = now.add(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 3)); + expect(find.text('tunnel-content'), findsOneWidget); + expect(controller.state.uptime, '0:01:01'); + + await tester.tap(find.byKey(const ValueKey('primary-navigation-xray'))); + await tester.pumpAndSettle(); + expect(controller.state.uptime, '0:01:04'); + + final detail = router.push( + '/xray/${AppPageDestination.xrayDiagnostics.segment}', + ); + await tester.pumpAndSettle(); + now = now.add(const Duration(seconds: 3)); + await tester.pump(const Duration(seconds: 3)); + expect(find.text('diagnostics-content'), findsOneWidget); + expect(controller.state.uptime, '0:01:04'); + router.pop(); + await tester.pumpAndSettle(); + await detail; + expect(controller.state.uptime, '0:01:07'); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + router.dispose(); + await bus.close(); + await controller.close(); + coordinator.dispose(); + await db.close(); + } }); } diff --git a/test/pages/connect/configuration_sync_test.dart b/test/pages/connect/configuration_sync_test.dart index 440ec5ab0..0312b2fee 100644 --- a/test/pages/connect/configuration_sync_test.dart +++ b/test/pages/connect/configuration_sync_test.dart @@ -183,7 +183,7 @@ Widget _screen(ConnectController controller) => MaterialApp( return ConnectView( view: state.connectionView, hasServers: state.servers.isNotEmpty, - expert: state.expertView, + expert: state.configuration.connection.expert, raws: state.raws, activeRawId: state.configuration.connection.expert ? state.configuration.connection.rawId @@ -197,14 +197,11 @@ Widget _screen(ConnectController controller) => MaterialApp( !state.configuration.connection.expert && !controller.providerConfiguration, onConnection: () => controller.connectionAction(context), - onExpert: (value) => controller.toggleExpert(context, value), onAddServers: () {}, onServer: () => controller.chooseServer(context), onMethod: () {}, onWhy: () {}, onRawAdd: () {}, - onRawSelect: (_) {}, - onRawActions: (_) {}, ); }, ), @@ -265,7 +262,7 @@ void main() { ); }); await tester.pumpAndSettle(); - expect(fixture.connect.expertView, isFalse); + expect(fixture.connect.configuration.connection.expert, isFalse); expect(find.byType(ShadSwitch), findsNothing); expect(find.text('Provider profile'), findsOneWidget); await tester.runAsync(() async { @@ -295,6 +292,52 @@ void main() { ); } + testWidgets( + 'complete-configuration picker commits a Raw target without starting VPN', + (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + late _Fixture fixture; + await tester.runAsync(() async { + fixture = await _Fixture.create(expert: false); + }); + addTearDown(() => tester.runAsync(fixture.close)); + addTearDown(() => tester.pumpWidget(const SizedBox.shrink())); + await tester.pumpWidget(_screen(fixture.connect)); + await tester.pumpAndSettle(); + final l = AppLocalizations.of(tester.element(find.byType(ConnectView)))!; + // Keep the dialog's async caller in the real zone too: selecting Raw + // resumes that caller and commits through Drift before a new fake frame. + await tester.runAsync(() async { + await tester.tap(find.text(l.prototypeAutomaticSelection)); + }); + await tester.pumpAndSettle(); + await tester.tap( + find.widgetWithText(ChoiceChip, l.serverCompleteConfigurationsTab), + ); + await tester.pumpAndSettle(); + await tester.runAsync(() async { + final selected = fixture.connect.stream.firstWhere( + (state) => + state.configuration.connection.expert && + state.configuration.connection.rawId == 63, + ); + await tester.tap(find.widgetWithText(ListTile, 'Personal Raw')); + await selected.timeout(const Duration(seconds: 5)); + final saved = await fixture.coordinator.configuration; + expect(saved.connection.expert, isTrue); + expect(saved.connection.rawId, 63); + expect(saved.connection.selection.kind, SelectionKind.automatic); + }); + await tester.pumpAndSettle(); + expect(find.byType(ConnectionTargetPicker), findsNothing); + expect(find.text('Personal Raw'), findsOneWidget); + expect(fixture.started, isEmpty); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + testWidgets( 'unrelated persisted settings preserve picker browsing; cancel does not commit its target', (tester) async { @@ -374,7 +417,6 @@ void main() { await tester.tap(find.widgetWithText(ShadButton, l.prototypeCancel)); await tester.pumpAndSettle(); expect(find.byType(ConnectionTargetPicker), findsNothing); - expect(fixture.connect.expertView, isFalse); expect(fixture.connect.configuration.connection.expert, isFalse); expect( fixture.connect.configuration.connection.selection.kind, diff --git a/test/pages/connect/controller_test.dart b/test/pages/connect/controller_test.dart index 76ce6381c..b24b452e4 100644 --- a/test/pages/connect/controller_test.dart +++ b/test/pages/connect/controller_test.dart @@ -222,10 +222,13 @@ void main() { }); testWidgets( - 'Raw plan explains its own routing without changing ordinary preferences', + 'Raw runtime explains its own routing without changing ordinary preferences', (tester) async { final coordinator = _Coordinator(); - coordinator.state.value = const ConnectionView(); + coordinator.state.value = ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(expert: true), + ); final configuration = ConnectionConfiguration( connection: ConnectionSettings( expert: true, @@ -277,6 +280,104 @@ void main() { }, ); + testWidgets('connection explanation does not preview an unstarted plan', ( + tester, + ) async { + final coordinator = _Coordinator(); + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = coordinator.saved; + addTearDown(controller.close); + addTearDown(coordinator.dispose); + addTearDown(coordinator.db.close); + await tester.pumpWidget( + _testApp( + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => controller.showWhy(context), + child: const Text('why'), + ), + ), + ), + ), + ); + for (final view in [ + const ConnectionView(), + ConnectionView(runtime: _runtime()), + const ConnectionView(phase: ConnectionPhase.failed, issue: 'stopFailed'), + ]) { + coordinator.state.value = view; + await tester.tap(find.text('why')); + await tester.pumpAndSettle(); + expect(find.text('Why this connection?'), findsNothing); + } + expect(coordinator.applyCount, 0); + expect(coordinator.connectCount, 0); + expect(coordinator.disconnectCount, 0); + }); + + for (final phase in [ConnectionPhase.connected, ConnectionPhase.failed]) { + testWidgets( + 'connection explanation uses only actual runtime even when the saved plan differs; phase=$phase', + (tester) async { + final coordinator = _Coordinator(); + coordinator.state.value = ConnectionView( + phase: phase, + issue: phase == ConnectionPhase.failed ? 'stopFailed' : null, + runtime: _runtime(), + ); + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = coordinator.saved; + addTearDown(controller.close); + addTearDown(coordinator.dispose); + addTearDown(coordinator.db.close); + await tester.pumpWidget( + _testApp( + Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => controller.showWhy(context), + child: const Text('why'), + ), + ), + ), + ), + ); + final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; + final before = coordinator.saved.encode(); + expect(controller.configuration.connection.expert, isTrue); + await tester.tap(find.text('why')); + await tester.pumpAndSettle(); + expect( + find.text( + l.prototypeSmartConnectionChainReason( + 'Singapore 03 + Japan 02', + 'United States 01', + ), + ), + findsOneWidget, + ); + expect( + find.text(l.prototypeRunningEntriesReason('Singapore 03 + Japan 02')), + findsOneWidget, + ); + expect(find.text(l.prototypeRawManagedSettingsNotice), findsNothing); + expect(find.text(l.providerRoutingNotice), findsNothing); + expect( + controller.runningRoute?.path, + 'Singapore 03 + Japan 02 → United States 01', + ); + expect(coordinator.saved.encode(), before); + expect(coordinator.applyCount, 0); + expect(coordinator.connectCount, 0); + }, + ); + } + testWidgets( 'scope fallback uses frozen source and runtime identities only after connection', (tester) async { @@ -783,13 +884,10 @@ void main() { 'choosing ordinary scope from Raw follows the committed result; failure=$fail', (tester) async { final coordinator = _Coordinator(fail: fail); - final controller = - ConnectController( - database: coordinator.db, - coordinator: coordinator, - ) - ..expertView = true - ..configuration = coordinator.saved; + final controller = ConnectController( + database: coordinator.db, + coordinator: coordinator, + )..configuration = coordinator.saved; addTearDown(controller.close); addTearDown(coordinator.dispose); addTearDown(coordinator.db.close); @@ -826,7 +924,7 @@ void main() { await tester.tap(find.text('Apply and reconnect')); await tester.pumpAndSettle(); await changing; - expect(controller.expertView, fail); + expect(controller.configuration.connection.expert, fail); expect(coordinator.saved.connection.expert, fail); if (!fail) { expect(controller.configuration.encode(), coordinator.saved.encode()); @@ -1346,13 +1444,10 @@ Widget _connectionScreen(ConnectController controller) => ShadTheme( method: 'Smart Routing', onConnection: () => controller.connectionAction(context), onAddServers: () {}, - onExpert: (_) {}, onServer: () {}, onMethod: () {}, onWhy: () {}, onRawAdd: () {}, - onRawSelect: (_) {}, - onRawActions: (_) {}, ), ), ), diff --git a/test/pages/connect/routing/custom_layout_test.dart b/test/pages/connect/routing/custom_layout_test.dart index 9084be72c..79d0de3fd 100644 --- a/test/pages/connect/routing/custom_layout_test.dart +++ b/test/pages/connect/routing/custom_layout_test.dart @@ -27,7 +27,6 @@ void main() { WidgetTester tester, { required double width, double windowWidth = 1440, - CustomRoutingRuleController? editor, Locale locale = const Locale('en'), double textScale = 1, Brightness brightness = Brightness.light, @@ -58,9 +57,6 @@ void main() { key: _rules, child: SizedBox(height: 180), ), - editor: editor == null - ? null - : CustomRoutingRuleForm(controller: editor), options: RoutingCard( key: _options, child: Column( @@ -170,7 +166,43 @@ void main() { (tester) async { final editor = CustomRoutingRuleController(); addTearDown(editor.close); - await pumpSections(tester, width: 900, editor: editor); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1440, 1100); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + Future pumpRuleForm({ + required double width, + Locale locale = const Locale('en'), + double textScale = 1, + Brightness brightness = Brightness.light, + }) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(brightness), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: ShadTheme(data: AppTheme.shad(brightness), child: child!), + ), + home: Scaffold( + body: SingleChildScrollView( + child: Center( + child: SizedBox( + width: width, + child: CustomRoutingRuleForm(controller: editor), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + await pumpRuleForm(width: 900); final ruleInput = find .descendant( of: find.byType(CustomRoutingRuleForm), @@ -189,20 +221,13 @@ void main() { (743.0, Locale('fa'), 1.6, Brightness.light), (680.0, Locale('zh'), 2.0, Brightness.dark), ]) { - await pumpSections( - tester, + await pumpRuleForm( width: width, - windowWidth: width < 400 ? 390 : 1440, - editor: editor, locale: locale, textScale: scale, brightness: brightness, ); - final rules = tester.getRect(find.byKey(_rules)); final form = tester.getRect(find.byType(CustomRoutingRuleForm)); - expect(form.top, greaterThan(rules.bottom)); - expect(form.left, rules.left); - expect(form.width, rules.width); expect(form.width, width); expect(editor.name.text, 'Edited rule'); expect(editor.name.selection, selection); diff --git a/test/pages/connect/routing/custom_page_parity_test.dart b/test/pages/connect/routing/custom_page_parity_test.dart index 60340eba5..f370a53fb 100644 --- a/test/pages/connect/routing/custom_page_parity_test.dart +++ b/test/pages/connect/routing/custom_page_parity_test.dart @@ -9,7 +9,6 @@ import 'package:onexray/pages/connect/routing/options_form.dart'; import 'package:onexray/pages/connect/routing/widgets.dart'; import 'package:onexray/pages/shared/widgets/app_switch.dart'; import 'package:onexray/pages/shared/widgets/configuration_transfer.dart'; -import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; @@ -66,9 +65,13 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(CustomRoutingEditorPage)), )!; - final header = tester.widget(find.byType(PageAppBar)); - expect((header.title as Text).data, l.routingNormalCreateTitle); - expect(header.description, isNull); + expect( + find.descendant( + of: find.byType(AppBar), + matching: find.text(l.routingNormalCreateTitle), + ), + findsOneWidget, + ); expect(find.text(l.routingNormalDescription), findsOneWidget); expect( find.descendant( diff --git a/test/pages/connect/routing/custom_ui_test.dart b/test/pages/connect/routing/custom_ui_test.dart index a87f2f962..0089674e3 100644 --- a/test/pages/connect/routing/custom_ui_test.dart +++ b/test/pages/connect/routing/custom_ui_test.dart @@ -316,7 +316,7 @@ void main() { ); testWidgets( - 'route ordering and inline edits only change the in-memory draft', + 'route ordering and separate rule edits only change the in-memory draft', (tester) async { final db = AppDatabase.forTesting(NativeDatabase.memory()); final coordinator = ConnectionCoordinator(database: db); @@ -392,7 +392,7 @@ void main() { controller.setFakeDns(false); expect(controller.profileState.fakeDns, false); controller.setFakeDns(true); - controller.setDirectDnsAddress('1.1.1.1'); + controller.setDns(controller.state.dns.withDirectAddress('1.1.1.1')); expect(controller.profileState.directDnsAddress, '1.1.1.1'); final storedDns = jsonDecode(controller.previewState!.encode())['dns']; expect(storedDns['servers'], [ @@ -418,62 +418,53 @@ void main() { controller.deleteRule(0); expect(controller.state.rules.map((rule) => rule.ruleTag), ['A', 'C']); expect(controller.state.selectedRuleKey, controller.state.ruleKeys.first); - controller.setInlineEditing(true); - expect(controller.inlineRule!.name.text, 'A'); + final beforeEdit = controller.state.rules.first.toJson(); + final result = Completer(); + var opened = false; + final editing = controller.editRule(context, (_, rule) { + opened = true; + expect(rule!.ruleTag, 'A'); + return result.future; + }, 0); + expect(opened, isTrue); + expect(controller.state.rules.first.toJson(), beforeEdit); + result.complete( + controller.state.rules.first.copyWith( + ruleTag: 'A edited', + domain: const ['edited.example'], + port: '443', + action: RoutingRuleAction.direct, + ), + ); + await editing; + expect(controller.state.rules.first.ruleTag, 'A edited'); + expect(controller.state.rules.first.domain, ['edited.example']); + expect(controller.state.rules.first.port, '443'); expect(controller.profileState.directDnsAddress, '1.1.1.1'); - expect(controller.profileState.fakeDns, true); - controller.inlineRule!.name.text = 'A edited'; - controller.inlineRule!.domains.single.text.text = 'edited.example'; - controller.inlineRule!.port.text = '65536'; - // Parent operations must synchronously flush the inline Cubit draft. - Future unexpectedNavigation( - BuildContext _, - RoutingRuleState? _, - ) async => - throw StateError('Desktop must keep the editor beside the list'); - await controller.editRule(context, unexpectedNavigation, 1); - expect(controller.inlineRule!.name.text, 'C'); - expect(controller.state.rules.first.port, '65536'); - expect(controller.previewState!.rules.first.port, '65536'); - await controller.editRule(context, unexpectedNavigation, 0); - expect(controller.inlineRule!.domains.single.text.text, 'edited.example'); - expect(controller.inlineRule!.port.text, '65536'); - controller.inlineRule!.port.text = '443'; + expect(controller.profileState.fakeDns, isTrue); controller.reorder(0, 1); expect(controller.state.rules.last.ruleTag, 'A edited'); - expect(controller.state.rules.last.port, '443'); - controller.inlineRule!.setAction(RoutingRuleAction.direct); - await controller.editRule(context, unexpectedNavigation); + final beforeCancel = controller.state.rules + .map((r) => r.toJson()) + .toList(); + await controller.editRule(context, (_, rule) async => null, 1); expect( - controller.state.rules - .singleWhere((rule) => rule.ruleTag == 'A edited') - .action, - RoutingRuleAction.direct, + controller.state.rules.map((r) => r.toJson()).toList(), + beforeCancel, ); - expect(controller.inlineRule!.name.text, 'New rule'); - expect(controller.previewState!.rules.last.domain, isEmpty); - controller.inlineRule!.domains.single.text.text = 'new.example'; - controller.deleteRule(0); - expect(controller.previewState!.rules, hasLength(2)); - expect(controller.state.rules.last.domain, ['new.example']); - expect(controller.inlineRule!.name.text, 'New rule'); - controller.deleteRule(1); - expect(controller.inlineRule!.name.text, 'A edited'); - controller.inlineRule!.port.text = '65536'; - await tester.pump(); - expect(controller.profileState.rules.single.port, '65536'); - controller.inlineRule!.port.text = '443'; - controller.setInlineEditing(false); - expect(controller.inlineRule, isNull); - var mobileOpened = false; await controller.editRule(context, (_, rule) async { - mobileOpened = true; - final current = rule!; - expect(current.ruleTag, 'A edited'); - return current.copyWith(ruleTag: 'Mobile edit'); - }, 0); - expect(mobileOpened, isTrue); - expect(controller.state.rules.last.ruleTag, 'Mobile edit'); + expect(rule, isNull); + return RoutingRuleState( + ruleTag: 'New rule', + domain: const ['new.example'], + ); + }); + expect(controller.state.rules.last.domain, ['new.example']); + controller.deleteRule(0); + expect(controller.state.rules.map((r) => r.ruleTag), [ + 'A edited', + 'New rule', + ]); await tester.pump(); expect( await tester.runAsync(() => db.routingProfileDao.allRows), diff --git a/test/pages/connect/routing_catalog_isolation_test.dart b/test/pages/connect/routing_catalog_isolation_test.dart index e91022bdc..aa8538cab 100644 --- a/test/pages/connect/routing_catalog_isolation_test.dart +++ b/test/pages/connect/routing_catalog_isolation_test.dart @@ -81,7 +81,6 @@ void main() { await waitForCatalog( () => connect.customRoutes.any((row) => row.id == goodId) && - servers.customRoutes.any((row) => row.id == goodId) && connect.servers.any((row) => row.id == nodeId) && servers.servers.any((row) => row.id == nodeId), ); @@ -113,9 +112,9 @@ void main() { expect(connect.ready && !connect.failed, isTrue); expect(servers.ready && !servers.failed, isTrue); expect(connect.customRoutes.map((row) => row.id), [goodId]); - expect(servers.customRoutes.map((row) => row.id), [goodId]); expect(connect.candidateCount(automatic), 1); - expect(servers.canUseSelection(automatic, servers.servers), isTrue); + expect(servers.canChoose(servers.servers.single), isTrue); + expect(servers.canTest(servers.servers.single), isTrue); } expect( @@ -124,60 +123,47 @@ void main() { ), isTrue, ); - await waitForCatalog( - () => connect.customRoutes.isEmpty && servers.customRoutes.isEmpty, - ); + await waitForCatalog(() => connect.customRoutes.isEmpty); expect(connect.failed || servers.failed, isFalse); - expect(servers.canUseSelection(automatic, servers.servers), isTrue); + expect(servers.canChoose(servers.servers.single), isTrue); + expect(servers.canTest(servers.servers.single), isTrue); expect(await db.routingProfileDao.updateRow(originalGood), isTrue); await waitForCatalog( - () => - connect.customRoutes.any((row) => row.id == goodId) && - servers.customRoutes.any((row) => row.id == goodId), + () => connect.customRoutes.any((row) => row.id == goodId), ); expect(connect.customRoutes.map((row) => row.id), [goodId]); - expect(servers.customRoutes.map((row) => row.id), [goodId]); expect(connect.failed || servers.failed, isFalse); }, ); - test( - 'a selected damaged Custom route stays unavailable without changing mode', - () async { - final selected = ConnectionConfiguration( - connection: ConnectionSettings( - trafficMode: TrafficMode.custom, - customId: brokenId, - ), - ); - await db.connectionConfigDao.commit(configurationJson: selected.encode()); - await waitForCatalog( - () => - connect.configuration.connection.customId == brokenId && - servers.configuration.connection.customId == brokenId, - ); - expect(connect.failed || servers.failed, isFalse); - expect(servers.entryCount(const ServerSelection.automatic()), 0); - expect( - servers.canUseSelection( - const ServerSelection.automatic(), - servers.servers, - ), - isFalse, - ); - await expectLater( - CustomRoutingEditorService( - database: db, - coordinator: coordinator, - ).load((await coordinator.configuration).connection.customId), - throwsFormatException, - ); - expect((await coordinator.configuration).encode(), selected.encode()); - expect(connect.configuration.connection.trafficMode, TrafficMode.custom); - expect(servers.configuration.connection.trafficMode, TrafficMode.custom); - expect(connect.customRoutes.map((row) => row.id), [goodId]); - expect(servers.customRoutes.map((row) => row.id), [goodId]); - }, - ); + test('a selected damaged Custom route stays invalid without blocking node management', () async { + final selected = ConnectionConfiguration( + connection: ConnectionSettings( + trafficMode: TrafficMode.custom, + customId: brokenId, + ), + ); + await db.connectionConfigDao.commit(configurationJson: selected.encode()); + await waitForCatalog( + () => + connect.configuration.connection.customId == brokenId && + servers.configuration.connection.customId == brokenId, + ); + expect(connect.failed || servers.failed, isFalse); + expect(servers.canChoose(servers.servers.single), isTrue); + expect(servers.canTest(servers.servers.single), isTrue); + expect(connect.candidateCount(const ServerSelection.automatic()), 1); + await expectLater( + CustomRoutingEditorService( + database: db, + coordinator: coordinator, + ).load((await coordinator.configuration).connection.customId), + throwsFormatException, + ); + expect((await coordinator.configuration).encode(), selected.encode()); + expect(connect.configuration.connection.trafficMode, TrafficMode.custom); + expect(servers.configuration.connection.trafficMode, TrafficMode.custom); + expect(connect.customRoutes.map((row) => row.id), [goodId]); + }); } diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index f4422ff6b..c862d203e 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -13,6 +13,10 @@ import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/core/pigeon/model.dart'; +import 'package:onexray/service/connect/compiler.dart'; +import 'package:onexray/service/connect/runtime.dart'; +import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/connect/resolver.dart'; import 'package:onexray/service/connect/traffic.dart'; import 'package:onexray/service/settings/backup/service.dart'; @@ -42,7 +46,6 @@ void main() { List raws = const [], int? activeRawId, VoidCallback? onAddServers, - VoidCallback? onExpert, VoidCallback? onRawAdd, VoidCallback? onServer, }) => ConnectView( @@ -59,13 +62,10 @@ void main() { methodEnabled: methodEnabled, onConnection: () {}, onAddServers: onAddServers ?? () {}, - onExpert: (_) => onExpert?.call(), onServer: onServer ?? () {}, onMethod: onMethod ?? () {}, onWhy: () {}, onRawAdd: onRawAdd ?? () {}, - onRawSelect: (_) {}, - onRawActions: (_) {}, ); Widget app( @@ -288,6 +288,43 @@ void main() { ); } + testWidgets('connection explanation requires actual runtime metadata', ( + tester, + ) async { + addTearDown(() => tester.binding.setSurfaceSize(null)); + for (final width in [390.0, 768.0, 1440.0]) { + await tester.binding.setSurfaceSize(Size(width, 900)); + for (final view in [ + const ConnectionView(), + ConnectionView(runtime: _runtime()), + const ConnectionView( + phase: ConnectionPhase.failed, + issue: 'stopFailed', + ), + ]) { + await tester.pumpWidget(app(screen(view: view))); + await tester.pumpAndSettle(); + expect(find.text('Why this connection?'), findsNothing); + } + await tester.pumpWidget( + app( + screen( + view: ConnectionView( + phase: ConnectionPhase.failed, + issue: 'stopFailed', + runtime: _runtime(), + ), + runningPath: 'Actual entry', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Why this connection?'), findsOneWidget); + expect(find.text('Actual entry'), findsOneWidget); + expect(tester.takeException(), isNull); + } + }); + testWidgets('scope fallback remains visible beside the actual running path', ( tester, ) async { @@ -295,7 +332,10 @@ void main() { await tester.pumpWidget( app( screen( - view: const ConnectionView(phase: ConnectionPhase.connected), + view: ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(), + ), runningPath: 'Frozen entry → Frozen final exit', fallbackMessage: message, ), @@ -319,7 +359,10 @@ void main() { app( screen( expert: expert, - view: const ConnectionView(phase: ConnectionPhase.connected), + view: ConnectionView( + phase: ConnectionPhase.connected, + runtime: _runtime(), + ), runningPath: 'Frozen entry → Frozen exit', ), ), @@ -450,8 +493,9 @@ void main() { await tester.pumpWidget( app( screen( - view: const ConnectionView( + view: ConnectionView( phase: ConnectionPhase.connected, + runtime: _runtime(), issue: 'selectionReset', ), runningPath: path, @@ -748,3 +792,29 @@ void main() { expect(tester.takeException(), isNull); }); } + +ConnectionRuntime _runtime() { + final configuration = ConnectionConfiguration(); + return ConnectionRuntime.create( + configuration: configuration, + compiled: CompiledConnection( + xrayJson: '{}', + entries: [ + ResolvedServer( + id: 1, + sourceId: 0, + outbound: {'protocol': 'freedom', 'tag': 'Actual entry'}, + ), + ], + finalExit: null, + nodeTags: const {}, + ), + platform: ConnectionPlatform.android, + request: StartVpnRequest( + configuration.policy.toTun(ConnectionPlatform.android), + null, + null, + '{}', + ), + ); +} diff --git a/test/pages/main/adaptive_shell_test.dart b/test/pages/main/adaptive_shell_test.dart index 811efe00d..f10a52352 100644 --- a/test/pages/main/adaptive_shell_test.dart +++ b/test/pages/main/adaptive_shell_test.dart @@ -998,14 +998,14 @@ void main() { body: Center( child: TextButton( onPressed: () => - context.pushScoped(AppPageDestination.theme), + context.pushScoped(AppPageDestination.language), child: Text('open-${primary.name}'), ), ), ), routes: [ GoRoute( - path: AppPageDestination.theme.segment, + path: AppPageDestination.language.segment, builder: (_, _) => Scaffold( appBar: AppBar( title: Text('detail-${primary.name}'), diff --git a/test/pages/main/page_app_bar_test.dart b/test/pages/main/page_app_bar_test.dart index 0510694b6..11238ead9 100644 --- a/test/pages/main/page_app_bar_test.dart +++ b/test/pages/main/page_app_bar_test.dart @@ -51,11 +51,8 @@ void main() { ), home: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: const Text('Настройки подключения'), - description: - 'Параметры соединения и данные этого устройства.', actions: [ IconButton( key: const Key('page-action'), @@ -72,10 +69,6 @@ void main() { await tester.pumpAndSettle(); final title = find.text('Настройки подключения'); expect(title, findsOneWidget); - expect( - find.text('Параметры соединения и данные этого устройства.'), - findsNothing, - ); expect(find.byKey(const ValueKey('page-heading')), findsNothing); expect(find.text('OneXray'), findsNothing); expect(find.byType(Image), findsNothing); @@ -124,9 +117,8 @@ void main() { localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: Builder( - builder: (context) => Scaffold( - appBar: PageAppBar.forPage(context, title: const Text('Servers')), - ), + builder: (context) => + Scaffold(appBar: PageAppBar(title: const Text('Servers'))), ), ), ), @@ -139,68 +131,62 @@ void main() { expect(tester.takeException(), isNull); }); - for (final inline in [false, true]) { - testWidgets( - 'legacy heading action stays in the standard toolbar: $inline', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(390, 844); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - var actions = 0; - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: true), - locale: const Locale('zh'), - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - home: Builder( - builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: const Text('服务器'), - description: '', - inlineHeadingAction: inline, - headingAction: IconButton( - key: const Key('compact-heading-action'), - onPressed: () => actions++, - tooltip: '导入', - icon: const Icon(Icons.add), - ), - ), - body: const SizedBox.expand(key: Key('compact-page-body')), + testWidgets('page heading action stays in the standard toolbar', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var actions = 0; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(Brightness.light, mobile: true), + locale: const Locale('zh'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + appBar: PageAppBar( + title: const Text('服务器'), + headingAction: IconButton( + key: const Key('compact-heading-action'), + onPressed: () => actions++, + tooltip: '导入', + icon: const Icon(Icons.add), ), ), + body: const SizedBox.expand(key: Key('compact-page-body')), ), - ); - await tester.pumpAndSettle(); - final title = find.text('服务器'); - final action = find.byKey(const Key('compact-heading-action')); - final titleBounds = tester.getRect(title); - final actionBounds = tester.getRect(action); - expect(action.hitTestable(), findsOneWidget); - expect(titleBounds.overlaps(actionBounds), isFalse); - expect(titleBounds.center.dy, closeTo(actionBounds.center.dy, .01)); - expect( - tester.getRect(find.byType(AppBar)).contains(actionBounds.center), - isTrue, - ); - expect( - tester.widget(find.byType(AppBar)).actions, - contains(tester.widget(action)), - ); - expect(find.byType(SingleChildScrollView), findsNothing); - expect( - tester.getRect(find.byKey(const Key('compact-page-body'))).top, - tester.getRect(find.byType(AppBar)).bottom, - ); - await tester.tap(action); - await tester.pumpAndSettle(); - expect(actions, 1); - expect(tester.takeException(), isNull); - }, + ), + ), ); - } + await tester.pumpAndSettle(); + final title = find.text('服务器'); + final action = find.byKey(const Key('compact-heading-action')); + final titleBounds = tester.getRect(title); + final actionBounds = tester.getRect(action); + expect(action.hitTestable(), findsOneWidget); + expect(titleBounds.overlaps(actionBounds), isFalse); + expect(titleBounds.center.dy, closeTo(actionBounds.center.dy, .01)); + expect( + tester.getRect(find.byType(AppBar)).contains(actionBounds.center), + isTrue, + ); + expect( + tester.widget(find.byType(AppBar)).actions, + contains(tester.widget(action)), + ); + expect(find.byType(SingleChildScrollView), findsNothing); + expect( + tester.getRect(find.byKey(const Key('compact-page-body'))).top, + tester.getRect(find.byType(AppBar)).bottom, + ); + await tester.tap(action); + await tester.pumpAndSettle(); + expect(actions, 1); + expect(tester.takeException(), isNull); + }); for (final locale in [const Locale('ru'), const Locale('fa')]) { testWidgets( @@ -234,22 +220,20 @@ void main() { ), home: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: const Text(titleText), - inlineHeadingAction: true, headingAction: IconButton( - key: const Key('inline-import'), + key: const Key('toolbar-import'), tooltip: 'Import', icon: const Icon(Icons.add), onPressed: () => imports++, ), ), - body: const SizedBox.expand(key: Key('inline-page-body')), + body: const SizedBox.expand(key: Key('toolbar-page-body')), bottomNavigationBar: PageActionBar( children: [ FilledButton( - key: const Key('inline-save'), + key: const Key('toolbar-save'), onPressed: () => saves++, child: const Text('Save'), ), @@ -278,17 +262,17 @@ void main() { isSemantics(label: titleText, isHeader: true), ); expect( - tester.getSize(find.byKey(const Key('inline-page-body'))).height, + tester.getSize(find.byKey(const Key('toolbar-page-body'))).height, greaterThanOrEqualTo(80), ); - final action = find.byKey(const Key('inline-import')); + final action = find.byKey(const Key('toolbar-import')); expect(action.hitTestable(), findsOneWidget); expect( - find.byKey(const Key('inline-save')).hitTestable(), + find.byKey(const Key('toolbar-save')).hitTestable(), findsOneWidget, ); await tester.tap(action); - await tester.tap(find.byKey(const Key('inline-save'))); + await tester.tap(find.byKey(const Key('toolbar-save'))); await tester.pumpAndSettle(); expect(imports, 1); expect(saves, 1); @@ -335,10 +319,8 @@ void main() { ), home: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: Text(fileName), - description: 'Resource source and update settings.', leading: BackButton(onPressed: () => backs++), headingAction: IconButton( key: const Key('resource-heading-action'), @@ -540,22 +522,14 @@ void main() { routes: [ GoRoute( path: primary.rootPath, - builder: (context, _) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(primary.name), - ), - ), + builder: (context, _) => + Scaffold(appBar: PageAppBar(title: Text(primary.name))), routes: [ for (final child in ['settings', 'detail']) GoRoute( path: child, - builder: (context, _) => Scaffold( - appBar: PageAppBar.forPage( - context, - title: Text(child), - ), - ), + builder: (context, _) => + Scaffold(appBar: PageAppBar(title: Text(child))), ), ], ), @@ -613,8 +587,7 @@ void main() { length: 2, child: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: const Text('Advanced'), bottom: const TabBar( tabs: [ @@ -786,8 +759,7 @@ void main() { ), home: Builder( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: const Text('Root'), actions: [ IconButton( @@ -805,8 +777,7 @@ void main() { navigator.currentState!.push( MaterialPageRoute( builder: (context) => Scaffold( - appBar: PageAppBar.forPage( - context, + appBar: PageAppBar( title: const Text('Detail'), headingAction: IconButton( key: headingAction, diff --git a/test/pages/servers/controller_test.dart b/test/pages/servers/controller_test.dart index 910cc9538..405969b53 100644 --- a/test/pages/servers/controller_test.dart +++ b/test/pages/servers/controller_test.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:async'; import 'package:drift/drift.dart' show Value; @@ -12,7 +11,6 @@ import 'package:onexray/pages/servers/controller.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/connect/runtime.dart'; import 'package:onexray/service/connect/settings.dart'; -import 'package:onexray/service/connect/routing/custom/service.dart'; import 'package:onexray/service/servers/outbound/state_db.dart'; void main() { @@ -85,7 +83,7 @@ void main() { expect(controller.groups(l).single.rows, [one, two]); controller.search.text = 'Tokyo'; expect(controller.groups(l).single.visibleRows, [one]); - expect(controller.favorites(l), [one]); + expect(controller.servers.where((row) => row.favorite), [one]); expect(controller.groups(l).single.rows, [one, two]); expect(controller.groups(l).single.selection?.id, 4); expect(controller.configuration.encode(), before); @@ -166,7 +164,7 @@ void main() { ]); expect(groups.every((group) => group.selection == null), isTrue); expect(groups[1].rows, isEmpty); - expect(controller.canUse(groups.last), isFalse); + expect(groups.last.selection, isNull); controller.search.text = 'Home'; expect(controller.groups(l).single.id, 'manual:8'); controller.servers = []; @@ -249,41 +247,30 @@ void main() { }, ); - test('Use N follows normal route and excludes final exit, not the current fixed selection', () async { - final one = await server('one'); - final two = await server('two'); - final exit = await server('exit'); - controller.servers = [one, two, exit]; - controller.configuration = ConnectionConfiguration( - connection: ConnectionSettings( - expert: true, - selection: ServerSelection.server(one.id), - smart: SmartRoutingSettings(entryCount: 2, finalExitId: exit.id), - ), - ); - final group = controller.groups(l).single; - expect(controller.entryCount(const ServerSelection.source(0)), 2); - expect( - controller.canUseSelection(const ServerSelection.source(0), group.rows), - true, - ); // Unmeasured candidates are eligible. - expect(controller.canChoose(exit), false); - controller.servers = [one, exit]; - expect( - controller.canUseSelection( - const ServerSelection.source(0), - controller.groups(l).single.rows, - ), - false, - ); - controller.configuration = ConnectionConfiguration( - connection: ConnectionSettings( - trafficMode: TrafficMode.allVpn, - smart: SmartRoutingSettings(entryCount: 3), - ), - ); - expect(controller.entryCount(const ServerSelection.source(0)), 1); - }); + test( + 'fixed nodes respect the final exit while failed probes remain usable', + () async { + final one = await server('one'); + final exit = await server('exit'); + controller.servers = [one, exit]; + controller.configuration = ConnectionConfiguration( + connection: ConnectionSettings( + selection: ServerSelection.server(one.id), + smart: SmartRoutingSettings(entryCount: 2, finalExitId: exit.id), + ), + ); + expect(controller.canChoose(one), isTrue); + expect( + controller.canChoose(one.copyWith(delay: PingDelayConstants.error)), + isTrue, + ); + expect(controller.canChoose(exit), isFalse); + controller.configuration = ConnectionConfiguration( + connection: ConnectionSettings(trafficMode: TrafficMode.allVpn), + ); + expect(controller.canChoose(exit), isTrue); + }, + ); test( 'latency reports measurement status without limiting selection', @@ -305,56 +292,25 @@ void main() { final candidate = row.copyWith(delay: delay); expect(controller.health(l, candidate), label); expect(controller.canChoose(candidate), selectable); - controller.servers = [candidate]; - expect( - controller.automaticResult(l), - PingDelayConstants.isSuccessful(delay) - ? l.prototypeCurrentServerLatency('one', delay) - : l.prototypeCurrentServerLatency('one', '—'), - ); } }, ); - test( - 'custom count uses empty outbounds and measured failures remain candidates', - () async { - final one = await server('one'); - final two = await server('two'); - await db.routingProfileDao.insertRow( - RoutingProfileCompanion.insert( - name: 'custom', - data: base64Encode( - utf8.encode( - jsonEncode({ - 'outbounds': [{}, {}], - 'routing': {'rules': []}, - }), - ), - ), - ), - ); - controller.customRoutes = (await db.routingProfileDao.allRows) - .map(CustomRoutingService.read) - .toList(); - controller.configuration = ConnectionConfiguration( - connection: ConnectionSettings( - trafficMode: TrafficMode.custom, - customId: controller.customRoutes.single.id, - ), - ); - controller.servers = [one, two.copyWith(delay: PingDelayConstants.error)]; - final group = controller.groups(l).single; - expect(controller.entryCount(const ServerSelection.source(0)), 2); - expect( - controller.canUseSelection(const ServerSelection.source(0), group.rows), - true, - ); - expect(controller.health(l, one), l.prototypeNotTested); - expect( - controller.health(l, controller.servers.last), - l.serverLatencyFailed, - ); - }, - ); + test('custom routing keeps manual node eligibility independent of cached latency', () async { + final one = await server('one'); + final two = await server('two'); + controller.configuration = ConnectionConfiguration( + connection: ConnectionSettings( + trafficMode: TrafficMode.custom, + customId: 4, + ), + ); + controller.servers = [one, two.copyWith(delay: PingDelayConstants.error)]; + expect(controller.servers.every(controller.canChoose), isTrue); + expect(controller.health(l, one), l.prototypeNotTested); + expect( + controller.health(l, controller.servers.last), + l.serverLatencyFailed, + ); + }); } diff --git a/test/pages/servers/empty_source_details_test.dart b/test/pages/servers/empty_source_details_test.dart index cdecd0e66..0c57d8ec1 100644 --- a/test/pages/servers/empty_source_details_test.dart +++ b/test/pages/servers/empty_source_details_test.dart @@ -10,8 +10,7 @@ import 'package:onexray/core/db/database/enum.dart'; import 'package:onexray/core/pigeon/messages.g.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/servers/controller.dart'; -import 'package:onexray/pages/servers/page.dart'; -import 'package:onexray/pages/servers/view.dart'; +import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; @@ -106,8 +105,11 @@ void main() { data: AppTheme.shad(Brightness.light), child: ShadToaster(child: child!), ), - home: ServerGroupPage( - params: ServerGroupParams(controller, 'subscription:$sourceId'), + home: ServerSourcePage( + params: ServerSourceParams( + controller: controller, + sourceId: sourceId, + ), ), ), ); @@ -149,7 +151,7 @@ void main() { await clearActiveCache(tester); await tester.pumpAndSettle(); final l = AppLocalizations.of( - tester.element(find.byType(ServerGroupPage)), + tester.element(find.byType(ServerSourcePage)), )!; expect(controller.groups(l), isEmpty); expect( @@ -161,21 +163,20 @@ void main() { ); expect(find.text('Complete profile'), findsNothing); expect(find.text(l.subscriptionModeAppManaged), findsOneWidget); - expect(find.text(l.subscriptionModeNoCacheTitle), findsOneWidget); + expect(find.text('https://example.test/provider'), findsOneWidget); expect(find.text(l.subscriptionModeNeedsRefresh), findsOneWidget); - expect(find.byType(SourceMenu), findsOneWidget); + expect( + find.widgetWithText(FilledButton, l.prototypeCheckForUpdates), + findsOneWidget, + ); for (final (action, label) in [ (SourceAction.update, l.prototypeCheckForUpdates), (SourceAction.edit, l.prototypeEditSubscription), ]) { - await tester.runAsync(() => tester.tap(find.byType(SourceMenu))); - await tester.pumpAndSettle(); - expect(find.text(l.prototypeCheckForUpdates), findsOneWidget); - expect(find.text(l.prototypeEditSubscription), findsOneWidget); - await tester.runAsync(() async { - await tester.tap(find.text(label)); - await Future.delayed(Duration.zero); - }); + final button = action == SourceAction.update + ? find.widgetWithText(FilledButton, label) + : find.widgetWithText(OutlinedButton, label); + await tester.runAsync(() => tester.tap(button)); await tester.pumpAndSettle(); expect(controller.actions.last, ( id: sourceId, @@ -200,7 +201,7 @@ void main() { ), findsOneWidget, ); - expect(find.byType(SourceMenu), findsOneWidget); + expect(find.text('https://example.test/provider'), findsOneWidget); await tester.runAsync(() async { await db.subscriptionDao.deleteRow(sourceId); if (controller.sources.isNotEmpty || controller.servers.isNotEmpty) { @@ -214,13 +215,16 @@ void main() { }); await tester.pumpAndSettle(); final l = AppLocalizations.of( - tester.element(find.byType(ServerGroupPage)), + tester.element(find.byType(ServerSourcePage)), )!; expect(find.text('Provider source'), findsNothing); - expect(find.text(l.prototypeServers), findsOneWidget); + expect(find.text(l.prototypeSubscriptions), findsOneWidget); expect(find.text(l.prototypeNoServersYet), findsOneWidget); expect(find.text(l.subscriptionModeNoCacheTitle), findsNothing); - expect(find.byType(SourceMenu), findsNothing); + expect( + find.widgetWithText(FilledButton, l.prototypeCheckForUpdates), + findsNothing, + ); expect(controller.actions, isEmpty); expect(tester.takeException(), isNull); }, diff --git a/test/pages/servers/provider_ui_test.dart b/test/pages/servers/provider_ui_test.dart index 438c68020..76fecd4bc 100644 --- a/test/pages/servers/provider_ui_test.dart +++ b/test/pages/servers/provider_ui_test.dart @@ -259,7 +259,7 @@ void main() { expect(find.text(l.prototypeTestServers), findsNothing); expect(find.text(l.prototypeNotTested), findsNothing); expect(find.text(l.prototypeTemporarilyUnavailable), findsNothing); - expect(find.byType(ServerUseButton), findsNothing); + expect(find.text(l.prototypeUseEntryServers(1)), findsNothing); await controller.test(context, profiles); expect(AppEventBus.instance.state.pinging, isFalse); expect(controller.state.serverTests, isEmpty); diff --git a/test/pages/servers/restored_resource_actions_test.dart b/test/pages/servers/restored_resource_actions_test.dart new file mode 100644 index 000000000..0c8775462 --- /dev/null +++ b/test/pages/servers/restored_resource_actions_test.dart @@ -0,0 +1,431 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart' show Value; +import 'package:drift/native.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/constants.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/core/db/database/enum.dart'; +import 'package:onexray/core/pigeon/messages.g.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/servers/view.dart'; +import 'package:onexray/pages/shared/widgets/setting_row.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/connect/runtime_host.dart'; +import 'package:onexray/service/servers/outbound/state_db.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:onexray/service/shared/ping/batch.dart'; +import 'package:onexray/service/shared/ping/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import 'fixtures/server_test_router.dart'; + +void main() { + late AppDatabase db; + late ConnectionCoordinator coordinator; + late ServersController controller; + late ScrollController scroll; + GoRouter? router; + late int providerSourceId; + late int firstSourceId; + late int secondSourceId; + late int oldFavoriteId; + late int newFavoriteId; + late int rawId; + late List firstNodeIds; + late int secondNodeId; + late Completer firstEntered; + late Completer secondEntered; + late Completer> firstResults; + late Completer> secondResults; + late List> batches; + + setUp(() async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + final bus = AppEventBus(); + addTearDown(bus.close); + db = AppDatabase.forTesting(NativeDatabase.memory()); + coordinator = ConnectionCoordinator( + database: db, + readRuntime: () async => null, + inspect: (_, {observedStatus}) async => + const HostConnection(VpnStatus.disconnected), + prepare: (_, _) async => + throw StateError('Resource actions must not prepare VPN'), + start: (_) async => + throw StateError('Resource actions must not start VPN'), + stop: () async => throw StateError('Resource actions must not stop VPN'), + ); + await coordinator.initialize(observe: false, registerReferences: false); + Future source(String name, {bool provider = false}) => + db.subscriptionDao.insertRow( + SubscriptionCompanion.insert( + name: name, + url: 'https://example.test/$name', + mode: Value( + provider + ? SubscriptionMode.providerManaged.name + : SubscriptionMode.appManaged.name, + ), + timestamp: DateTime(2026, 11, 4), + ), + ); + providerSourceId = await source('Complete source', provider: true); + firstSourceId = await source('First source'); + secondSourceId = await source('Second source'); + final graph = base64Encode( + utf8.encode('{"outbounds":[{"tag":"direct","protocol":"freedom"}]}'), + ); + Future profile(String name, bool favorite) => + db.coreConfigDao.insertAssetRow( + CoreConfigCompanion.insert( + name: name, + type: CoreConfigType.Remnawave.name, + tags: '', + data: Value(graph), + delay: PingDelayConstants.unknown, + subId: providerSourceId, + favorite: Value(favorite), + ), + ); + oldFavoriteId = await profile('Existing favorite', true); + newFavoriteId = await profile('Another profile', false); + rawId = await db.coreConfigDao.insertAssetRow( + CoreConfigCompanion.insert( + name: 'Personal Raw', + type: CoreConfigType.raw.name, + tags: '', + data: Value(graph), + delay: PingDelayConstants.unknown, + subId: 0, + ), + ); + Future node(String name, int subId) => db.coreConfigDao.insertAssetRow( + outboundCompanion({'tag': name, 'protocol': 'vless'}) + .copyWith(subId: Value(subId)), + ); + firstNodeIds = []; + for (var index = 0; index < PingBatchRunner.maxBatchSize + 1; index++) { + firstNodeIds.add(await node('First node $index', firstSourceId)); + } + secondNodeId = await node('Second node', secondSourceId); + firstEntered = Completer(); + secondEntered = Completer(); + firstResults = Completer>(); + secondResults = Completer>(); + batches = []; + final ping = PingService.forTesting( + database: db, + automaticEnabled: false, + runBatch: (sources, _) { + final names = sources + .map( + (source) => + ((jsonDecode(source.xrayJson) + as Map)['outbounds'] + as List) + .first['tag'] + as String, + ) + .toList(); + batches.add(names); + if (names.first.startsWith('First node')) { + if (!firstEntered.isCompleted) firstEntered.complete(); + return firstResults.future; + } + if (!secondEntered.isCompleted) secondEntered.complete(); + return secondResults.future; + }, + ); + controller = ServersController( + database: db, + coordinator: coordinator, + ping: ping, + ); + await controller.initialize(); + if (controller.servers.length != 2 + firstNodeIds.length + 1 || + controller.rawConfigurations.isEmpty) { + await controller.stream + .firstWhere( + (state) => + state.servers.length == 2 + firstNodeIds.length + 1 && + state.rawConfigurations.isNotEmpty, + ) + .timeout(const Duration(seconds: 5)); + } + scroll = ScrollController(); + }); + + tearDown(() async { + router?.dispose(); + router = null; + scroll.dispose(); + await controller.close(); + coordinator.dispose(); + await db.close(); + }); + + Future open(WidgetTester tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + router = serverTestRouter( + home: (_) => Scaffold( + body: BlocBuilder( + bloc: controller, + builder: (_, _) => + ServerBrowser(controller: controller, scroll: scroll), + ), + ), + ); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + return AppLocalizations.of(tester.element(find.byType(ServerBrowser)))!; + } + + Future stateWhere( + WidgetTester tester, + bool Function(ServersPageState) predicate, + ) async { + await tester.runAsync(() async { + if (!predicate(controller.state)) { + await controller.stream + .firstWhere(predicate) + .timeout(const Duration(seconds: 5)); + } + }); + await tester.pump(); + } + + testWidgets( + 'complete configuration list and detail can change existing favorites without offering Raw favorites', + (tester) async { + final before = (await tester.runAsync(() => coordinator.configuration))! + .encode(); + final l = await open(tester); + await tester.tap(find.text(l.serverCompleteConfigurationsTab).first); + await tester.pumpAndSettle(); + expect(find.text('Personal Raw'), findsOneWidget); + expect(find.byTooltip(l.prototypeRemoveFavorite), findsOneWidget); + expect(find.byTooltip(l.prototypeAddFavorite), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('server-favorites-filter'))); + await tester.pumpAndSettle(); + expect(find.text('Existing favorite'), findsOneWidget); + expect(find.text('Another profile'), findsNothing); + expect(find.text('Personal Raw'), findsNothing); + await tester.tap(find.byTooltip(l.prototypeRemoveFavorite)); + await stateWhere( + tester, + (state) => !state.servers + .firstWhere((row) => row.id == oldFavoriteId) + .favorite, + ); + await tester.pumpAndSettle(); + expect(find.text('Existing favorite'), findsNothing); + expect(find.text(l.serverNoMatchingResources), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('server-favorites-filter'))); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byKey(ValueKey(('server', newFavoriteId))), + matching: find.byTooltip(l.prototypeAddFavorite), + ), + ); + await stateWhere( + tester, + (state) => + state.servers.firstWhere((row) => row.id == newFavoriteId).favorite, + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Another profile')); + await tester.pumpAndSettle(); + expect(find.byType(ServerResourcePage), findsOneWidget); + final remove = find.text(l.prototypeRemoveFavorite); + await tester.ensureVisible(remove); + await tester.tap(remove); + await stateWhere( + tester, + (state) => !state.servers + .firstWhere((row) => row.id == newFavoriteId) + .favorite, + ); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeAddFavorite), findsOneWidget); + final rows = await tester.runAsync( + () async => [ + await db.coreConfigDao.searchRow(oldFavoriteId), + await db.coreConfigDao.searchRow(newFavoriteId), + await db.coreConfigDao.searchRow(rawId), + ], + ); + expect(rows!.every((row) => !row!.favorite), isTrue); + expect( + (await tester.runAsync(() => coordinator.configuration))!.encode(), + before, + ); + router!.pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Personal Raw')); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeAddFavorite), findsNothing); + expect(find.text(l.prototypeRemoveFavorite), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'subscription detail prevents duplicate tests and cancels only its batch while another source remains queued', + (tester) async { + final before = (await tester.runAsync(() => coordinator.configuration))! + .encode(); + final l = await open(tester); + await tester.ensureVisible(find.text(l.prototypeSubscriptions).first); + await tester.tap(find.text(l.prototypeSubscriptions).first); + await tester.pumpAndSettle(); + await tester.tap(find.text('First source')); + await tester.pumpAndSettle(); + final firstAction = find.byKey( + ValueKey('source-test-action-$firstSourceId'), + ); + await tester.scrollUntilVisible( + firstAction, + 240, + scrollable: find + .descendant( + of: find.byType(ServerSourcePage), + matching: find.byType(Scrollable), + ) + .first, + ); + // Two taps before the next rebuild exercise the original start callback twice. + await tester.runAsync(() async { + await tester.tap(firstAction); + await tester.tap(firstAction); + await firstEntered.future.timeout(const Duration(seconds: 5)); + }); + await tester.pump(); + expect(controller.state.serverTests, hasLength(1)); + expect(batches, hasLength(1)); + expect(tester.widget(firstAction).title, l.prototypeCancel); + await tester.tap(firstAction); + await tester.pump(); + expect( + tester.widget(firstAction).title, + l.prototypePleaseWait, + ); + expect(tester.widget(firstAction).enabled, isFalse); + expect(controller.cancellingGroup('subscription:$firstSourceId'), isTrue); + router!.pop(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.ensureVisible(find.text('Second source')); + await tester.tap(find.text('Second source')); + await tester.pump(const Duration(milliseconds: 400)); + final secondAction = find.byKey( + ValueKey('source-test-action-$secondSourceId'), + ); + await tester.scrollUntilVisible( + secondAction, + 240, + scrollable: find + .descendant( + of: find.byType(ServerSourcePage), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.runAsync(() async { + await tester.tap(secondAction); + if (!controller.testingGroup('subscription:$secondSourceId')) { + await controller.stream + .firstWhere( + (_) => controller.testingGroup('subscription:$secondSourceId'), + ) + .timeout(const Duration(seconds: 5)); + } + }); + await tester.pump(); + expect( + controller.cancellingGroup('subscription:$secondSourceId'), + isFalse, + ); + expect(controller.state.serverTests, hasLength(2)); + await tester.runAsync(() async { + firstResults.complete( + List.generate( + PingBatchRunner.maxBatchSize, + (_) => const PingBatchResult(true, 20, ''), + ), + ); + await secondEntered.future.timeout(const Duration(seconds: 5)); + if (controller.testingGroup('subscription:$firstSourceId')) { + await controller.stream + .firstWhere( + (_) => !controller.testingGroup('subscription:$firstSourceId'), + ) + .timeout(const Duration(seconds: 5)); + } + }); + await tester.pump(); + expect(controller.testingGroup('subscription:$secondSourceId'), isTrue); + expect( + controller.cancellingGroup('subscription:$secondSourceId'), + isFalse, + ); + expect(batches, hasLength(2)); + expect(batches.first, hasLength(PingBatchRunner.maxBatchSize)); + expect(batches.last, ['Second node']); + await tester.runAsync(() async { + secondResults.complete([const PingBatchResult(true, 40, '')]); + if (controller.state.serverTests.isNotEmpty) { + await controller.stream + .firstWhere((state) => state.serverTests.isEmpty) + .timeout(const Duration(seconds: 5)); + } + }); + await tester.pumpAndSettle(); + expect( + tester.widget(secondAction).title, + l.prototypeTestServers, + ); + final untouched = await tester.runAsync( + () => db.coreConfigDao.searchRow(firstNodeIds.last), + ); + final measured = await tester.runAsync( + () => db.coreConfigDao.searchRow(secondNodeId), + ); + expect(untouched!.delay, PingDelayConstants.unknown); + expect(measured!.delay, 40); + expect( + (await tester.runAsync(() => coordinator.configuration))!.encode(), + before, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/pages/servers/sources_test.dart b/test/pages/servers/sources_test.dart index 5bb235405..46edc3e24 100644 --- a/test/pages/servers/sources_test.dart +++ b/test/pages/servers/sources_test.dart @@ -1,19 +1,24 @@ import 'package:drift/drift.dart' show Value; import 'package:drift/native.dart'; -import 'package:material_ui/material_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:onexray/core/db/database/database.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; -import 'package:onexray/service/settings/language/locale.dart'; import 'package:onexray/pages/servers/controller.dart'; -import 'package:onexray/pages/servers/menus.dart'; +import 'package:onexray/pages/servers/details.dart'; import 'package:onexray/pages/servers/sources.dart'; import 'package:onexray/pages/servers/subscription/user_info.dart'; +import 'package:onexray/pages/servers/view.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; + +import 'fixtures/server_test_router.dart'; -const _open = Key('open-sources'); final _source = SubscriptionData( panel: 'generic', mode: 'appManaged', @@ -37,7 +42,6 @@ CoreConfigData _server(int id, {int source = 0}) => CoreConfigData( class _Controller extends ServersController { _Controller({required super.database, required super.coordinator}); - final actions = []; @override @@ -52,212 +56,168 @@ class _Controller extends ServersController { } } -String _label(AppLocalizations l, SourceAction action) => switch (action) { - SourceAction.update => l.prototypeCheckForUpdates, - SourceAction.test => l.prototypeTestServers, - SourceAction.edit => l.prototypeEditSubscription, - SourceAction.share => l.prototypeShareSubscription, - SourceAction.delete => l.prototypeDelete, -}; - void main() { - setUp(() { - final bus = AppEventBus(); - addTearDown(bus.close); - }); late AppDatabase db; late ConnectionCoordinator coordinator; late _Controller controller; + late ScrollController scroll; + GoRouter? router; setUp(() { + final bus = AppEventBus(); + addTearDown(bus.close); db = AppDatabase.forTesting(NativeDatabase.memory()); coordinator = ConnectionCoordinator(database: db); controller = _Controller(database: db, coordinator: coordinator); + scroll = ScrollController(); }); tearDown(() async { + router?.dispose(); + router = null; + scroll.dispose(); await controller.close(); coordinator.dispose(); await db.close(); }); - Future pumpSources(WidgetTester tester) async { + Future pumpSources(WidgetTester tester) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(427, 900); addTearDown(tester.view.resetDevicePixelRatio); addTearDown(tester.view.resetPhysicalSize); + router = serverTestRouter( + home: (_) => Scaffold( + body: BlocBuilder( + bloc: controller, + builder: (_, _) => + ServerBrowser(controller: controller, scroll: scroll), + ), + ), + ); await tester.pumpWidget( - MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: true), + MaterialApp.router( + theme: AppTheme.light, locale: const Locale('en'), + routerConfig: router, localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: Builder( - builder: (context) => TextButton( - key: _open, - onPressed: () => controller.openSources(context), - child: const Text('Open'), - ), - ), + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), ), ), ); - await tester.tap(find.byKey(_open)); await tester.pumpAndSettle(); - } - - testWidgets('local servers appear without a subscription or source actions', ( - tester, - ) async { - controller.servers = List.generate(4, (index) => _server(index + 1)); - await pumpSources(tester); - final l = AppLocalizations.of( - tester.element(find.byType(ServerSourcesDialog)), - )!; - expect(find.text(l.prototypeManualAdditions), findsOneWidget); - expect( - find.text('${l.prototypeServerCount(4)} · ${l.prototypeLocalOnly}'), - findsOneWidget, - ); - expect(find.text(l.prototypeStoredOnThisDevice), findsOneWidget); - expect(find.text(l.prototypeNoServersYet), findsNothing); - expect(find.byTooltip(l.prototypeCheckForUpdates), findsNothing); - await tester.tap(find.byTooltip(l.prototypeCloseDialog)); + final l = AppLocalizations.of(tester.element(find.byType(ServerBrowser)))!; + await tester.ensureVisible(find.text(l.prototypeSubscriptions).first); + await tester.tap(find.text(l.prototypeSubscriptions).first); await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsNothing); - expect(controller.actions, isEmpty); - expect(tester.takeException(), isNull); - }); + expect(find.byType(ServerSourcesView), findsOneWidget); + return l; + } - testWidgets('source management shares package summary and updates it', ( - tester, - ) async { - controller.sources = [_source]; - controller.servers = [_server(1, source: 7)]; - await pumpSources(tester); - expect(find.byType(SubscriptionPackageSummary), findsNothing); - controller.sources = [ - _source.copyWith( - totalBytes: const Value(0), - expireTimestamp: const Value(0), - userInfoUpdatedAt: Value(DateTime(2026, 9, 2)), - ), - ]; - await tester.pumpAndSettle(); - final l = AppLocalizations.of( - tester.element(find.byType(ServerSourcesDialog)), - )!; - expect(find.byType(SubscriptionPackageSummary), findsOneWidget); - expect( - find.text( - '${l.subscriptionPackageUnlimited} · ${l.subscriptionPackageNoExpiry}', - ), - findsOneWidget, - ); - expect( - find.byTooltip('${l.prototypeMoreActions}: ${_source.name}'), - findsOneWidget, - ); - expect(tester.takeException(), isNull); - }); + testWidgets( + 'subscription destination does not represent personal nodes as a source', + (tester) async { + controller.servers = List.generate(4, (index) => _server(index + 1)); + final l = await pumpSources(tester); + expect(find.text(l.prototypeManualAdditions), findsNothing); + expect( + find.widgetWithText(OutlinedButton, l.prototypeAddSubscription), + findsOneWidget, + ); + expect(controller.actions, isEmpty); + expect(tester.takeException(), isNull); + }, + ); testWidgets( - 'manual additions stay first and disappear with the last local node', + 'subscription cards update package information from their source stream', (tester) async { controller.sources = [_source]; - controller.servers = [_server(1, source: 7), _server(2)]; - await pumpSources(tester); - final l = AppLocalizations.of( - tester.element(find.byType(ServerSourcesDialog)), - )!; + controller.servers = [_server(1, source: 7)]; + final l = await pumpSources(tester); + expect(find.byType(SubscriptionPackageSummary), findsNothing); + controller.sources = [ + _source.copyWith( + totalBytes: const Value(0), + expireTimestamp: const Value(0), + userInfoUpdatedAt: Value(DateTime(2026, 9, 2)), + ), + ]; + await tester.pumpAndSettle(); + expect(find.byType(SubscriptionPackageSummary), findsOneWidget); expect( - tester.getTopLeft(find.text(l.prototypeManualAdditions)).dy, - lessThan(tester.getTopLeft(find.text(_source.name)).dy), + find.text( + '${l.subscriptionPackageUnlimited} · ${l.subscriptionPackageNoExpiry}', + ), + findsOneWidget, ); + await tester.tap(find.text(_source.name)); + await tester.pumpAndSettle(); + expect(find.byType(ServerSourcePage), findsOneWidget); + expect(find.byType(SubscriptionPackageDetails), findsOneWidget); + expect(find.text(_source.url), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); - controller.servers = [_server(1, source: 7)]; + testWidgets( + 'source search matches URL and can be cleared without changing the connection', + (tester) async { + controller.sources = [_source]; + final l = await pumpSources(tester); + final before = controller.configuration.encode(); + await tester.enterText(find.byType(TextField).first, 'example.test'); + await tester.pumpAndSettle(); + expect(find.text(_source.name), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'not-present'); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeNoMatchingSubscriptions), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('server-search-clear'))); await tester.pumpAndSettle(); - expect(find.text(l.prototypeManualAdditions), findsNothing); expect(find.text(_source.name), findsOneWidget); + expect(controller.configuration.encode(), before); expect(tester.takeException(), isNull); }, ); - testWidgets('menu replaces sources and closing ends the flow', ( - tester, - ) async { - controller.sources = [_source]; - controller.servers = [_server(1, source: 7), _server(2, source: 7)]; - controller.setSourceError(7, 'Stored update failure'); - await pumpSources(tester); - final context = tester.element(find.byType(ServerSourcesDialog)); - final l = AppLocalizations.of(context)!; - final material = MaterialLocalizations.of(context); - expect(find.text('Stored update failure'), findsOneWidget); - expect(find.text(l.prototypeUpdated), findsNothing); - expect( - find.textContaining(material.formatMediumDate(_source.timestamp)), - findsOneWidget, - ); - await tester.tap( - find.byTooltip('${l.prototypeMoreActions}: ${_source.name}'), - ); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsNothing); - expect(find.byType(SourceActionsMenu), findsOneWidget); - expect(find.text(l.prototypeServerCount(2)), findsOneWidget); - await tester.tap(find.byTooltip(l.prototypeCloseDialog)); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsNothing); - expect(find.byType(SourceActionsMenu), findsNothing); - expect(controller.actions, isEmpty); - - await tester.tap(find.byKey(_open)); - await tester.pumpAndSettle(); - expect(find.byType(ServerSourcesDialog), findsOneWidget); - expect(find.text('Stored update failure'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('all subscription actions dispatch once after the menu closes', ( - tester, - ) async { - controller.sources = [_source]; - controller.servers = [_server(1, source: 7)]; - await pumpSources(tester); - final l = AppLocalizations.of( - tester.element(find.byType(ServerSourcesDialog)), - )!; - for (final action in SourceAction.values) { - if (action != SourceAction.values.first) { - await tester.tap(find.byKey(_open)); + testWidgets( + 'production source detail retains failures and dispatches its actions once', + (tester) async { + controller.sources = [_source]; + controller.servers = [_server(1, source: 7)]; + controller.setSourceError(7, 'Stored update failure'); + final l = await pumpSources(tester); + expect(find.text('Stored update failure'), findsOneWidget); + await tester.tap(find.text(_source.name)); + await tester.pumpAndSettle(); + expect(find.byType(ServerSourcePage), findsOneWidget); + expect(find.text('Stored update failure'), findsOneWidget); + for (final (action, label) in [ + (SourceAction.update, l.prototypeCheckForUpdates), + (SourceAction.edit, l.prototypeEditSubscription), + (SourceAction.share, l.prototypeShareSubscription), + (SourceAction.delete, l.prototypeDeleteSource), + ]) { + final target = find.text(label); + await tester.ensureVisible(target); + await tester.tap(target); await tester.pumpAndSettle(); + expect(controller.actions.last, action); } - await tester.tap( - find.byTooltip('${l.prototypeMoreActions}: ${_source.name}'), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text(_label(l, action))); - await tester.pumpAndSettle(); - expect(controller.actions.last, action); - expect(find.byType(ServerSourcesDialog), findsNothing); - expect(find.byType(SourceActionsMenu), findsNothing); - } - expect(controller.actions, SourceAction.values); - expect(tester.takeException(), isNull); - }); + expect(controller.actions, SourceAction.values); + expect(tester.takeException(), isNull); + }, + ); testWidgets( 'zero-node update result explains that existing nodes were kept', (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(540, 700); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); await tester.pumpWidget( MaterialApp( - theme: AppTheme.material(Brightness.light, mobile: false), + theme: AppTheme.light, locale: const Locale('en'), localizationsDelegates: AppLocalePolicy.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, @@ -269,7 +229,6 @@ void main() { final l = AppLocalizations.of( tester.element(find.byType(SourceUpdateErrorDialog)), )!; - expect(find.text(l.prototypeSubscriptionUpdateFailed), findsOneWidget); expect(find.text('Example subscription'), findsOneWidget); expect( diff --git a/test/pages/servers/view_test.dart b/test/pages/servers/view_test.dart index ae3043e39..1b910a3e3 100644 --- a/test/pages/servers/view_test.dart +++ b/test/pages/servers/view_test.dart @@ -6,6 +6,7 @@ import 'package:drift/native.dart'; import 'package:material_ui/material_ui.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:onexray/core/db/database/constants.dart'; @@ -18,6 +19,7 @@ import 'package:onexray/pages/servers/menus.dart'; import 'package:onexray/pages/servers/subscription/user_info.dart'; import 'package:onexray/pages/servers/view.dart'; import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; @@ -47,18 +49,13 @@ class _Controller extends ServersController { super.ping, }); - bool? browsedOnMobile; + int groupBrowseCount = 0; bool helpOpened = false; bool addOpened = false; @override - Future browse( - BuildContext context, - ServerGroup group, { - required bool mobile, - }) async { - browsedOnMobile = mobile; - activeGroupId = group.id; + Future browse(BuildContext context, ServerGroup group) async { + groupBrowseCount++; } @override @@ -112,6 +109,13 @@ CoreConfigData _server(int id, {bool favorite = false, String? name}) => ); void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async { + // Keep narrow-page geometry faithful to the existing production font. + final font = FontLoader(AppFontFamily.sans) + ..addFont(rootBundle.load('packages/shadcn_ui/fonts/Geist[wght].ttf')); + await font.load(); + }); late AppDatabase db; late ConnectionCoordinator coordinator; late _Controller controller; @@ -162,7 +166,6 @@ void main() { ? ServerGroupView( controller: controller, group: controller.groups(AppLocalizations.of(context)!).first, - embedded: groupViewport != null, ) : ServerBrowser(controller: controller, scroll: scroll); return groupViewport == null @@ -700,16 +703,12 @@ void main() { ); testWidgets( - 'short embedded group scrolls an expanded diagnostic through its last line', + 'short group page scrolls an expanded diagnostic through its last line', (tester) async { controller.servers = [_server(1)]; - await pumpBrowser( - tester, - 956, - height: 440, - groupPage: true, - groupViewport: const Size(280, 180), - ); + // The manual group now occupies a complete content page. Model its + // actual narrow viewport rather than the retired desktop embedded pane. + await pumpBrowser(tester, 280, height: 180, groupPage: true); const tail = 'Original native diagnostic ends here.'; final diagnostic = [ 'Head "https://example.test/": context deadline exceeded', @@ -828,68 +827,77 @@ void main() { }); } - testWidgets('source updates refresh package UI without restricting use', ( - tester, - ) async { - final source = SubscriptionData( - panel: 'generic', - mode: 'appManaged', - id: 1, - name: 'Provider', - url: 'https://example.test/subscription', - hwidEnabled: false, - timestamp: DateTime(2026, 9, 1), - ); - controller.sources = [source]; - controller.servers = [ - _server(1).copyWith( - subId: 1, - data: outboundCompanion({'tag': 'Node 1', 'protocol': 'vless'}).data, - ), - ]; - await pumpBrowser(tester, 390, groupPage: true); - expect(find.byType(SubscriptionPackageDetails), findsNothing); - expect( - tester - .widget( - find.descendant( - of: find.byType(ServerUseButton), - matching: find.byType(OutlinedButton), - ), - ) - .onPressed, - isNotNull, - ); - controller.sources = [ - source.copyWith( - uploadBytes: const Value(1024), - downloadBytes: const Value(2048), - totalBytes: const Value(1024), - expireTimestamp: const Value(1), - userInfoUpdatedAt: Value(DateTime(2026, 9, 2)), - ), - ]; - await tester.pumpAndSettle(); - expect(find.byType(SubscriptionPackageDetails), findsOneWidget); - final l = AppLocalizations.of(tester.element(find.byType(Scaffold)))!; - expect(find.textContaining(l.subscriptionPackageExpired), findsOneWidget); - expect( - tester - .widget( - find.descendant( - of: find.byType(ServerUseButton), - matching: find.byType(OutlinedButton), - ), - ) - .onPressed, - isNotNull, - ); - controller.sources = [source]; - await tester.pumpAndSettle(); - expect(find.byType(SubscriptionPackageDetails), findsNothing); - expect(find.byType(ServerNodeRow), findsOneWidget); - expect(tester.takeException(), isNull); - }); + testWidgets( + 'source details refresh package information without restricting node use', + (tester) async { + final source = SubscriptionData( + panel: 'generic', + mode: 'appManaged', + id: 1, + name: 'Provider', + url: 'https://example.test/subscription', + hwidEnabled: false, + timestamp: DateTime(2026, 9, 1), + ); + controller.sources = [source]; + controller.servers = [_server(1).copyWith(subId: 1)]; + await pumpBrowser(tester, 390, routed: true); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + await tester.ensureVisible(find.text(l.prototypeSubscriptions).first); + await tester.tap(find.text(l.prototypeSubscriptions).first); + await tester.pumpAndSettle(); + await tester.tap(find.text(source.name)); + await tester.pumpAndSettle(); + expect(find.byType(ServerSourcePage), findsOneWidget); + expect(find.byType(SubscriptionPackageDetails), findsNothing); + controller.sources = [ + source.copyWith( + uploadBytes: const Value(1024), + downloadBytes: const Value(2048), + totalBytes: const Value(1024), + expireTimestamp: const Value(1), + userInfoUpdatedAt: Value(DateTime(2026, 9, 2)), + ), + ]; + await tester.pumpAndSettle(); + expect(find.byType(SubscriptionPackageDetails), findsOneWidget); + expect(find.textContaining(l.subscriptionPackageExpired), findsOneWidget); + await tester.scrollUntilVisible( + find.text('Node 1'), + 240, + scrollable: find + .descendant( + of: find.byType(ServerSourcePage), + matching: find.byType(Scrollable), + ) + .first, + ); + await tester.tap(find.text('Node 1')); + await tester.pumpAndSettle(); + expect(find.byType(ServerResourcePage), findsOneWidget); + expect( + tester + .widget( + find.widgetWithText(FilledButton, l.prototypeUse), + ) + .onPressed, + isNotNull, + ); + controller.sources = [source]; + await tester.pumpAndSettle(); + expect( + tester + .widget( + find.widgetWithText(FilledButton, l.prototypeUse), + ) + .onPressed, + isNotNull, + ); + expect(tester.takeException(), isNull); + }, + ); for (final brightness in Brightness.values) { for (final width in [427.0, 1160.0]) { @@ -974,18 +982,7 @@ void main() { testWidgets('group test can be cancelled without locking menus at $width', ( tester, ) async { - controller.sources = [ - SubscriptionData( - panel: 'generic', - mode: 'appManaged', - id: 4, - name: 'Source', - hwidEnabled: false, - url: 'https://example.test/sub', - timestamp: DateTime(2026), - ), - ]; - controller.servers = [_server(1).copyWith(subId: 4)]; + controller.servers = [_server(1)]; await pumpBrowser(tester, width, groupPage: true); final l = AppLocalizations.of( tester.element(find.byType(ServerGroupView)), @@ -1000,11 +997,11 @@ void main() { ), ); AppEventBus.instance.updatePinging(true); - expect(controller.testingGroup(group), isTrue); + expect(controller.testingGroup(group.id), isTrue); await tester.pump(); await tester.pump(); - for (final menu in [find.byType(ServerMenu), find.byType(SourceMenu)]) { + for (final menu in [find.byType(ServerMenu)]) { for (final element in menu.evaluate()) { final button = tester.widget( find.descendant( @@ -1021,7 +1018,7 @@ void main() { await tester.tap(find.widgetWithText(OutlinedButton, l.prototypeCancel)); await tester.pump(); await tester.pump(); - expect(controller.cancellingGroup(group), isTrue); + expect(controller.cancellingGroup(group.id), isTrue); expect(controller.state.serverTests.values.last.cancelling, isFalse); expect( tester @@ -1243,7 +1240,7 @@ void main() { expect(find.byType(ServerResourcePage), findsOneWidget); expect(find.text('Second provider'), findsOneWidget); expect(controller.configuration.encode(), before); - expect(controller.browsedOnMobile, isNull); + expect(controller.groupBrowseCount, 0); expect(tester.takeException(), isNull); }, ); @@ -1268,7 +1265,7 @@ void main() { expect(find.byType(ServerResourcePage), findsOneWidget); expect(find.widgetWithText(FilledButton, l.prototypeUse), findsOneWidget); expect(controller.configuration.encode(), before); - expect(controller.browsedOnMobile, isNull); + expect(controller.groupBrowseCount, 0); expect(tester.takeException(), isNull); }); diff --git a/test/pages/settings/appearance_startup_update_test.dart b/test/pages/settings/appearance_startup_update_test.dart index 51cd7c1d7..e2b82b1ff 100644 --- a/test/pages/settings/appearance_startup_update_test.dart +++ b/test/pages/settings/appearance_startup_update_test.dart @@ -20,7 +20,6 @@ import 'package:onexray/pages/settings/about/page.dart'; import 'package:onexray/pages/settings/controller.dart'; import 'package:onexray/pages/settings/widgets.dart'; import 'package:onexray/pages/shared/widgets/setting_row.dart'; -import 'package:onexray/pages/settings/theme/page.dart'; import 'package:onexray/pages/theme/theme.dart'; import 'package:onexray/service/settings/app_update/service.dart'; import 'package:onexray/service/shared/event_bus/enum.dart'; @@ -411,12 +410,12 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); await tester.pumpWidget( - app(ThemeChoiceView(selected: ThemeCode.dark, onSelected: (_) {})), + app(SettingsThemeOptions(selected: ThemeCode.dark, onSelected: (_) {})), ); await tester.pump(); final l = AppLocalizations.of( - tester.element(find.byType(ThemeChoiceView)), + tester.element(find.byType(SettingsThemeOptions)), )!; expect(find.text(l.themePageSystem), findsOneWidget); expect(find.text(l.themePageDark), findsOneWidget); @@ -438,69 +437,6 @@ void main() { expect(tester.takeException(), isNull); }); - testWidgets('theme selection stays a draft until Save persists and returns', ( - tester, - ) async { - await tester.binding.setSurfaceSize(const Size(1200, 900)); - addTearDown(() => tester.binding.setSurfaceSize(null)); - final previousPreferences = SharedPreferencesAsyncPlatform.instance; - SharedPreferencesAsyncPlatform.instance = - InMemorySharedPreferencesAsync.empty(); - addTearDown( - () => SharedPreferencesAsyncPlatform.instance = previousPreferences, - ); - await PreferencesKey().saveThemeCode(ThemeCode.system.name); - final bus = AppEventBus(); - addTearDown(bus.close); - final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, _) => Scaffold( - body: TextButton( - onPressed: () => context.push('/theme'), - child: const Text('Open theme'), - ), - ), - ), - GoRoute(path: '/theme', builder: (_, _) => const ThemePage()), - ], - ); - addTearDown(router.dispose); - await tester.pumpWidget( - MaterialApp.router( - theme: AppTheme.light, - routerConfig: router, - localizationsDelegates: AppLocalePolicy.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - builder: (context, child) => ShadTheme( - data: ShadThemeData(colorScheme: const ShadBlueColorScheme.light()), - child: ShadToaster(child: child!), - ), - ), - ); - await tester.tap(find.text('Open theme')); - await tester.pumpAndSettle(); - final l = AppLocalizations.of(tester.element(find.byType(ThemePage)))!; - await tester.tap(find.text(l.themePageDark)); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(ThemeChoiceView)).selected, - ThemeCode.dark, - ); - expect(bus.state.themeCode, ThemeCode.system); - expect(await PreferencesKey().readThemeCode(), ThemeCode.system.name); - - await tester.tap(find.text(l.buttonSave)); - await tester.pumpAndSettle(); - expect(await PreferencesKey().readThemeCode(), ThemeCode.dark.name); - expect(bus.state.themeCode, ThemeCode.dark); - expect(find.byType(ThemePage), findsNothing); - expect(find.text('Open theme'), findsOneWidget); - expect(find.text(l.prototypeSettingsSaved), findsOneWidget); - expect(tester.takeException(), isNull); - }); - testWidgets('update dialog keeps long notes scrollable with all actions', ( tester, ) async { diff --git a/test/service/servers/server_test.dart b/test/service/servers/server_test.dart index 319ace8de..ca77b8771 100644 --- a/test/service/servers/server_test.dart +++ b/test/service/servers/server_test.dart @@ -613,7 +613,8 @@ void main() { isTrue, ); await service.remove(await service.previewRemoval(ids: {profile.id})); - expect(await service.rows(), isEmpty); + expect(await db.coreConfigDao.searchRow(profile.id), isNull); + expect(await db.coreConfigDao.allActiveServerRows(), isEmpty); expect( (await coordinator.configuration).connection.selection.kind, SelectionKind.automatic, From 1a65598ede014ad8bd8bba7eae7caf8fcc7db2e1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 5 Nov 2026 09:33:41 +0800 Subject: [PATCH 57/60] fix(ui): unify Raw creation and repair JSON editor borders --- docs/app.md | 3 + docs/validation.md | 1 + lib/pages/connect/controller.dart | 2 - lib/pages/connect/page.dart | 1 - lib/pages/connect/view.dart | 10 +- lib/pages/servers/controller.dart | 2 +- lib/pages/servers/form_page.dart | 10 - lib/pages/servers/import/controller.dart | 11 +- lib/pages/servers/import/page.dart | 14 +- lib/pages/servers/view.dart | 14 +- lib/pages/shared/widgets/json_editor.dart | 187 ++++++++------- .../shared/widgets/page_empty_state.dart | 22 +- .../connect/configuration_sync_test.dart | 1 - test/pages/connect/controller_test.dart | 1 - test/pages/connect/view_test.dart | 31 +-- test/pages/servers/import_test.dart | 6 +- .../servers/raw_creation_navigation_test.dart | 219 ++++++++++++++++++ test/pages/servers/view_test.dart | 34 +++ .../widgets/json_editor_frame_test.dart | 83 +++++++ 19 files changed, 494 insertions(+), 158 deletions(-) create mode 100644 test/pages/servers/raw_creation_navigation_test.dart create mode 100644 test/pages/shared/widgets/json_editor_frame_test.dart diff --git a/docs/app.md b/docs/app.md index 30588655a..9b9feee21 100644 --- a/docs/app.md +++ b/docs/app.md @@ -184,6 +184,9 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、 完整配置列表提供独立收藏筛选,列表与详情都可修改提供方收藏;个人 Raw 不支持收藏,不因切换资源标签继承普通节点的收藏筛选。 普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。 资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。 +新建资源统一从“添加服务器”页面选择:普通节点手动 JSON 与完整 Raw JSON 为独立方式,Raw 复用原有完整配置编辑器、校验和配额。 +连接空状态只保留添加服务器主入口,完整配置列表及空状态同样先进入添加方式页,不再直接新建 Raw。 +取消 Raw 新建返回添加方式页,不写入或改变选择;保存成功后结束添加流程。已有 Raw 详情的编辑仍按原 ID 打开,不经过添加方式页。 导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。 节点、手动添加、Raw 与高级 JSON 复用全宽编辑工作区和原有 `re_editor`:编辑器占满 AppBar 与固定操作区之间扣除名称、工具和必要提示后的剩余宽高,不设置固定编辑器高度或正文最大宽度,编辑器保留 160 逻辑像素的最小高度。 名称与工具、提示可分别滚动;正常空间仅在代码编辑器内部滚动,横屏、短屏或键盘压缩到最小布局以下时,工作区整体允许滚动,保存区仍固定。 diff --git a/docs/validation.md b/docs/validation.md index 99396903d..6c0665864 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -76,6 +76,7 @@ 重连确认取消保留持久化选择和运行快照;确认前资产被删或来源方式改变时不得按旧列表提交。 - 节点、手动添加、Raw 和高级 JSON 编辑检查编辑器随可用正文宽高伸缩,填满名称、工具与提示之外的剩余区域; 正常空间不保留固定代码框高度或整页外层滚动。横屏、短屏和键盘下编辑器至少 160 逻辑像素,空间不足时工作区整体可滚动,名称/工具/必要提示仍可到达,保存区固定。 + 编辑页 AppBar 仅保留返回与有效页面动作,不另放重复关闭按钮;明暗主题下检查四边和圆角,行号、代码及双向滚动条不遮盖边框。 节点、Raw 和高级 JSON 全宽编辑覆盖干净/修改/手动还原、关闭/取消/系统返回/Escape、继续/明确放弃、 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;光标移动不算修改,名称与原文还原恢复干净。 旧节点弹窗另覆盖遮罩关闭,全宽页不增加遮罩路径;明确放弃不写库,成功保存不循环提示。 diff --git a/lib/pages/connect/controller.dart b/lib/pages/connect/controller.dart index 6332bd702..bf61ec074 100644 --- a/lib/pages/connect/controller.dart +++ b/lib/pages/connect/controller.dart @@ -555,8 +555,6 @@ class ConnectController extends PageCubit with ServerLabels { Future addServers(BuildContext context) => context.pushScoped(AppPageDestination.serversImport); - Future editRaw(BuildContext context, [int? id]) => - context.pushScoped(AppPageDestination.rawEditor, extra: id); Future chooseServer(BuildContext context) async { if (connectionView.busy || pendingChange != null) return; final l = AppLocalizations.of(context)!; diff --git a/lib/pages/connect/page.dart b/lib/pages/connect/page.dart index 562d26555..188e710c0 100644 --- a/lib/pages/connect/page.dart +++ b/lib/pages/connect/page.dart @@ -99,7 +99,6 @@ class _ConnectPageState extends State { onServer: () => controller.chooseServer(context), onMethod: () => controller.chooseTrafficMethod(context), onWhy: () => controller.showWhy(context), - onRawAdd: () => controller.editRaw(context), ); }, ), diff --git a/lib/pages/connect/view.dart b/lib/pages/connect/view.dart index bc1f143f8..8cc0fa4cb 100644 --- a/lib/pages/connect/view.dart +++ b/lib/pages/connect/view.dart @@ -37,7 +37,6 @@ class ConnectView extends StatelessWidget { required this.onServer, required this.onMethod, required this.onWhy, - required this.onRawAdd, }); final ConnectionView view; final Widget Function(bool desktop)? trafficBuilder; @@ -53,12 +52,7 @@ class ConnectView extends StatelessWidget { final String? locationDetail, locationHealth, methodDetail; final String method; final bool methodEnabled; - final VoidCallback onConnection, - onAddServers, - onServer, - onMethod, - onWhy, - onRawAdd; + final VoidCallback onConnection, onAddServers, onServer, onMethod, onWhy; bool get _connectionPending => pendingChange == 'connection'; bool get _busy => view.busy || _connectionPending; @@ -143,8 +137,6 @@ class ConnectView extends StatelessWidget { description: l.prototypeFirstConnectionHint, primaryLabel: l.prototypeAddServers, onPrimary: onAddServers, - secondaryLabel: l.prototypeUseCompleteRawJson, - onSecondary: onRawAdd, ), ); } diff --git a/lib/pages/servers/controller.dart b/lib/pages/servers/controller.dart index 92ee190ea..de8a735e0 100644 --- a/lib/pages/servers/controller.dart +++ b/lib/pages/servers/controller.dart @@ -365,7 +365,7 @@ class ServersController extends PageCubit ); } - Future editRaw(BuildContext context, [int? id]) => + Future editRaw(BuildContext context, int id) => context.pushScoped(AppPageDestination.rawEditor, extra: id); Future chooseRaw(BuildContext context, CoreConfigData row) async { diff --git a/lib/pages/servers/form_page.dart b/lib/pages/servers/form_page.dart index 792ced04c..f862c8d06 100644 --- a/lib/pages/servers/form_page.dart +++ b/lib/pages/servers/form_page.dart @@ -1,7 +1,5 @@ import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/widgets/page_action_bar.dart'; import 'package:onexray/pages/shared/widgets/page_app_bar.dart'; import 'package:onexray/pages/shared/widgets/responsive_content.dart'; @@ -65,14 +63,6 @@ class ServerFormPage extends StatelessWidget { appBar: PageAppBar( title: Text(title), leading: BackButton(onPressed: onBack ?? onClose), - actions: [ - if (onBack != null && onClose != null) - IconButton( - tooltip: AppLocalizations.of(context)!.prototypeCloseDialog, - onPressed: onClose, - icon: const Icon(LucideIcons.x, size: 20), - ), - ], ), body: SafeArea( child: expandBody diff --git a/lib/pages/servers/import/controller.dart b/lib/pages/servers/import/controller.dart index 5cad8b127..754c6829c 100644 --- a/lib/pages/servers/import/controller.dart +++ b/lib/pages/servers/import/controller.dart @@ -9,6 +9,7 @@ import 'package:onexray/core/tools/platform.dart'; import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/shared/alert.dart'; import 'package:onexray/pages/shared/page_cubit.dart'; +import 'package:onexray/pages/main/navigation.dart'; import 'package:onexray/pages/servers/import/page.dart'; import 'package:onexray/pages/servers/subscription/format_confirmation.dart'; import 'package:onexray/service/servers/import.dart'; @@ -20,7 +21,7 @@ import 'package:onexray/service/servers/subscription/validator.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:re_editor/re_editor.dart'; -enum ServerImportAction { scan, paste, subscription, file, json } +enum ServerImportAction { scan, paste, subscription, file, json, raw } enum SubscriptionFormField { name, url, userAgent } @@ -306,6 +307,7 @@ class ServerImportController extends PageCubit { bool canSubmit(ServerImportAction action) { if (state.busy || state.generatingAgeKey || state.loadFailed) return false; + if (action == ServerImportAction.raw) return false; if (action == ServerImportAction.subscription) { return _subscriptionValidation == null; } @@ -452,7 +454,12 @@ class ServerImportController extends PageCubit { state.copyWith(error: null, committedResult: null, activeAction: action), ); ServerImportResult? result; - if (action == ServerImportAction.file || + if (action == ServerImportAction.raw) { + final id = await context.pushScoped(AppPageDestination.rawEditor); + if (id != null) { + result = const ServerImportResult(count: 0, rawCount: 1); + } + } else if (action == ServerImportAction.file || action == ServerImportAction.scan) { String? input; emit(state.copyWith(busy: true, openingAction: action)); diff --git a/lib/pages/servers/import/page.dart b/lib/pages/servers/import/page.dart index dc0699240..fd552cf2f 100644 --- a/lib/pages/servers/import/page.dart +++ b/lib/pages/servers/import/page.dart @@ -113,6 +113,14 @@ class _ServersImportPageState extends State { ServerImportAction.json, LucideIcons.fileJson, l10n.prototypeAddManually, + subtitle: l10n.prototypeXrayNodeJson, + ), + _choice( + context, + ServerImportAction.raw, + LucideIcons.fileJson, + l10n.prototypeAddRawJson, + subtitle: l10n.prototypeCompleteXrayConfiguration, ), ], ), @@ -130,10 +138,12 @@ class _ServersImportPageState extends State { BuildContext context, ServerImportAction action, IconData icon, - String title, - ) => SettingRow( + String title, { + String? subtitle, + }) => SettingRow( key: ValueKey('server-import-choice-${action.name}'), title: title, + subtitle: subtitle, enabled: !controller.state.busy, showChevron: true, onTap: () => controller.open(context, action), diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index c51134c9d..d4ce7c286 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -312,9 +312,9 @@ class _ServerBrowserState extends State { ), ] else FilledButton.icon( - onPressed: () => controller.editRaw(context), + onPressed: () => controller.addServers(context), icon: const Icon(LucideIcons.plus, size: 17), - label: Text(l.prototypeAddRawJson), + label: Text(l.prototypeAddServer), ), IconButton( key: const ValueKey('server-favorites-filter'), @@ -394,14 +394,8 @@ class _ServerBrowserState extends State { ) else TextButton( - onPressed: () => complete - ? controller.editRaw(context) - : controller.addServers(context), - child: Text( - complete - ? l.prototypeAddRawJson - : l.prototypeAddServer, - ), + onPressed: () => controller.addServers(context), + child: Text(l.prototypeAddServer), ), ], ), diff --git a/lib/pages/shared/widgets/json_editor.dart b/lib/pages/shared/widgets/json_editor.dart index 49d3befe0..0a3a46cc5 100644 --- a/lib/pages/shared/widgets/json_editor.dart +++ b/lib/pages/shared/widgets/json_editor.dart @@ -272,97 +272,110 @@ class _AppJsonEditorState extends State return MaterialUiCompatibilityBridge( child: Directionality( textDirection: TextDirection.ltr, - child: CodeEditor( - controller: widget.controller, - onChanged: (_) { - if (_focus.hasFocus) _revealEditor(); - }, - focusNode: _focus, - scrollController: _scroll, - toolbarController: _toolbar, - shortcutOverrideActions: state.completions.isEmpty - ? null - : { - CodeShortcutCursorMoveIntent: - CallbackAction( - onInvoke: (intent) { - if (intent.direction == AxisDirection.up) { - _cubit.selectCompletion(-1); - } else if (intent.direction == AxisDirection.down) { - _cubit.selectCompletion(1); - } else { - widget.controller.moveCursor(intent.direction); - } - return null; - }, - ), - CodeShortcutNewLineIntent: - CallbackAction( - onInvoke: (_) { - _applyCompletion(); - return null; - }, - ), - CodeShortcutIndentIntent: - CallbackAction( - onInvoke: (_) { - _applyCompletion(); - return null; - }, - ), - CodeShortcutEscIntent: CallbackAction( - onInvoke: (_) { - _cubit.dismissCompletions(); - return null; + // re_editor paints its border behind the gutter and code viewport. + // Keep our frame above that content and clip the whole editor, including + // scrollbars, to the same rounded outline. + child: DecoratedBox( + position: DecorationPosition.foreground, + decoration: BoxDecoration( + border: Border.all(color: palette.input), + borderRadius: BorderRadius.circular(AppRadii.control), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadii.control), + child: CodeEditor( + controller: widget.controller, + onChanged: (_) { + if (_focus.hasFocus) _revealEditor(); + }, + focusNode: _focus, + scrollController: _scroll, + toolbarController: _toolbar, + shortcutOverrideActions: state.completions.isEmpty + ? null + : { + CodeShortcutCursorMoveIntent: + CallbackAction( + onInvoke: (intent) { + if (intent.direction == AxisDirection.up) { + _cubit.selectCompletion(-1); + } else if (intent.direction == + AxisDirection.down) { + _cubit.selectCompletion(1); + } else { + widget.controller.moveCursor(intent.direction); + } + return null; + }, + ), + CodeShortcutNewLineIntent: + CallbackAction( + onInvoke: (_) { + _applyCompletion(); + return null; + }, + ), + CodeShortcutIndentIntent: + CallbackAction( + onInvoke: (_) { + _applyCompletion(); + return null; + }, + ), + CodeShortcutEscIntent: + CallbackAction( + onInvoke: (_) { + _cubit.dismissCompletions(); + return null; + }, + ), }, + autofocus: false, + autocompleteSymbols: true, + wordWrap: false, + padding: EdgeInsets.symmetric( + horizontal: 13, + vertical: + MediaQuery.sizeOf(context).width <= + AppLayout.mobileBreakpoint + ? 14 + : 12, + ), + style: CodeEditorStyle( + fontFamily: AppFontFamily.mono, + fontSize: style.fontSize, + fontHeight: style.height, + textColor: palette.foreground, + backgroundColor: palette.card, + selectionColor: palette.selection, + cursorColor: palette.primary, + cursorLineColor: palette.primary.withValues(alpha: .05), + codeTheme: CodeHighlightTheme( + languages: {'json': CodeHighlightThemeMode(mode: langJson)}, + theme: codeTheme, + ), + ), + indicatorBuilder: (context, editingController, _, notifier) { + _indicatorValue = notifier; + return Container( + key: _indicatorKey, + color: Color.lerp(palette.card, palette.surfaceHover, .76), + // Paragraph offsets already include the editor's top padding. + padding: const EdgeInsets.symmetric(horizontal: 5), + child: DefaultCodeLineNumber( + controller: editingController, + notifier: notifier, + textStyle: lineNumberStyle, + focusedTextStyle: lineNumberStyle.copyWith( + color: palette.primary, + fontWeight: FontWeight.w600, + ), ), - }, - autofocus: false, - autocompleteSymbols: true, - wordWrap: false, - padding: EdgeInsets.symmetric( - horizontal: 13, - vertical: - MediaQuery.sizeOf(context).width <= AppLayout.mobileBreakpoint - ? 14 - : 12, - ), - border: Border.all(color: palette.input), - borderRadius: BorderRadius.circular(AppRadii.control), - clipBehavior: Clip.antiAlias, - style: CodeEditorStyle( - fontFamily: AppFontFamily.mono, - fontSize: style.fontSize, - fontHeight: style.height, - textColor: palette.foreground, - backgroundColor: palette.card, - selectionColor: palette.selection, - cursorColor: palette.primary, - cursorLineColor: palette.primary.withValues(alpha: .05), - codeTheme: CodeHighlightTheme( - languages: {'json': CodeHighlightThemeMode(mode: langJson)}, - theme: codeTheme, + ); + }, + leadingDivider: Container(width: 1, color: palette.border), ), ), - indicatorBuilder: (context, editingController, _, notifier) { - _indicatorValue = notifier; - return Container( - key: _indicatorKey, - color: Color.lerp(palette.card, palette.surfaceHover, .76), - // Paragraph offsets already include the editor's top padding. - padding: const EdgeInsets.symmetric(horizontal: 5), - child: DefaultCodeLineNumber( - controller: editingController, - notifier: notifier, - textStyle: lineNumberStyle, - focusedTextStyle: lineNumberStyle.copyWith( - color: palette.primary, - fontWeight: FontWeight.w600, - ), - ), - ); - }, - leadingDivider: Container(width: 1, color: palette.border), ), ), ); diff --git a/lib/pages/shared/widgets/page_empty_state.dart b/lib/pages/shared/widgets/page_empty_state.dart index f566f929b..6c26ea724 100644 --- a/lib/pages/shared/widgets/page_empty_state.dart +++ b/lib/pages/shared/widgets/page_empty_state.dart @@ -15,13 +15,15 @@ class PageEmptyState extends StatelessWidget { required this.description, required this.primaryLabel, required this.onPrimary, - required this.secondaryLabel, - required this.onSecondary, + this.secondaryLabel, + this.onSecondary, this.scrollController, }); - final String title, description, primaryLabel, secondaryLabel; - final VoidCallback onPrimary, onSecondary; + final String title, description, primaryLabel; + final String? secondaryLabel; + final VoidCallback onPrimary; + final VoidCallback? onSecondary; final ScrollController? scrollController; @override @@ -106,11 +108,13 @@ class PageEmptyState extends StatelessWidget { ), ), ], - const SizedBox(height: 4), - TextButton( - onPressed: onSecondary, - child: Text(secondaryLabel, textAlign: TextAlign.center), - ), + if (secondaryLabel != null && onSecondary != null) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: onSecondary, + child: Text(secondaryLabel!, textAlign: TextAlign.center), + ), + ], ], ), ), diff --git a/test/pages/connect/configuration_sync_test.dart b/test/pages/connect/configuration_sync_test.dart index 0312b2fee..49a87279b 100644 --- a/test/pages/connect/configuration_sync_test.dart +++ b/test/pages/connect/configuration_sync_test.dart @@ -201,7 +201,6 @@ Widget _screen(ConnectController controller) => MaterialApp( onServer: () => controller.chooseServer(context), onMethod: () {}, onWhy: () {}, - onRawAdd: () {}, ); }, ), diff --git a/test/pages/connect/controller_test.dart b/test/pages/connect/controller_test.dart index b24b452e4..e0ee1bb25 100644 --- a/test/pages/connect/controller_test.dart +++ b/test/pages/connect/controller_test.dart @@ -1447,7 +1447,6 @@ Widget _connectionScreen(ConnectController controller) => ShadTheme( onServer: () {}, onMethod: () {}, onWhy: () {}, - onRawAdd: () {}, ), ), ), diff --git a/test/pages/connect/view_test.dart b/test/pages/connect/view_test.dart index c862d203e..8c4b7f844 100644 --- a/test/pages/connect/view_test.dart +++ b/test/pages/connect/view_test.dart @@ -46,7 +46,6 @@ void main() { List raws = const [], int? activeRawId, VoidCallback? onAddServers, - VoidCallback? onRawAdd, VoidCallback? onServer, }) => ConnectView( view: view, @@ -65,7 +64,6 @@ void main() { onServer: onServer ?? () {}, onMethod: onMethod ?? () {}, onWhy: () {}, - onRawAdd: onRawAdd ?? () {}, ); Widget app( @@ -519,15 +517,16 @@ void main() { ); } - testWidgets('empty connection offers complete configuration import', ( + testWidgets('empty connection opens the shared add-server flow', ( tester, ) async { var switched = false; await tester.pumpWidget( - app(screen(hasServers: false, onRawAdd: () => switched = true)), + app(screen(hasServers: false, onAddServers: () => switched = true)), ); await tester.pumpAndSettle(); - await tester.tap(find.text('Use a complete Raw JSON configuration')); + expect(find.text('Use a complete Raw JSON configuration'), findsNothing); + await tester.tap(find.widgetWithText(FilledButton, 'Add servers')); expect(switched, isTrue); }); @@ -580,12 +579,8 @@ void main() { greaterThanOrEqualTo(AppLayout.emptyStateDesktopMinHeight), ); final add = find.widgetWithText(FilledButton, l.prototypeAddServers); - final complete = find.widgetWithText( - TextButton, - l.prototypeUseCompleteRawJson, - ); expect(add.hitTestable(), findsOneWidget); - expect(complete.hitTestable(), findsOneWidget); + expect(find.text(l.prototypeUseCompleteRawJson), findsNothing); if (BackupService.supported) { expect( find @@ -604,32 +599,26 @@ void main() { addTearDown(() => tester.binding.setSurfaceSize(null)); for (final size in [const Size(760, 240), const Size(390, 600)]) { var added = false; - var switched = false; await tester.binding.setSurfaceSize(size); await tester.pumpWidget( app( - screen( - hasServers: false, - onAddServers: () => added = true, - onRawAdd: () => switched = true, - ), + screen(hasServers: false, onAddServers: () => added = true), scale: 1.5, ), ); await tester.pumpAndSettle(); for (final action in [ find.widgetWithText(FilledButton, 'Add servers'), - find.widgetWithText( - TextButton, - 'Use a complete Raw JSON configuration', - ), ]) { await tester.ensureVisible(action); await tester.pumpAndSettle(); await tester.tap(action); } expect(added, isTrue); - expect(switched, isTrue); + expect( + find.text('Use a complete Raw JSON configuration'), + findsNothing, + ); expect(tester.takeException(), isNull); } }, diff --git a/test/pages/servers/import_test.dart b/test/pages/servers/import_test.dart index 77dbf1551..cfcd478ea 100644 --- a/test/pages/servers/import_test.dart +++ b/test/pages/servers/import_test.dart @@ -65,6 +65,7 @@ void main() { ServerImportAction.subscription, ServerImportAction.file, ServerImportAction.json, + ServerImportAction.raw, ]) { final choice = find.byKey( ValueKey('server-import-choice-${action.name}'), @@ -75,7 +76,7 @@ void main() { } expect( find.byType(SettingRow), - findsNWidgets(controller.supportsScan ? 5 : 4), + findsNWidgets(controller.supportsScan ? 6 : 5), ); expect(tester.takeException(), isNull); }, @@ -902,7 +903,8 @@ void main() { .controller, same(controller), ); - await tester.tap(find.byTooltip('Close dialog')); + expect(find.byTooltip('Close dialog'), findsNothing); + await tester.tap(find.widgetWithText(OutlinedButton, 'Cancel')); await tester.pumpAndSettle(); expect(completed, isTrue); expect(result, isNull); diff --git a/test/pages/servers/raw_creation_navigation_test.dart b/test/pages/servers/raw_creation_navigation_test.dart new file mode 100644 index 000000000..c93d51e6d --- /dev/null +++ b/test/pages/servers/raw_creation_navigation_test.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/core/db/database/database.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/main/navigation.dart'; +import 'package:onexray/pages/servers/controller.dart'; +import 'package:onexray/pages/servers/details.dart'; +import 'package:onexray/pages/servers/import/controller.dart'; +import 'package:onexray/pages/servers/import/page.dart'; +import 'package:onexray/pages/shared/widgets/outbound_json_editor.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/connect/coordinator.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:onexray/service/shared/event_bus/service.dart'; +import 'package:shadcn_ui/shadcn_ui.dart' show ShadTheme, ShadToaster; + +import 'fixtures/server_test_router.dart'; + +void main() { + late ServerImportController importer; + late GoRouter router; + late List rawEditorIds; + ServerImportResult? result; + bool returned = false; + + setUp(() { + final bus = AppEventBus(); + addTearDown(bus.close); + importer = ServerImportController(loadSubscription: (_) async => null); + rawEditorIds = []; + result = null; + returned = false; + }); + + tearDown(() async { + router.dispose(); + await importer.close(); + }); + + Future open(WidgetTester tester, {WidgetBuilder? home}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + router = serverTestRouter( + home: + home ?? + (context) => Scaffold( + body: TextButton( + onPressed: () async { + result = await context.pushScoped( + AppPageDestination.serversImport, + ); + returned = true; + }, + child: const Text('Open add servers'), + ), + ), + extraRoutes: [ + GoRoute( + path: 'servers-import', + builder: (_, _) => ServersImportPage(controller: importer), + ), + GoRoute( + path: 'raw-editor', + builder: (context, state) { + rawEditorIds.add(state.extra as int?); + // The production JSON editor returns its saved row ID. This stub + // checks only the surrounding route/result contract; Raw saving and + // dirty-draft behavior are covered by the editor's own tests. + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const Text('Raw editor destination'), + ), + body: TextButton( + onPressed: () => Navigator.of(context).pop(42), + child: const Text('Finish Raw save'), + ), + ); + }, + ), + ], + ); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + theme: AppTheme.light, + locale: const Locale('en'), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: (_, child) => ShadTheme( + data: AppTheme.shad(Brightness.light), + child: ShadToaster(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets( + 'Raw creation is an explicit add-server method; cancel returns and save ends the flow', + (tester) async { + importer.text.text = 'Retained paste draft'; + await open(tester); + await tester.tap(find.text('Open add servers')); + await tester.pumpAndSettle(); + final choice = find.byKey(const ValueKey('server-import-choice-raw')); + await tester.ensureVisible(choice); + await tester.tap(choice); + await tester.pumpAndSettle(); + expect(find.text('Raw editor destination'), findsOneWidget); + expect(rawEditorIds, [null]); + expect(find.byType(OutboundJsonEditor), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(ServersImportPage), findsOneWidget); + expect(importer.state.activeAction, isNull); + expect(importer.text.text, 'Retained paste draft'); + expect(returned, isFalse); + await tester.ensureVisible(choice); + await tester.tap(choice); + await tester.pumpAndSettle(); + expect(rawEditorIds, [null, null]); + await tester.tap(find.text('Finish Raw save')); + await tester.pumpAndSettle(); + expect(returned, isTrue); + expect(result!.count, 0); + expect(result!.rawCount, 1); + expect(find.byType(ServersImportPage), findsNothing); + expect(find.text('Open add servers'), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('ordinary manual JSON stays a separate outbound flow', ( + tester, + ) async { + await open(tester); + await tester.tap(find.text('Open add servers')); + await tester.pumpAndSettle(); + final choice = find.byKey(const ValueKey('server-import-choice-json')); + await tester.ensureVisible(choice); + await tester.tap(choice); + await tester.pumpAndSettle(); + expect(find.byType(ServerImportFormPage), findsOneWidget); + expect(find.byType(OutboundJsonEditor), findsOneWidget); + expect(rawEditorIds, isEmpty); + final l = AppLocalizations.of( + tester.element(find.byType(ServerImportFormPage)), + )!; + expect(find.text(l.prototypeXrayNodeJson), findsOneWidget); + expect(find.byTooltip(l.prototypeCloseDialog), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(ServersImportPage), findsOneWidget); + expect(returned, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'existing Raw detail still edits its original ID without opening the add-server methods', + (tester) async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + final coordinator = ConnectionCoordinator(database: db); + final servers = ServersController(database: db, coordinator: coordinator); + addTearDown(() async { + await servers.close(); + coordinator.dispose(); + await db.close(); + }); + servers.emit( + servers.state.copyWith( + rawConfigurations: [ + CoreConfigData( + id: 17, + name: 'Existing Raw', + type: 'raw', + tags: '', + subId: 0, + delay: 0, + manualSortOrder: 0, + favorite: false, + data: base64Encode(utf8.encode('{"outbounds":[]}')), + ), + ], + ), + ); + await open( + tester, + home: (_) => ServerResourcePage( + params: ServerResourceParams( + controller: servers, + resourceId: 17, + raw: true, + ), + ), + ); + final l = AppLocalizations.of( + tester.element(find.byType(ServerResourcePage)), + )!; + await tester.tap( + find.widgetWithText(OutlinedButton, l.prototypeEditRawJson), + ); + await tester.pumpAndSettle(); + expect(rawEditorIds, [17]); + expect(find.byType(ServersImportPage), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byType(ServerResourcePage), findsOneWidget); + expect(find.text('Existing Raw'), findsWidgets); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/pages/servers/view_test.dart b/test/pages/servers/view_test.dart index 1b910a3e3..1d01aae9c 100644 --- a/test/pages/servers/view_test.dart +++ b/test/pages/servers/view_test.dart @@ -255,6 +255,40 @@ void main() { return batches; } + testWidgets( + 'complete configuration toolbar and empty state use the shared add-server action', + (tester) async { + await pumpBrowser(tester, 390); + final l = AppLocalizations.of( + tester.element(find.byType(ServerBrowser)), + )!; + final selection = controller.configuration.toJson(); + final completeTab = find.text(l.serverCompleteConfigurationsTab).first; + await tester.ensureVisible(completeTab); + await tester.tap(completeTab); + await tester.pumpAndSettle(); + expect(find.text(l.prototypeNoRawJson), findsOneWidget); + expect(find.text(l.prototypeAddRawJson), findsNothing); + + final toolbarAdd = find.widgetWithText( + FilledButton, + l.prototypeAddServer, + ); + await tester.ensureVisible(toolbarAdd); + await tester.tap(toolbarAdd); + expect(controller.addOpened, isTrue); + controller.addOpened = false; + + final emptyAdd = find.widgetWithText(TextButton, l.prototypeAddServer); + await tester.ensureVisible(emptyAdd); + await tester.tap(emptyAdd); + expect(controller.addOpened, isTrue); + expect(controller.configuration.toJson(), selection); + expect(coordinator.state.value.phase, ConnectionPhase.disconnected); + expect(tester.takeException(), isNull); + }, + ); + testWidgets( 'sorting changes display order without selecting or testing nodes', (tester) async { diff --git a/test/pages/shared/widgets/json_editor_frame_test.dart b/test/pages/shared/widgets/json_editor_frame_test.dart new file mode 100644 index 000000000..99c92f934 --- /dev/null +++ b/test/pages/shared/widgets/json_editor_frame_test.dart @@ -0,0 +1,83 @@ +import 'dart:ui' as ui; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:onexray/l10n/localizations/app_localizations.dart'; +import 'package:onexray/pages/shared/widgets/json_editor.dart'; +import 'package:onexray/pages/theme/color.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/service/settings/language/locale.dart'; +import 'package:re_editor/re_editor.dart'; + +void main() { + for (final brightness in Brightness.values) { + testWidgets('JSON frame stays continuous when content scrolls: $brightness', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(420, 320); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + final controller = CodeLineEditingController.fromText( + '{\n${List.generate(100, (i) => ' "line$i": "${'x' * 100}"').join(',\n')}\n}', + ); + addTearDown(controller.dispose); + final frame = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.material(brightness), + localizationsDelegates: AppLocalePolicy.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(20), + child: RepaintBoundary( + key: frame, + child: AppJsonEditor(controller: controller), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final context = tester.element(find.byType(AppJsonEditor)); + final expected = ColorManager.palette(context).input; + Future expectFrame() async => tester.runAsync(() async { + final boundary = + frame.currentContext!.findRenderObject()! as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 1); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + for (final (x, y) in [ + for (var y = 20; y < image.height - 20; y += 20) (0, y), + for (var y = 20; y < image.height - 20; y += 20) (image.width - 1, y), + for (var x = 20; x < image.width - 20; x += 20) (x, 0), + for (var x = 20; x < image.width - 20; x += 20) (x, image.height - 1), + ]) { + final offset = (y * image.width + x) * 4; + final actual = Color.fromARGB( + bytes.getUint8(offset + 3), + bytes.getUint8(offset), + bytes.getUint8(offset + 1), + bytes.getUint8(offset + 2), + ); + expect(actual, expected, reason: 'Missing frame at ($x, $y)'); + } + image.dispose(); + }); + + await expectFrame(); + final scroll = tester + .widget(find.byType(CodeEditor)) + .scrollController!; + scroll.verticalScroller.jumpTo(250); + scroll.horizontalScroller.jumpTo(100); + await tester.pumpAndSettle(); + await expectFrame(); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } +} From 3eb98a9fe8164ddf19d95c7802ae40bf00eb607f Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 5 Nov 2026 09:54:44 +0800 Subject: [PATCH 58/60] fix(ui): highlight target categories and clarify configuration types --- docs/app.md | 4 +- docs/validation.md | 1 + lib/l10n/app_en.arb | 6 +- lib/l10n/app_fa.arb | 3 +- lib/l10n/app_ru.arb | 3 +- lib/l10n/app_zh.arb | 3 +- lib/l10n/app_zh_Hant.arb | 3 +- lib/pages/connect/target_picker.dart | 92 +++++++++++----------- lib/pages/servers/view.dart | 10 ++- lib/pages/theme/theme.dart | 14 ++++ test/pages/connect/target_picker_test.dart | 73 +++++++++++++++-- test/pages/servers/view_test.dart | 12 ++- 12 files changed, 161 insertions(+), 63 deletions(-) diff --git a/docs/app.md b/docs/app.md index 9b9feee21..a84fbca93 100644 --- a/docs/app.md +++ b/docs/app.md @@ -157,7 +157,9 @@ FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、 首页按节点、完整配置、订阅三类资源浏览。节点与完整配置使用单列列表,普通节点、个人 Raw 和提供方配置 保留明确类型;订阅来源有独立详情,展示当前方式、套餐、缓存状态及更新、编辑和分享入口。 浏览资源或打开详情不修改连接目标;详情中的“使用”才提交选择,编辑、测速、分享和删除分别执行各自动作。 -完整配置类别包含个人 Raw 与提供方配置,原图查看、复制编辑与选择不互相替代。 +完整配置指以整份 Xray JSON 运行的连接资源,不使用普通节点的路由偏好。 +类别包含个人 Raw JSON 和订阅下发的提供方配置:个人 Raw JSON 可编辑;提供方配置只读、随订阅更新, +需要编辑时先复制为个人 Raw JSON。原图查看、复制编辑与选择不互相替代;完整配置工具区提供按需查看的定义与类型说明。 服务器的订阅按 ID 升序,普通手动节点可整理为一层自定义分组;未分组固定第一,空自定义组保留。 支持新建、重命名和通过节点菜单移组;解散分组保留节点,删除分组及节点需确认数量与运行影响。 diff --git a/docs/validation.md b/docs/validation.md index 6c0665864..2fd078001 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -73,6 +73,7 @@ - 连接页检查计划与实际运行分离、Raw/provider 名称及入口/最终出口快照、回退来源与实际目标说明, 重命名、隐藏缓存或新计划不改写运行身份;未知速率/流量/时长不填假值。空库首屏的导入、恢复和完整配置入口可达。 目标选择器覆盖三标签、搜索、长列表按需构建、管理返回与取消不提交;来源/节点同数字 ID、Raw/provider 同名不串选。 + 三个分类标签在明暗主题下有清晰的实色选中态与可读文字;分类切换和完整配置定义帮助只用于浏览,不提交连接选择。 重连确认取消保留持久化选择和运行快照;确认前资产被删或来源方式改变时不得按旧列表提交。 - 节点、手动添加、Raw 和高级 JSON 编辑检查编辑器随可用正文宽高伸缩,填满名称、工具与提示之外的剩余区域; 正常空间不保留固定代码框高度或整页外层滚动。横屏、短屏和键盘下编辑器至少 160 逻辑像素,空间不足时工作区整体可滚动,名称/工具/必要提示仍可到达,保存区固定。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7d5ad9675..bf58aff8a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -816,10 +816,6 @@ "@prototypeOrdinaryConnectionRunning": { "description": "Approved prototype copy: The ordinary connection is still running. Select a configuration to switch." }, - "prototypeNoRawJson": "No Raw JSON configurations yet", - "@prototypeNoRawJson": { - "description": "Approved prototype copy: No Raw JSON configurations yet" - }, "prototypeAddRawJsonHint": "Add a complete Xray configuration, then select it here to connect.", "@prototypeAddRawJsonHint": { "description": "Approved prototype copy: Add a complete Xray configuration, then select it here to connect." @@ -3119,6 +3115,8 @@ "navigationRouting": "Routing", "serverNodesTab": "Nodes", "serverCompleteConfigurationsTab": "Configurations", + "serverNoCompleteConfigurations": "No configurations yet", + "completeConfigurationDefinition": "Runs the entire Xray JSON configuration without applying routing preferences for ordinary nodes.\n\nIncludes personal Raw JSON and provider configurations delivered by subscriptions. Personal Raw JSON is editable. Provider configurations are read-only and update with the subscription; copy one to personal Raw JSON to edit it.", "serverAllSources": "All sources", "connectionPlan": "Connection plan", "serverSelectionAutomaticScope": "Automatic scope", diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b0868d828..751495d1e 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -412,7 +412,6 @@ "prototypeDownload": "دانلود", "prototypeUpload": "آپلود", "prototypeOrdinaryConnectionRunning": "اتصال عادی همچنان برقرار است. برای تغییر، یک پیکربندی انتخاب کنید.", - "prototypeNoRawJson": "هنوز پیکربندی JSON خامی وجود ندارد", "prototypeAddRawJsonHint": "یک پیکربندی کامل Xray اضافه کنید، سپس برای اتصال آن را اینجا انتخاب کنید.", "prototypeAddRawJson": "افزودن JSON خام", "prototypeEditRawJson": "ویرایش JSON خام", @@ -988,6 +987,8 @@ "navigationRouting": "مسیریابی", "serverNodesTab": "گره‌ها", "serverCompleteConfigurationsTab": "پیکربندی‌ها", + "serverNoCompleteConfigurations": "هنوز پیکربندی‌ای وجود ندارد", + "completeConfigurationDefinition": "یک پیکربندی کامل Xray JSON اجرا می‌شود و تنظیمات مسیریابی گره‌های عادی اعمال نمی‌شوند.\n\nشامل Raw JSON شخصی و پیکربندی‌های ارائه‌دهنده دریافت‌شده از اشتراک است. Raw JSON شخصی قابل ویرایش است. پیکربندی‌های ارائه‌دهنده فقط‌خواندنی‌اند و با اشتراک به‌روزرسانی می‌شوند؛ برای ویرایش، یک نسخه را به Raw JSON شخصی کپی کنید.", "serverAllSources": "همهٔ منابع", "connectionPlan": "برنامهٔ اتصال", "serverSelectionAutomaticScope": "محدودهٔ انتخاب خودکار", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5d2434537..73036049f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -412,7 +412,6 @@ "prototypeDownload": "Загрузка", "prototypeUpload": "Отправка", "prototypeOrdinaryConnectionRunning": "Обычное подключение продолжает работать. Для переключения выберите конфигурацию.", - "prototypeNoRawJson": "Нет конфигураций Raw JSON", "prototypeAddRawJsonHint": "Добавьте полную конфигурацию Xray, затем выберите её для подключения.", "prototypeAddRawJson": "Добавить Raw JSON", "prototypeEditRawJson": "Редактировать Raw JSON", @@ -988,6 +987,8 @@ "navigationRouting": "Маршрутизация", "serverNodesTab": "Узлы", "serverCompleteConfigurationsTab": "Конфигурации", + "serverNoCompleteConfigurations": "Пока нет конфигураций", + "completeConfigurationDefinition": "Запускается полная конфигурация Xray JSON без применения настроек маршрутизации обычных узлов.\n\nСюда входят личные Raw JSON и конфигурации провайдера, полученные по подписке. Личные Raw JSON можно редактировать. Конфигурации провайдера доступны только для чтения и обновляются вместе с подпиской; для редактирования создайте личную копию Raw JSON.", "serverAllSources": "Все источники", "connectionPlan": "План подключения", "serverSelectionAutomaticScope": "Область автовыбора", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 3bfba03b7..e54555648 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -412,7 +412,6 @@ "prototypeDownload": "下载", "prototypeUpload": "上传", "prototypeOrdinaryConnectionRunning": "当前仍使用普通连接,选择配置后才会切换。", - "prototypeNoRawJson": "还没有 Raw JSON 配置", "prototypeAddRawJsonHint": "添加完整的 Xray 配置后,即可在这里选择并连接。", "prototypeAddRawJson": "添加 Raw JSON", "prototypeEditRawJson": "编辑 Raw JSON", @@ -988,6 +987,8 @@ "navigationRouting": "路由", "serverNodesTab": "节点", "serverCompleteConfigurationsTab": "完整配置", + "serverNoCompleteConfigurations": "还没有完整配置", + "completeConfigurationDefinition": "以整份 Xray JSON 运行,不使用普通节点的路由偏好。\n\n包含个人 Raw JSON 和订阅下发的提供方配置。个人 Raw JSON 可以编辑;提供方配置只读并随订阅更新,可以复制为个人 Raw JSON 后编辑。", "serverAllSources": "全部来源", "connectionPlan": "连接计划", "serverSelectionAutomaticScope": "自动选择范围", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 06fc43901..4bd475db2 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -412,7 +412,6 @@ "prototypeDownload": "下載", "prototypeUpload": "上傳", "prototypeOrdinaryConnectionRunning": "目前仍使用一般連線,選擇設定後才會切換。", - "prototypeNoRawJson": "還沒有 Raw JSON 設定", "prototypeAddRawJsonHint": "新增完整的 Xray 設定後,即可在這裡選擇並連線。", "prototypeAddRawJson": "新增 Raw JSON", "prototypeEditRawJson": "編輯 Raw JSON", @@ -988,6 +987,8 @@ "navigationRouting": "路由", "serverNodesTab": "節點", "serverCompleteConfigurationsTab": "完整設定", + "serverNoCompleteConfigurations": "還沒有完整設定", + "completeConfigurationDefinition": "以整份 Xray JSON 執行,不使用一般節點的路由偏好。\n\n包含個人 Raw JSON 和訂閱下發的提供方設定。個人 Raw JSON 可以編輯;提供方設定唯讀並隨訂閱更新,可以複製為個人 Raw JSON 後編輯。", "serverAllSources": "全部來源", "connectionPlan": "連線計畫", "serverSelectionAutomaticScope": "自動選擇範圍", diff --git a/lib/pages/connect/target_picker.dart b/lib/pages/connect/target_picker.dart index 793631f2c..516f31159 100644 --- a/lib/pages/connect/target_picker.dart +++ b/lib/pages/connect/target_picker.dart @@ -7,6 +7,8 @@ import 'package:onexray/l10n/localizations/app_localizations.dart'; import 'package:onexray/pages/theme/color.dart'; import 'package:onexray/pages/theme/font.dart'; import 'package:onexray/pages/theme/layout.dart'; +import 'package:onexray/pages/theme/theme.dart'; +import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/servers/catalog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -302,6 +304,11 @@ class _ConnectionTargetPickerState extends State { style: AppTypography.dialogTitle, ), ), + if (tab == 2) + PageHelpButton( + title: l.serverCompleteConfigurationsTab, + messages: [l.completeConfigurationDefinition], + ), IconButton( tooltip: l.prototypeClose, onPressed: () => Navigator.of(context).pop(), @@ -318,55 +325,52 @@ class _ConnectionTargetPickerState extends State { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: LayoutBuilder( - builder: (context, tabConstraints) => Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final entry in [ - l.serverSelectionAutomaticScope, - l.serverSelectionFixedNode, - l.serverCompleteConfigurationsTab, - ].indexed) - ChoiceChip( - label: ConstrainedBox( - constraints: BoxConstraints( - minHeight: 36, - maxWidth: math.max( - 1.0, - tabConstraints.maxWidth - 24, + builder: (context, tabConstraints) => ChipTheme( + data: AppTheme.choiceChips(context), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final entry in [ + l.serverSelectionAutomaticScope, + l.serverSelectionFixedNode, + l.serverCompleteConfigurationsTab, + ].indexed) + ChoiceChip( + label: ConstrainedBox( + constraints: BoxConstraints( + minHeight: 36, + maxWidth: math.max( + 1.0, + tabConstraints.maxWidth - 24, + ), ), - ), - child: Align( - widthFactor: 1, - heightFactor: 1, - child: Text( - entry.$2, - textAlign: TextAlign.center, + child: Align( + widthFactor: 1, + heightFactor: 1, + child: Text( + entry.$2, + textAlign: TextAlign.center, + ), ), ), - ), - labelStyle: AppTypography.control.copyWith( - color: palette.foreground, - ), - labelPadding: EdgeInsets.zero, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - showCheckmark: false, - side: BorderSide.none, - backgroundColor: palette.muted, - selectedColor: palette.background, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(4), + labelPadding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + showCheckmark: false, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(4), + ), ), + selected: tab == entry.$1, + onSelected: (_) => + setState(() => tab = entry.$1), ), - selected: tab == entry.$1, - onSelected: (_) => - setState(() => tab = entry.$1), - ), - ], + ], + ), ), ), ), diff --git a/lib/pages/servers/view.dart b/lib/pages/servers/view.dart index d4ce7c286..ddba4c480 100644 --- a/lib/pages/servers/view.dart +++ b/lib/pages/servers/view.dart @@ -15,6 +15,7 @@ import 'package:onexray/pages/theme/layout.dart'; import 'package:onexray/pages/shared/widgets/button_progress.dart'; import 'package:onexray/pages/shared/widgets/app_activity.dart'; import 'package:onexray/pages/shared/widgets/page_empty_state.dart'; +import 'package:onexray/pages/shared/widgets/page_help.dart'; import 'package:onexray/service/connect/settings.dart'; import 'package:onexray/service/connect/coordinator.dart'; import 'package:onexray/service/servers/catalog.dart'; @@ -310,12 +311,17 @@ class _ServerBrowserState extends State { icon: const Icon(LucideIcons.layers, size: 17), label: Text(l.prototypeManualAdditions), ), - ] else + ] else ...[ FilledButton.icon( onPressed: () => controller.addServers(context), icon: const Icon(LucideIcons.plus, size: 17), label: Text(l.prototypeAddServer), ), + PageHelpButton( + title: l.serverCompleteConfigurationsTab, + messages: [l.completeConfigurationDefinition], + ), + ], IconButton( key: const ValueKey('server-favorites-filter'), tooltip: l.prototypeFavorites, @@ -374,7 +380,7 @@ class _ServerBrowserState extends State { filtered ? l.serverNoMatchingResources : complete - ? l.prototypeNoRawJson + ? l.serverNoCompleteConfigurations : l.prototypeNoServersYet, textAlign: TextAlign.center, ), diff --git a/lib/pages/theme/theme.dart b/lib/pages/theme/theme.dart index 3e6906983..b6eede0b5 100644 --- a/lib/pages/theme/theme.dart +++ b/lib/pages/theme/theme.dart @@ -54,6 +54,20 @@ abstract final class AppTheme { ); } + static ChipThemeData choiceChips(BuildContext context) { + final palette = ColorManager.palette(context); + return ChipTheme.of(context).copyWith( + backgroundColor: palette.muted, + selectedColor: palette.primarySolid, + secondarySelectedColor: palette.primarySolid, + labelStyle: AppTypography.control.copyWith(color: palette.foreground), + secondaryLabelStyle: AppTypography.control.copyWith( + color: palette.primaryForeground, + ), + side: BorderSide.none, + ); + } + static ThemeData pageActions(BuildContext context) { final theme = Theme.of(context); final style = ButtonStyle( diff --git a/test/pages/connect/target_picker_test.dart b/test/pages/connect/target_picker_test.dart index 01cb178ca..e9d7b5101 100644 --- a/test/pages/connect/target_picker_test.dart +++ b/test/pages/connect/target_picker_test.dart @@ -74,6 +74,7 @@ void main() { Size size = const Size(960, 1100), EdgeInsets viewInsets = EdgeInsets.zero, double textScale = 1, + Brightness brightness = Brightness.light, ServerCatalog? catalogOverride, }) async { await tester.binding.setSurfaceSize(size); @@ -82,7 +83,7 @@ void main() { completions = 0; await tester.pumpWidget( MaterialApp( - theme: AppTheme.light, + theme: AppTheme.material(brightness), locale: const Locale('en'), supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalePolicy.localizationsDelegates, @@ -92,10 +93,7 @@ void main() { viewInsets: viewInsets, textScaler: TextScaler.linear(textScale), ), - child: ShadTheme( - data: AppTheme.shad(Brightness.light), - child: child!, - ), + child: ShadTheme(data: AppTheme.shad(brightness), child: child!), ), home: Builder( builder: (context) => Scaffold( @@ -240,8 +238,13 @@ void main() { for (final chip in tester.widgetList( find.byType(ChoiceChip), )) { - expect(chip.labelStyle!.fontSize, 14); - expect(chip.labelStyle!.fontWeight, FontWeight.w600); + final label = find.descendant( + of: find.byWidget(chip), + matching: find.byType(Text), + ); + final style = DefaultTextStyle.of(tester.element(label)).style; + expect(style.fontSize, 14); + expect(style.fontWeight, FontWeight.w600); } final firstTab = find.widgetWithText( ChoiceChip, @@ -257,6 +260,62 @@ void main() { }); } + for (final brightness in Brightness.values) { + testWidgets( + 'target categories keep a distinct selected state: $brightness', + (tester) async { + final settings = ConnectionSettings(); + final before = settings.toJson(); + await open(tester, settings, brightness: brightness); + final labels = [ + l.serverSelectionAutomaticScope, + l.serverSelectionFixedNode, + l.serverCompleteConfigurationsTab, + ]; + final palette = ColorManager.palette( + tester.element(find.byType(ConnectionTargetPicker)), + ); + for (final selected in labels) { + await tab(tester, selected); + for (final label in labels) { + final chip = find.widgetWithText(ChoiceChip, label); + final text = find.descendant(of: chip, matching: find.text(label)); + final style = DefaultTextStyle.of(tester.element(text)).style; + final surface = tester.widget( + find.ancestor(of: text, matching: find.byType(Ink)).first, + ); + final fill = (surface.decoration! as ShapeDecoration).color!; + final active = label == selected; + expect(tester.widget(chip).selected, active); + expect(fill, active ? palette.primarySolid : palette.muted); + expect( + style.color, + active ? palette.primaryForeground : palette.foreground, + ); + if (active) { + final a = fill.computeLuminance(); + final b = style.color!.computeLuminance(); + expect((b + .05) / (a + .05), greaterThanOrEqualTo(4.5)); + } + } + expect(choice, isNull); + expect(completions, 0); + expect(settings.toJson(), before); + } + await tester.tap( + find.byTooltip( + '${l.pageHelp} · ${l.serverCompleteConfigurationsTab}', + ), + ); + await tester.pumpAndSettle(); + expect(find.text(l.completeConfigurationDefinition), findsOneWidget); + expect(settings.toJson(), before); + expect(completions, 0); + expect(tester.takeException(), isNull); + }, + ); + } + testWidgets( 'source ID collisions keep automatic scope selected by kind and ID', (tester) async { diff --git a/test/pages/servers/view_test.dart b/test/pages/servers/view_test.dart index 1d01aae9c..29e801f79 100644 --- a/test/pages/servers/view_test.dart +++ b/test/pages/servers/view_test.dart @@ -263,12 +263,22 @@ void main() { tester.element(find.byType(ServerBrowser)), )!; final selection = controller.configuration.toJson(); + final help = find.byTooltip( + '${l.pageHelp} · ${l.serverCompleteConfigurationsTab}', + ); + expect(help, findsNothing); final completeTab = find.text(l.serverCompleteConfigurationsTab).first; await tester.ensureVisible(completeTab); await tester.tap(completeTab); await tester.pumpAndSettle(); - expect(find.text(l.prototypeNoRawJson), findsOneWidget); + expect(find.text(l.serverNoCompleteConfigurations), findsOneWidget); expect(find.text(l.prototypeAddRawJson), findsNothing); + await tester.ensureVisible(help); + await tester.tap(help); + await tester.pumpAndSettle(); + expect(find.text(l.completeConfigurationDefinition), findsOneWidget); + await tester.tap(find.byTooltip(l.prototypeCloseDialog)); + await tester.pumpAndSettle(); final toolbarAdd = find.widgetWithText( FilledButton, From c1f85869bb427ba6cea28c82fd8b14c3dadb5da0 Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 5 Nov 2026 15:01:40 +0800 Subject: [PATCH 59/60] fix(ci): format managed log cleanup test --- test/core/ffi/base_ffi_api_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/ffi/base_ffi_api_test.dart b/test/core/ffi/base_ffi_api_test.dart index c1232f528..75d5cb732 100644 --- a/test/core/ffi/base_ffi_api_test.dart +++ b/test/core/ffi/base_ffi_api_test.dart @@ -161,7 +161,7 @@ void main() { } else { await Directory(error).create(); } - await expectLater(api.clearLogs(), throwsA(isA())); + await expectLater(api.clearLogs(), throwsA(isA())); expect(await access.readAsString(), 'old access'); if (link) { expect( From e0edc49f0e3b16b2362aadb46b6aba1ec380c04f Mon Sep 17 00:00:00 2001 From: yiguo Date: Thu, 5 Nov 2026 15:19:54 +0800 Subject: [PATCH 60/60] fix(test): account for wrapped JSON editor tools on resize Linux uses Copy link instead of Share, which adds a wrapped tool row at phone width. A taller viewport lets the capped footer expand, so only the remaining height belongs to the editor. Assert that allocation and footer anchoring rather than assuming fixed chrome height. --- test/pages/connect/raw_editor_test.dart | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/pages/connect/raw_editor_test.dart b/test/pages/connect/raw_editor_test.dart index a40291775..0dad54362 100644 --- a/test/pages/connect/raw_editor_test.dart +++ b/test/pages/connect/raw_editor_test.dart @@ -258,11 +258,29 @@ void main() { await tester.pumpAndSettle(); expect(position.pixels, 320); final previousHeight = tester.getSize(editor).height; + final previousWorkspaceHeight = tester.getSize(workspace).height; + final previousHeaderHeight = tester.getSize(header).height; + final previousFooterRect = tester.getRect(footer); tester.view.physicalSize = Size(size.width, size.height + 200); await tester.pumpAndSettle(); + // Wrapped tools (including Linux's Copy link) can expand their capped + // footer as the viewport grows. The editor fills the space left over. + final headerGrowth = + tester.getSize(header).height - previousHeaderHeight; + final footerGrowth = + tester.getSize(footer).height - previousFooterRect.height; + expect( + tester.getSize(workspace).height, + closeTo(previousWorkspaceHeight + 200, .01), + ); expect( tester.getSize(editor).height, - closeTo(previousHeight + 200, .01), + closeTo(previousHeight + 200 - headerGrowth - footerGrowth, .01), + ); + expect(tester.getRect(editor), tester.getRect(slot)); + expect( + tester.getRect(footer).bottom, + closeTo(previousFooterRect.bottom + 200, .01), ); expect(fixture.controller.name.text, 'Workspace draft'); expect(fixture.controller.text.text, source);