diff --git a/apps/android/build.gradle.kts b/apps/android/build.gradle.kts index 8c8f431e9..3504169f1 100644 --- a/apps/android/build.gradle.kts +++ b/apps/android/build.gradle.kts @@ -1,6 +1,5 @@ plugins { - id("com.android.application") version "8.5.2" apply false + id("com.android.application") version "8.6.1" apply false id("org.jetbrains.kotlin.android") version "1.9.24" apply false id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false } - diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 3b450647b..7d4425e21 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit struct RootTabs: View { @EnvironmentObject private var appModel: NodeAppModel @@ -13,29 +14,13 @@ struct RootTabs: View { .tabItem { Label("Voice", systemImage: "mic") } SettingsTab() - .tabItem { - VStack { - ZStack(alignment: .topTrailing) { - Image(systemName: "gearshape") - Circle() - .fill(self.settingsIndicatorColor) - .frame(width: 9, height: 9) - .overlay( - Circle() - .stroke(.black.opacity(0.2), lineWidth: 0.5)) - .shadow( - color: self.settingsIndicatorGlowColor, - radius: self.settingsIndicatorGlowRadius, - x: 0, - y: 0) - .scaleEffect(self.settingsIndicatorScale) - .opacity(self.settingsIndicatorOpacity) - .offset(x: 7, y: -2) - } - Text("Settings") - } - } + .tabItem { Label("Settings", systemImage: "gearshape") } } + .background(TabBarControllerAccessor { tabBarController in + guard let item = tabBarController.tabBar.items?[Self.settingsTabIndex] else { return } + item.badgeValue = "" + item.badgeColor = self.settingsBadgeColor + }) .onAppear { self.updateConnectingPulse(for: self.bridgeIndicatorState) } .onChange(of: self.bridgeIndicatorState) { _, newValue in self.updateConnectingPulse(for: newValue) @@ -48,55 +33,25 @@ struct RootTabs: View { case disconnected } + private static let settingsTabIndex = 2 + private var bridgeIndicatorState: BridgeIndicatorState { if self.appModel.bridgeServerName != nil { return .connected } if self.appModel.bridgeStatusText.localizedCaseInsensitiveContains("connecting") { return .connecting } return .disconnected } - private var settingsIndicatorColor: Color { + private var settingsBadgeColor: UIColor { switch self.bridgeIndicatorState { case .connected: - Color.green + UIColor.systemGreen case .connecting: - Color.yellow + UIColor.systemYellow.withAlphaComponent(self.isConnectingPulse ? 1.0 : 0.6) case .disconnected: - Color.red + UIColor.systemRed } } - private var settingsIndicatorGlowColor: Color { - switch self.bridgeIndicatorState { - case .connected: - Color.green.opacity(0.75) - case .connecting: - Color.yellow.opacity(0.6) - case .disconnected: - Color.clear - } - } - - private var settingsIndicatorGlowRadius: CGFloat { - switch self.bridgeIndicatorState { - case .connected: - 6 - case .connecting: - self.isConnectingPulse ? 6 : 3 - case .disconnected: - 0 - } - } - - private var settingsIndicatorScale: CGFloat { - guard self.bridgeIndicatorState == .connecting else { return 1 } - return self.isConnectingPulse ? 1.12 : 0.96 - } - - private var settingsIndicatorOpacity: Double { - guard self.bridgeIndicatorState == .connecting else { return 1 } - return self.isConnectingPulse ? 1.0 : 0.75 - } - private func updateConnectingPulse(for state: BridgeIndicatorState) { guard state == .connecting else { withAnimation(.easeOut(duration: 0.2)) { self.isConnectingPulse = false } @@ -109,3 +64,41 @@ struct RootTabs: View { } } } + +private struct TabBarControllerAccessor: UIViewControllerRepresentable { + let onResolve: (UITabBarController) -> Void + + func makeUIViewController(context: Context) -> ResolverViewController { + ResolverViewController(onResolve: self.onResolve) + } + + func updateUIViewController(_ uiViewController: ResolverViewController, context: Context) { + uiViewController.onResolve = self.onResolve + uiViewController.resolveIfPossible() + } +} + +private final class ResolverViewController: UIViewController { + var onResolve: (UITabBarController) -> Void + + init(onResolve: @escaping (UITabBarController) -> Void) { + self.onResolve = onResolve + super.init(nibName: nil, bundle: nil) + self.view.isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + self.resolveIfPossible() + } + + func resolveIfPossible() { + guard let tabBarController = self.tabBarController else { return } + self.onResolve(tabBarController) + } +} diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift new file mode 100644 index 000000000..35615d56a --- /dev/null +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -0,0 +1,43 @@ +import ClawdisKit +import Foundation +import Testing + +@Suite struct DeepLinkParserTests { + @Test func parseRejectsNonClawdisScheme() { + let url = URL(string: "https://example.com/agent?message=hi")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseRejectsEmptyMessage() { + let url = URL(string: "clawdis://agent?message=%20%20%0A")! + #expect(DeepLinkParser.parse(url) == nil) + } + + @Test func parseAgentLinkParsesCommonFields() { + let url = URL(string: "clawdis://agent?message=Hello&deliver=1&sessionKey=node-iris&thinking=low&timeoutSeconds=30")! + #expect( + DeepLinkParser.parse(url) == .agent( + .init( + message: "Hello", + sessionKey: "node-iris", + thinking: "low", + deliver: true, + to: nil, + channel: nil, + timeoutSeconds: 30, + key: nil))) + } + + @Test func parseRejectsNegativeTimeoutSeconds() { + let url = URL(string: "clawdis://agent?message=Hello&timeoutSeconds=-1")! + #expect(DeepLinkParser.parse(url) == .agent(.init( + message: "Hello", + sessionKey: nil, + thinking: nil, + deliver: false, + to: nil, + channel: nil, + timeoutSeconds: nil, + key: nil))) + } +} diff --git a/apps/ios/project.yml b/apps/ios/project.yml index 2da19ab13..1c4dc1c58 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -9,6 +9,16 @@ packages: ClawdisKit: path: ../shared/ClawdisKit +schemes: + Clawdis: + shared: true + build: + targets: + Clawdis: all + test: + targets: + - ClawdisTests + targets: Clawdis: type: application