diff --git a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift index 353df8937720..8acec46b4ddb 100644 --- a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift +++ b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift @@ -1,6 +1,6 @@ +import Combine import ClerkKit import Foundation -import Observation import OSLog public extension Notification.Name { @@ -50,8 +50,7 @@ protocol T3ConnectDeviceManaging: AnyObject { } @MainActor -@Observable -public final class T3ConnectController: T3ConnectDeviceManaging { +public final class T3ConnectController: ObservableObject, T3ConnectDeviceManaging { private static let logger = Logger( subsystem: "codes.t3.swift-ios", category: "T3Connect" @@ -59,7 +58,7 @@ public final class T3ConnectController: T3ConnectDeviceManaging { public let resolution: T3ConnectConfigurationResolution public let managedAuthorizer: T3ConnectManagedEnvironmentAuthorizer - public private(set) var account: T3ConnectAccount? { + @Published public private(set) var account: T3ConnectAccount? { didSet { guard oldValue != account else { return } var accountIDs: [String: String] = [:] @@ -76,10 +75,10 @@ public final class T3ConnectController: T3ConnectDeviceManaging { ) } } - public private(set) var environments: [T3ConnectCloudEnvironment] = [] - public private(set) var isRefreshing = false - public private(set) var busyEnvironmentID: String? - public var errorMessage: String? + @Published public private(set) var environments: [T3ConnectCloudEnvironment] = [] + @Published public private(set) var isRefreshing = false + @Published public private(set) var busyEnvironmentID: String? + @Published public var errorMessage: String? private let auth: T3ConnectClerkSession? private let relay: T3ConnectRelayClient? diff --git a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift index e4ae2f836aed..032103e18086 100644 --- a/apps/swift-ios/App/Platform/PlatformIncomingShare.swift +++ b/apps/swift-ios/App/Platform/PlatformIncomingShare.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation import SwiftUI enum PlatformIncomingShareError: LocalizedError, Equatable { @@ -161,10 +161,9 @@ struct PlatformIncomingSharePipeline: Sendable { } @MainActor -@Observable -final class PlatformIncomingShareCoordinator { - private(set) var pendingEnvelope: T3IncomingShareEnvelope? - private(set) var isImporting = false +final class PlatformIncomingShareCoordinator: ObservableObject { + @Published private(set) var pendingEnvelope: T3IncomingShareEnvelope? + @Published private(set) var isImporting = false private let pipeline: PlatformIncomingSharePipeline private var isRefreshing = false diff --git a/apps/swift-ios/App/Platform/PlatformRootView.swift b/apps/swift-ios/App/Platform/PlatformRootView.swift index b4fe3ab1223f..992b7ec37c57 100644 --- a/apps/swift-ios/App/Platform/PlatformRootView.swift +++ b/apps/swift-ios/App/Platform/PlatformRootView.swift @@ -2,13 +2,13 @@ import SwiftUI struct PlatformRootView: View { @SwiftUI.Environment(\.scenePhase) private var scenePhase - @Bindable private var model: FeatureRootModel + @ObservedObject private var model: FeatureRootModel @State private var navigationRequest: FeatureWorkspaceNavigationRequest? @State private var pendingRoute: PlatformRoute? @State private var previousThreadStates: [String: FeatureThreadState]? @State private var lastNotificationPreference: Bool? - @State private var incomingShareCoordinator = PlatformIncomingShareCoordinator() + @StateObject private var incomingShareCoordinator = PlatformIncomingShareCoordinator() @State private var incomingShareNeedsProject = false @State private var importedShareProjectID: String? @State private var recentThreadsPersistenceTask: Task? @@ -64,7 +64,7 @@ struct PlatformRootView: View { await model.removeManagedEnvironmentsAfterAccountChange() } } - .onChange(of: model.isLoading, initial: true) { _, isLoading in + .t3OnChange(of: model.isLoading, initial: true) { _, isLoading in guard !isLoading else { return } processThreadChanges() synchronizeNotificationPreference() @@ -73,10 +73,10 @@ struct PlatformRootView: View { consumeMailboxRouteIfAvailable() refreshIncomingShares() } - .onChange(of: model.homePresentationRevision) { _, _ in + .t3OnChange(of: model.homePresentationRevision) { _, _ in processThreadChanges() } - .onChange(of: scenePhase) { _, phase in + .t3OnChange(of: scenePhase) { _, phase in if phase == .active { consumeMailboxRouteIfAvailable() synchronizeNotificationPreference() @@ -86,15 +86,15 @@ struct PlatformRootView: View { PlatformBackgroundRefreshCoordinator.shared.schedule() } } - .onChange(of: model.snapshot.settings.notificationsEnabled) { _, _ in + .t3OnChange(of: model.snapshot.settings.notificationsEnabled) { _, _ in synchronizeNotificationPreference() synchronizeCloudDelivery() } - .onChange(of: model.snapshot.settings.liveActivitiesEnabled) { _, _ in + .t3OnChange(of: model.snapshot.settings.liveActivitiesEnabled) { _, _ in synchronizeAgentAwareness() synchronizeCloudDelivery() } - .onChange(of: model.snapshot.projects.map(\.id)) { _, _ in + .t3OnChange(of: model.snapshot.projects.map(\.id)) { _, _ in refreshIncomingShares() } .sheet(item: presentedIncomingShare, onDismiss: openImportedShareDraft) { envelope in diff --git a/apps/swift-ios/App/T3CodeApp.swift b/apps/swift-ios/App/T3CodeApp.swift index d2854016003a..1463837a828d 100644 --- a/apps/swift-ios/App/T3CodeApp.swift +++ b/apps/swift-ios/App/T3CodeApp.swift @@ -4,12 +4,12 @@ import SwiftUI @MainActor struct T3CodeApp: App { @UIApplicationDelegateAdaptor(T3PlatformAppDelegate.self) private var appDelegate - @State private var model: FeatureRootModel + @StateObject private var model: FeatureRootModel init() { let client = NativeFeatureClient() let model = FeatureRootModel(client: client) - _model = State(initialValue: model) + _model = StateObject(wrappedValue: model) PlatformCloudDeliveryCoordinator.shared.install( controller: client.t3ConnectController ) diff --git a/apps/swift-ios/DesignSystem/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift index 1c52485650de..4cf25b51a843 100644 --- a/apps/swift-ios/DesignSystem/T3Theme.swift +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -99,9 +99,195 @@ enum T3Metrics { static let readingWidth: CGFloat = 760 } +/// Back-deploys the standard iOS 17 empty-state presentation to iOS 16. +struct T3ContentUnavailableView: View { + private let label: AnyView + private let description: AnyView + private let actions: AnyView + private let searchText: String? + + init( + _ title: String, + systemImage: String, + description: Text? = nil + ) { + label = AnyView(Label(title, systemImage: systemImage)) + self.description = description.map { AnyView($0) } ?? AnyView(EmptyView()) + actions = AnyView(EmptyView()) + searchText = nil + } + + init( + @ViewBuilder label: () -> LabelContent, + @ViewBuilder description: () -> DescriptionContent + ) { + self.label = AnyView(label()) + self.description = AnyView(description()) + actions = AnyView(EmptyView()) + searchText = nil + } + + init( + @ViewBuilder label: () -> LabelContent, + @ViewBuilder description: () -> DescriptionContent, + @ViewBuilder actions: () -> ActionsContent + ) { + self.label = AnyView(label()) + self.description = AnyView(description()) + self.actions = AnyView(actions()) + searchText = nil + } + + @ViewBuilder + var body: some View { + if #available(iOS 17.0, *) { + if let searchText { + ContentUnavailableView.search(text: searchText) + } else { + ContentUnavailableView { + label + } description: { + description + } actions: { + actions + } + } + } else { + fallback + } + } + + private var fallback: some View { + VStack(spacing: 12) { + label + .font(.title3.weight(.semibold)) + .foregroundStyle(T3Colors.textPrimary) + description + .font(T3Typography.threadBody) + .foregroundStyle(T3Colors.textSecondary) + .multilineTextAlignment(.center) + actions + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + } + + static func search(text: String) -> Self { + Self( + text.isEmpty ? "No results" : "No results for “\(text)”", + systemImage: "magnifyingglass", + searchText: text + ) + } + + private init( + _ title: String, + systemImage: String, + searchText: String + ) { + label = AnyView(Label(title, systemImage: systemImage)) + description = AnyView(EmptyView()) + actions = AnyView(EmptyView()) + self.searchText = searchText + } +} + extension View { func t3NavigationChrome() -> some View { toolbarBackground(T3Colors.sheet, for: .navigationBar) .toolbarBackground(.visible, for: .navigationBar) } + + @ViewBuilder + func t3PresentationBackground(_ color: Color) -> some View { + if #available(iOS 16.4, *) { + presentationBackground(color) + } else { + background(color) + } + } + + @ViewBuilder + func t3ListSectionSpacing(_ spacing: CGFloat) -> some View { + if #available(iOS 17.0, *) { + listSectionSpacing(spacing) + } else { + self + } + } + + @ViewBuilder + func t3ScrollBounceBasedOnSize() -> some View { + if #available(iOS 16.4, *) { + scrollBounceBehavior(.basedOnSize) + } else { + self + } + } + + /// iOS 16-compatible form of the two-value `onChange` API introduced in iOS 17. + /// Uses the native modifier on iOS 17; the manual tracking below exists only for iOS 16. + @ViewBuilder + func t3OnChange( + of value: Value, + initial: Bool = false, + perform action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void + ) -> some View { + if #available(iOS 17.0, *) { + onChange(of: value, initial: initial, action) + } else { + modifier(T3OnChangeModifier(value: value, initial: initial, action: action)) + } + } + + func t3OnChange( + of value: Value, + initial: Bool = false, + perform action: @escaping (_ newValue: Value) -> Void + ) -> some View { + t3OnChange(of: value, initial: initial) { _, newValue in action(newValue) } + } + + func t3OnChange( + of value: Value, + initial: Bool = false, + perform action: @escaping () -> Void + ) -> some View { + t3OnChange(of: value, initial: initial) { _, _ in action() } + } +} + +private struct T3OnChangeModifier: ViewModifier { + let value: Value + let initial: Bool + let action: (_ oldValue: Value, _ newValue: Value) -> Void + + @State private var previous: Value + @State private var didAppear = false + + init( + value: Value, + initial: Bool, + action: @escaping (_ oldValue: Value, _ newValue: Value) -> Void + ) { + self.value = value + self.initial = initial + self.action = action + _previous = State(initialValue: value) + } + + func body(content: Content) -> some View { + content + .onAppear { + guard !didAppear else { return } + didAppear = true + previous = value + if initial { action(value, value) } + } + .onChange(of: value) { newValue in + let oldValue = previous + previous = newValue + action(oldValue, newValue) + } + } } diff --git a/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift index fe077760ee91..e5138d8ae6b5 100644 --- a/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift +++ b/apps/swift-ios/Extensions/Widgets/AgentActivityWidget.swift @@ -6,7 +6,6 @@ struct T3TaskLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LiveActivityAttributes.self) { context in T3LiveActivityLockScreenView(context: context) - .activityBackgroundTint(Color(uiColor: .systemBackground)) .activitySystemActionForegroundColor(Color(uiColor: .label)) .widgetURL(T3ActivityPresentation(state: context.state).deepLinkURL) } dynamicIsland: { context in diff --git a/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift index c1ced6e9ca7c..be7d8d14b002 100644 --- a/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift +++ b/apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift @@ -29,7 +29,7 @@ struct T3RecentTasksWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: T3TaskWidgetProvider()) { entry in T3TaskWidgetView(entry: entry) - .containerBackground(Color(uiColor: .systemBackground), for: .widget) + .t3WidgetBackground() } .configurationDisplayName("T3 Code Tasks") .description("See active and recent T3 Code tasks at a glance.") @@ -37,6 +37,26 @@ struct T3RecentTasksWidget: Widget { } } +private extension View { + @ViewBuilder + func t3LegacyWidgetContentMargins() -> some View { + if #available(iOSApplicationExtension 17.0, *) { + self + } else { + padding() + } + } + + @ViewBuilder + func t3WidgetBackground() -> some View { + if #available(iOSApplicationExtension 17.0, *) { + containerBackground(Color(uiColor: .systemBackground), for: .widget) + } else { + background(Color(uiColor: .systemBackground)) + } + } +} + private struct T3TaskWidgetView: View { @Environment(\.widgetFamily) private var family let entry: T3TaskWidgetEntry @@ -88,6 +108,7 @@ private struct T3TaskWidgetView: View { } } .widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask) + .t3LegacyWidgetContentMargins() } private var mediumView: some View { @@ -128,6 +149,7 @@ private struct T3TaskWidgetView: View { } } } + .t3LegacyWidgetContentMargins() } private var accessoryView: some View { diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift index b5303077c7ae..52ab1038f668 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -278,11 +278,11 @@ struct FeatureComposerUserInputPanel: View { .opacity(isResponding ? 0.56 : 1) } } - .onChange(of: input.id) { + .t3OnChange(of: input.id) { answers = [:] questionIndex = 0 } - .onChange(of: questionIDs) { previousIDs, currentIDs in + .t3OnChange(of: questionIDs) { previousIDs, currentIDs in questionIndex = FeatureComposerQuestionReconciliation.index( current: questionIndex, previousQuestionIDs: previousIDs, diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 7b32f9bc2b0c..08a2cda3d726 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -117,7 +117,7 @@ struct FeatureComposerView: View { ) .ignoresSafeArea() } - .onChange(of: focused) { + .t3OnChange(of: focused) { if FeatureComposerCollapsePolicy.shouldCollapse( isFocused: focused, textIsEmpty: textIsEmpty, diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index a09c7a119f54..4ab31236c511 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -687,6 +687,15 @@ enum MarkdownCodeBlockWrapping { } } +enum MarkdownLinkInteractionPolicy { + static func shouldOpenURL(for interaction: UITextItemInteraction) -> Bool { + if #available(iOS 17.0, *) { + return false + } + return interaction == .invokeDefaultAction + } +} + private struct MarkdownInlineText: UIViewRepresentable { @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize @SwiftUI.Environment(\.openURL) private var openURL @@ -943,6 +952,7 @@ private struct MarkdownInlineText: UIViewRepresentable { return UIMenu(children: suggestedActions + [copyMessage]) } + @available(iOS 17.0, *) func textView( _ textView: UITextView, primaryActionFor textItem: UITextItem, @@ -954,6 +964,19 @@ private struct MarkdownInlineText: UIViewRepresentable { } } + func textView( + _ textView: UITextView, + shouldInteractWith url: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + guard MarkdownLinkInteractionPolicy.shouldOpenURL(for: interaction) else { + return true + } + onOpenURL?(url) + return false + } + private func copyMessage() { UIPasteboard.general.string = selectionContext.source.text } diff --git a/apps/swift-ios/Features/Chat/ThreadDetailView.swift b/apps/swift-ios/Features/Chat/ThreadDetailView.swift index 2eff9a929e1e..e01f7b5a97cd 100644 --- a/apps/swift-ios/Features/Chat/ThreadDetailView.swift +++ b/apps/swift-ios/Features/Chat/ThreadDetailView.swift @@ -7,7 +7,7 @@ public struct ThreadDetailView: View { @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass @SwiftUI.Environment(\.scenePhase) private var scenePhase - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel let thread: FeatureThread let submitMessage: (FeatureMessageSubmission) async -> Bool let onNavigateBack: () -> Void @@ -52,7 +52,7 @@ public struct ThreadDetailView: View { } else if isLoading { FeatureThreadOpeningView() } else { - ContentUnavailableView { + T3ContentUnavailableView { Label("Thread unavailable", systemImage: "exclamationmark.bubble") } description: { Text("The thread could not be loaded.") @@ -81,10 +81,10 @@ public struct ThreadDetailView: View { await restoreDraft(from: restoreBaseline, key: restoreKey) isLoading = false } - .onChange(of: draft) { scheduleDraftSave() } - .onChange(of: attachments) { scheduleDraftSave() } - .onChange(of: selection) { scheduleDraftSave() } - .onChange(of: scenePhase) { _, phase in + .t3OnChange(of: draft) { scheduleDraftSave() } + .t3OnChange(of: attachments) { scheduleDraftSave() } + .t3OnChange(of: selection) { scheduleDraftSave() } + .t3OnChange(of: scenePhase) { _, phase in if phase != .active { persistDraftBeforeLeaving() } @@ -373,7 +373,7 @@ public struct ThreadDetailView: View { || detail.thread.state == .monitoring return Group { if detail.messages.isEmpty, !isWorking { - ContentUnavailableView( + T3ContentUnavailableView( "Ready for a task", systemImage: "sparkles", description: Text("Tell the agent what you want to build.") @@ -2124,7 +2124,7 @@ private struct FeatureAttachmentPreview: View { .resizable() .scaledToFit() case .failure: - ContentUnavailableView( + T3ContentUnavailableView( "Image unavailable", systemImage: "exclamationmark.triangle" ) diff --git a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift index 46f0bcf60bf7..3fa98e7714ae 100644 --- a/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift +++ b/apps/swift-ios/Features/Connection/ConnectionOnboardingView.swift @@ -3,7 +3,7 @@ import UIKit public struct ConnectionOnboardingView: View { @SwiftUI.Environment(\.scenePhase) private var scenePhase - @Bindable private var model: FeatureRootModel + @ObservedObject private var model: FeatureRootModel private let readinessChecker: any ConnectionReadinessChecking private let onConnected: @MainActor () -> Void @@ -108,7 +108,7 @@ public struct ConnectionOnboardingView: View { .onOpenURL { url in applyConnectionString(url.absoluteString, heading: "Confirm connection") } - .onChange(of: scenePhase) { _, newPhase in + .t3OnChange(of: scenePhase) { _, newPhase in if newPhase == .active, showsPermissionAction { showsPermissionAction = false errorMessage = nil @@ -289,7 +289,7 @@ public struct ConnectionOnboardingView: View { "Server address", text: $endpoint, prompt: Text("http://192.168.1.5:3773") - .foregroundStyle(T3Colors.placeholder) + .foregroundColor(T3Colors.placeholder) ) .textInputAutocapitalization(.never) .keyboardType(.URL) @@ -300,7 +300,7 @@ public struct ConnectionOnboardingView: View { .accessibilityIdentifier("connection-onboarding-address") .submitLabel(.next) .onSubmit { focusedField = .pairingCode } - .onChange(of: endpoint) { _, value in + .t3OnChange(of: endpoint) { _, value in autofillIfPairingLink(value) } } @@ -314,7 +314,7 @@ public struct ConnectionOnboardingView: View { "Pairing code", text: $pairingCode, prompt: Text("Enter pairing code") - .foregroundStyle(T3Colors.placeholder) + .foregroundColor(T3Colors.placeholder) ) .textInputAutocapitalization(.never) .textContentType(.oneTimeCode) diff --git a/apps/swift-ios/Features/Connection/T3ConnectView.swift b/apps/swift-ios/Features/Connection/T3ConnectView.swift index d4c28399b285..b3519218ba13 100644 --- a/apps/swift-ios/Features/Connection/T3ConnectView.swift +++ b/apps/swift-ios/Features/Connection/T3ConnectView.swift @@ -9,7 +9,7 @@ public struct T3ConnectView: View { } @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable private var controller: T3ConnectController + @ObservedObject private var controller: T3ConnectController @State private var isAuthPresented = false @State private var didFinishInitialRefresh = false @State private var connectingEnvironmentID: String? @@ -28,7 +28,7 @@ public struct T3ConnectView: View { onConnected: @escaping @MainActor () async -> Void = {}, onUnlinked: @escaping @MainActor (String) async -> Void = { _ in } ) { - controller = capability.t3ConnectController + _controller = ObservedObject(wrappedValue: capability.t3ConnectController) connectEnvironment = capability.connectT3Environment signOut = if let model { model.signOutT3Connect @@ -55,7 +55,7 @@ public struct T3ConnectView: View { didFinishInitialRefresh = true presentAuthenticationIfNeeded() } - .onChange(of: controller.account?.id) { _, accountID in + .t3OnChange(of: controller.account?.id) { _, accountID in guard didFinishInitialRefresh, !isSigningOut, accountID == nil, @@ -108,7 +108,7 @@ public struct T3ConnectView: View { content() } .listStyle(.plain) - .listSectionSpacing(28) + .t3ListSectionSpacing(28) .scrollContentBackground(.hidden) .background(T3Colors.background.ignoresSafeArea()) } @@ -160,8 +160,8 @@ public struct T3ConnectView: View { } return false } - .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) - .environment(clerk) + .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) + .environmentObject(clerk) } else { loadingView("Loading sign-in") } @@ -393,7 +393,7 @@ public struct T3ConnectView: View { @MainActor private struct T3ConnectAuthenticationView: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @SwiftUI.Environment(Clerk.self) private var clerk + @EnvironmentObject private var clerk: Clerk @State private var activeProvider: OAuthProvider? @State private var errorMessage: String? @State private var isEmailPresented = false @@ -441,7 +441,7 @@ private struct T3ConnectAuthenticationView: View { .padding(.bottom, 40) .frame(maxWidth: .infinity) } - .scrollBounceBehavior(.basedOnSize) + .t3ScrollBounceBasedOnSize() .background(T3Colors.background.ignoresSafeArea()) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -469,7 +469,7 @@ private struct T3ConnectAuthenticationView: View { AuthView(mode: .signInOrUp) .prefetchClerkImages() .environment(\.clerkTheme, T3ConnectClerkAppearance.theme) - .environment(clerk) + .environmentObject(clerk) } .alert( "Couldn’t sign in", @@ -624,8 +624,10 @@ private struct T3ConnectAuthProviderIcon: View { .foregroundStyle(T3Colors.textPrimary) case .github: Image("AuthGitHub") + .renderingMode(.template) .resizable() .scaledToFit() + .foregroundStyle(T3Colors.textPrimary) case .google: Image("AuthGoogle") .resizable() diff --git a/apps/swift-ios/Features/Devices/DevicesView.swift b/apps/swift-ios/Features/Devices/DevicesView.swift index 59a2a80a612e..914d71193f92 100644 --- a/apps/swift-ios/Features/Devices/DevicesView.swift +++ b/apps/swift-ios/Features/Devices/DevicesView.swift @@ -24,7 +24,7 @@ public struct DevicesView: View { .foregroundStyle(T3Colors.textSecondary) } } else if let errorMessage, sessions.isEmpty { - ContentUnavailableView { + T3ContentUnavailableView { Label("Couldn’t load devices", systemImage: "exclamationmark.circle") } description: { Text(errorMessage) @@ -35,7 +35,7 @@ public struct DevicesView: View { .buttonStyle(.borderedProminent) } } else if sessions.isEmpty { - ContentUnavailableView { + T3ContentUnavailableView { Label("No devices found", systemImage: "laptopcomputer.and.iphone") } description: { Text("Device sessions will appear here when this server supports access management.") diff --git a/apps/swift-ios/Features/Files/FeatureFilesView.swift b/apps/swift-ios/Features/Files/FeatureFilesView.swift index c18d62bce57f..3e2fec82c82f 100644 --- a/apps/swift-ios/Features/Files/FeatureFilesView.swift +++ b/apps/swift-ios/Features/Files/FeatureFilesView.swift @@ -35,13 +35,13 @@ private struct FeatureFileDirectoryView: View { ProgressView("Loading files…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let errorMessage, entries.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( "Files unavailable", systemImage: "folder.badge.questionmark", description: Text(errorMessage) ) } else if filteredEntries.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( searchText.isEmpty ? "Empty folder" : "No matches", systemImage: "folder", description: Text(searchText.isEmpty ? "This folder has no visible files." : "Try another search.") @@ -204,7 +204,7 @@ private struct FeatureFilePreviewView: View { } } } else { - ContentUnavailableView( + T3ContentUnavailableView( previewKind == .image ? "Image unavailable" : "File unavailable", systemImage: previewKind == .image ? "photo.badge.exclamationmark" : "doc.badge.ellipsis", description: Text(errorMessage ?? "The file could not be read.") diff --git a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift index 9e5d9532c0be..2bf1425189ac 100644 --- a/apps/swift-ios/Features/PullRequests/PullRequestsView.swift +++ b/apps/swift-ios/Features/PullRequests/PullRequestsView.swift @@ -1,4 +1,4 @@ -import Observation +import Combine import SwiftUI struct FeaturePullRequestRow: Identifiable, Equatable { @@ -21,23 +21,22 @@ struct FeaturePullRequestRow: Identifiable, Equatable { } @MainActor -@Observable -final class PullRequestsModel { - var rows: [FeaturePullRequestRow] = [] +final class PullRequestsModel: ObservableObject { + @Published var rows: [FeaturePullRequestRow] = [] private var allRows: [FeaturePullRequestRow] = [] - var environments: [FeaturePullRequestEnvironmentList] = [] - var state: PullRequestListState = .open - var involvement: PullRequestInvolvement = .all - var query = "" - var draftFilter: String? - var reviewFilter: String? - var checksFilter: String? - var environmentFilter: String? - var hostFilter: String? - var projectFilter: String? - var isLoading = false - var isLoadingMore = false - var errorMessage: String? + @Published var environments: [FeaturePullRequestEnvironmentList] = [] + @Published var state: PullRequestListState = .open + @Published var involvement: PullRequestInvolvement = .all + @Published var query = "" + @Published var draftFilter: String? + @Published var reviewFilter: String? + @Published var checksFilter: String? + @Published var environmentFilter: String? + @Published var hostFilter: String? + @Published var projectFilter: String? + @Published var isLoading = false + @Published var isLoadingMore = false + @Published var errorMessage: String? private let client: any FeatureClient private var loadGeneration: UInt64 = 0 @@ -218,13 +217,13 @@ final class PullRequestsModel { } public struct PullRequestsView: View { - @Bindable private var rootModel: FeatureRootModel - @State private var model: PullRequestsModel + @ObservedObject private var rootModel: FeatureRootModel + @StateObject private var model: PullRequestsModel @State private var searchTask: Task? public init(model: FeatureRootModel) { rootModel = model - _model = State(initialValue: PullRequestsModel(client: model.client)) + _model = StateObject(wrappedValue: PullRequestsModel(client: model.client)) } public var body: some View { @@ -246,15 +245,15 @@ public struct PullRequestsView: View { } .t3NavigationChrome() .task { await model.load() } - .onChange(of: model.state) { reload() } - .onChange(of: model.involvement) { reload() } - .onChange(of: model.draftFilter) { reload() } - .onChange(of: model.reviewFilter) { reload() } - .onChange(of: model.checksFilter) { reload() } - .onChange(of: model.environmentFilter) { model.applyLocalFilters() } - .onChange(of: model.hostFilter) { model.applyLocalFilters() } - .onChange(of: model.projectFilter) { model.applyLocalFilters() } - .onChange(of: model.query) { + .t3OnChange(of: model.state) { reload() } + .t3OnChange(of: model.involvement) { reload() } + .t3OnChange(of: model.draftFilter) { reload() } + .t3OnChange(of: model.reviewFilter) { reload() } + .t3OnChange(of: model.checksFilter) { reload() } + .t3OnChange(of: model.environmentFilter) { model.applyLocalFilters() } + .t3OnChange(of: model.hostFilter) { model.applyLocalFilters() } + .t3OnChange(of: model.projectFilter) { model.applyLocalFilters() } + .t3OnChange(of: model.query) { searchTask?.cancel() searchTask = Task { try? await Task.sleep(for: .milliseconds(300)) @@ -393,7 +392,7 @@ public struct PullRequestsView: View { ProgressView("Loading pull requests…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let error = model.errorMessage, model.rows.isEmpty { - ContentUnavailableView("Couldn’t load pull requests", systemImage: "exclamationmark.triangle", description: Text(error)) + T3ContentUnavailableView("Couldn’t load pull requests", systemImage: "exclamationmark.triangle", description: Text(error)) } else { List { ForEach(model.environments.filter { $0.errorMessage != nil }) { environment in @@ -420,7 +419,7 @@ public struct PullRequestsView: View { } if model.rows.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( "No pull requests", systemImage: "arrow.triangle.pull", description: Text("Try another state, involvement, or search.") @@ -493,19 +492,18 @@ private struct PullRequestRowView: View { } @MainActor -@Observable -private final class PullRequestDetailModel { - var detail: PullRequestDetail? - var activity: PullRequestActivity? - var diffFiles: [PullRequestDiffFile] = [] - var isDiffIncomplete = false - var isLoading = true - var isLoadingDiff = false - var isActing = false - var errorMessage: String? - var reviewDrafts: [PullRequestReviewCommentDraft] = [] - var reviewerCandidates: [PullRequestReviewerCandidate] = [] - var isLoadingReviewers = false +private final class PullRequestDetailModel: ObservableObject { + @Published var detail: PullRequestDetail? + @Published var activity: PullRequestActivity? + @Published var diffFiles: [PullRequestDiffFile] = [] + @Published var isDiffIncomplete = false + @Published var isLoading = true + @Published var isLoadingDiff = false + @Published var isActing = false + @Published var errorMessage: String? + @Published var reviewDrafts: [PullRequestReviewCommentDraft] = [] + @Published var reviewerCandidates: [PullRequestReviewerCandidate] = [] + @Published var isLoadingReviewers = false private let client: any FeatureClient let target: FeaturePullRequestTarget @@ -660,9 +658,9 @@ private struct PullRequestDetailView: View { var updateMethod: PullRequestUpdateMethod? } - @Bindable var rootModel: FeatureRootModel + @ObservedObject var rootModel: FeatureRootModel let row: FeaturePullRequestRow - @State private var model: PullRequestDetailModel + @StateObject private var model: PullRequestDetailModel @State private var tab: PullRequestDetailTab = .summary @State private var editor: PullRequestEditor? @State private var reviewSheet = false @@ -673,7 +671,7 @@ private struct PullRequestDetailView: View { init(rootModel: FeatureRootModel, row: FeaturePullRequestRow) { self.rootModel = rootModel self.row = row - _model = State(initialValue: PullRequestDetailModel(client: rootModel.client, target: row.target)) + _model = StateObject(wrappedValue: PullRequestDetailModel(client: rootModel.client, target: row.target)) } var body: some View { @@ -694,7 +692,7 @@ private struct PullRequestDetailView: View { tabContent(detail) } } else { - ContentUnavailableView( + T3ContentUnavailableView( "Couldn’t load pull request", systemImage: "exclamationmark.triangle", description: Text(model.errorMessage ?? "Try again.") @@ -709,7 +707,7 @@ private struct PullRequestDetailView: View { } .t3NavigationChrome() .task { await model.load() } - .onChange(of: tab) { _, value in + .t3OnChange(of: tab) { _, value in if value == .files { Task { await model.loadDiff() } } } .sheet(item: $editor) { editor in @@ -919,7 +917,7 @@ private struct PullRequestDetailView: View { private struct PullRequestSummaryView: View { let detail: PullRequestDetail let activity: PullRequestActivity? - @Bindable var model: PullRequestDetailModel + @ObservedObject var model: PullRequestDetailModel let onUpdateBranch: (PullRequestUpdateMethod) -> Void var body: some View { @@ -1009,7 +1007,7 @@ private struct PullRequestSummaryView: View { private struct PullRequestActivityView: View { let activity: PullRequestActivity? - @Bindable var model: PullRequestDetailModel + @ObservedObject var model: PullRequestDetailModel @State private var replyThread: PullRequestReviewThread? var body: some View { @@ -1072,7 +1070,7 @@ private struct PullRequestActivityView: View { .font(T3Typography.supporting) } if activity.comments.isEmpty && activity.reviewThreads.isEmpty && activity.commits.isEmpty { - ContentUnavailableView("No activity", systemImage: "text.bubble") + T3ContentUnavailableView("No activity", systemImage: "text.bubble") } } else { ProgressView("Loading activity…") @@ -1090,7 +1088,7 @@ private struct PullRequestActivityView: View { private struct PullRequestCommentView: View { let comment: PullRequestComment - @Bindable var model: PullRequestDetailModel + @ObservedObject var model: PullRequestDetailModel var body: some View { VStack(alignment: .leading, spacing: 9) { @@ -1179,7 +1177,7 @@ private struct PullRequestFilesView: View { .frame(maxWidth: .infinity) .padding(.top, 50) } else if files.isEmpty { - ContentUnavailableView("No diff available", systemImage: "doc.text.magnifyingglass") + T3ContentUnavailableView("No diff available", systemImage: "doc.text.magnifyingglass") } else { ForEach(files) { file in VStack(alignment: .leading, spacing: 0) { @@ -1243,7 +1241,7 @@ private struct PullRequestFilesView: View { private struct PullRequestReviewSheet: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable var model: PullRequestDetailModel + @ObservedObject var model: PullRequestDetailModel @State private var verdict: PullRequestReviewVerdict = .comment @State private var reviewBody = "" @@ -1294,7 +1292,7 @@ private struct PullRequestReviewSheet: View { private struct PullRequestReviewerSheet: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable var model: PullRequestDetailModel + @ObservedObject var model: PullRequestDetailModel var body: some View { NavigationStack { diff --git a/apps/swift-ios/Features/Review/FeatureReviewView.swift b/apps/swift-ios/Features/Review/FeatureReviewView.swift index a6e6db343c68..40482ce9107e 100644 --- a/apps/swift-ios/Features/Review/FeatureReviewView.swift +++ b/apps/swift-ios/Features/Review/FeatureReviewView.swift @@ -23,7 +23,7 @@ public struct FeatureReviewView: View { } else if let review { reviewList(review) } else { - ContentUnavailableView( + T3ContentUnavailableView( "Review unavailable", systemImage: "doc.text.magnifyingglass", description: Text(errorMessage ?? "Changes could not be loaded.") @@ -44,7 +44,7 @@ public struct FeatureReviewView: View { } } .task { await load() } - .onChange(of: scenePhase) { _, phase in + .t3OnChange(of: scenePhase) { _, phase in guard phase == .active, review != nil, !isLoading else { return } Task { await load() } } @@ -77,7 +77,7 @@ public struct FeatureReviewView: View { Section("\(review.files.count) changed \(review.files.count == 1 ? "file" : "files")") { if review.files.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( "No changes", systemImage: "checkmark.circle", description: Text("The working tree is clean.") @@ -212,7 +212,7 @@ private struct FeatureDiffView: View { ProgressView("Loading full diff…") .frame(maxWidth: .infinity, maxHeight: .infinity) } else if renderedLines.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( file.change == .binary ? "Binary file" : "Diff unavailable", systemImage: file.change == .binary ? "doc.richtext" : "doc.text.magnifyingglass", description: Text("No line-level preview is available.") diff --git a/apps/swift-ios/Features/Root/FeatureRootModel.swift b/apps/swift-ios/Features/Root/FeatureRootModel.swift index 7feed18ffe67..3a58a29eff81 100644 --- a/apps/swift-ios/Features/Root/FeatureRootModel.swift +++ b/apps/swift-ios/Features/Root/FeatureRootModel.swift @@ -1,5 +1,5 @@ +import Combine import Foundation -import Observation private struct FeatureConnectionUnavailableError: LocalizedError { var errorDescription: String? { @@ -19,8 +19,7 @@ struct FeatureDetailRenderUpdate: Equatable { } @MainActor -@Observable -public final class FeatureRootModel { +public final class FeatureRootModel: ObservableObject { private static let maximumRetainedThreadDetails = 6 private struct PendingSettlementMutation { @@ -38,22 +37,22 @@ public final class FeatureRootModel { } } - public private(set) var snapshot = FeatureSnapshot() - public private(set) var details: [String: FeatureThreadDetail] = [:] + @Published public private(set) var snapshot = FeatureSnapshot() + @Published public private(set) var details: [String: FeatureThreadDetail] = [:] /// Advances whenever a Home presentation input changes. - public private(set) var homePresentationRevision: UInt64 = 0 + @Published public private(set) var homePresentationRevision: UInt64 = 0 /// Advances when a Home-visible thread is inserted, removed, or changed. - public private(set) var threadCollectionRevision: UInt64 = 0 + @Published public private(set) var threadCollectionRevision: UInt64 = 0 /// Advances for any selected-thread metadata, message, approval, or input change. - public private(set) var detailRevision: UInt64 = 0 + @Published public private(set) var detailRevision: UInt64 = 0 /// The latest detail revision for each loaded thread. - public private(set) var detailRevisions: [String: UInt64] = [:] - private(set) var detailRenderUpdates: [String: FeatureDetailRenderUpdate] = [:] - public private(set) var isLoading = true - public private(set) var isPerformingAction = false - public private(set) var isManagingConnections = false - private(set) var isSigningOutT3Connect = false - public var errorMessage: String? + @Published public private(set) var detailRevisions: [String: UInt64] = [:] + @Published private(set) var detailRenderUpdates: [String: FeatureDetailRenderUpdate] = [:] + @Published public private(set) var isLoading = true + @Published public private(set) var isPerformingAction = false + @Published public private(set) var isManagingConnections = false + @Published private(set) var isSigningOutT3Connect = false + @Published public var errorMessage: String? let client: any FeatureClient private let outboxStore: FeatureOutboxStore diff --git a/apps/swift-ios/Features/Root/FeatureRootView.swift b/apps/swift-ios/Features/Root/FeatureRootView.swift index 3505dca51577..7fbade3137fc 100644 --- a/apps/swift-ios/Features/Root/FeatureRootView.swift +++ b/apps/swift-ios/Features/Root/FeatureRootView.swift @@ -1,12 +1,12 @@ import SwiftUI public struct FeatureRootView: View { - @State private var model: FeatureRootModel + @StateObject private var model: FeatureRootModel private let navigationRequest: FeatureWorkspaceNavigationRequest? private let onNavigationRequestConsumed: @MainActor (UUID) -> Void public init(client: any FeatureClient) { - _model = State(initialValue: FeatureRootModel(client: client)) + _model = StateObject(wrappedValue: FeatureRootModel(client: client)) navigationRequest = nil onNavigationRequestConsumed = { _ in } } @@ -16,7 +16,7 @@ public struct FeatureRootView: View { navigationRequest: FeatureWorkspaceNavigationRequest? = nil, onNavigationRequestConsumed: @escaping @MainActor (UUID) -> Void = { _ in } ) { - _model = State(initialValue: model) + _model = StateObject(wrappedValue: model) self.navigationRequest = navigationRequest self.onNavigationRequestConsumed = onNavigationRequestConsumed } diff --git a/apps/swift-ios/Features/Settings/ConnectionsView.swift b/apps/swift-ios/Features/Settings/ConnectionsView.swift index d7e21816caa3..a55334f3a524 100644 --- a/apps/swift-ios/Features/Settings/ConnectionsView.swift +++ b/apps/swift-ios/Features/Settings/ConnectionsView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ConnectionsView: View { - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel @State private var pendingEnabledValues: [String: Bool] = [:] @State private var showingAddConnection = false @@ -500,7 +500,7 @@ struct ConnectionsView: View { private struct ConnectionDetailView: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel let environmentID: String @Binding var pendingEnabledValues: [String: Bool] let onRemove: () -> Void @@ -536,7 +536,7 @@ private struct ConnectionDetailView: View { } } } else { - ContentUnavailableView("Connection removed", systemImage: "network.slash") + T3ContentUnavailableView("Connection removed", systemImage: "network.slash") } } .scrollContentBackground(.hidden) diff --git a/apps/swift-ios/Features/Settings/SettingsView.swift b/apps/swift-ios/Features/Settings/SettingsView.swift index ab39c3990cf3..cfbd961341af 100644 --- a/apps/swift-ios/Features/Settings/SettingsView.swift +++ b/apps/swift-ios/Features/Settings/SettingsView.swift @@ -2,7 +2,7 @@ import SwiftUI public struct SettingsView: View { @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable private var model: FeatureRootModel + @ObservedObject private var model: FeatureRootModel @State private var settings: FeatureSettings @State private var isSaving = false @State private var appearanceSaveTask: Task? @@ -79,12 +79,12 @@ public struct SettingsView: View { .onDisappear { model.setConnectionManagementPresented(false) } - .onChange(of: settings.appearance) { _, appearance in + .t3OnChange(of: settings.appearance) { _, appearance in saveAppearance(appearance) } } .interactiveDismissDisabled(isSaving || hasUnsavedChanges) - .presentationBackground(T3Colors.background) + .t3PresentationBackground(T3Colors.background) .presentationDragIndicator(.visible) } diff --git a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift index 1182485c2b0a..598d73a20784 100644 --- a/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift +++ b/apps/swift-ios/Features/SourceControl/FeatureSourceControlView.swift @@ -24,7 +24,7 @@ public struct FeatureSourceControlView: View { } else if let status, status.isRepository { statusList(status) } else { - ContentUnavailableView( + T3ContentUnavailableView( "Source control unavailable", systemImage: "arrow.triangle.branch", description: Text( diff --git a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift index 21d503bd6436..c4c28c7b9e80 100644 --- a/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift +++ b/apps/swift-ios/Features/Terminal/FeatureTerminalView.swift @@ -111,7 +111,7 @@ public struct FeatureTerminalView: View { .tint(T3Colors.textPrimary) .foregroundStyle(T3Colors.textPrimary) } else if let errorMessage, terminal == nil { - ContentUnavailableView( + T3ContentUnavailableView( "Terminal unavailable", systemImage: "terminal", description: Text(errorMessage) diff --git a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift index 6f58399affde..d11db9b8df0e 100644 --- a/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift +++ b/apps/swift-ios/Features/Terminal/TerminalSurfaceView.swift @@ -355,15 +355,19 @@ private final class TerminalAccessoryView: UIInputView { stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), ]) refreshAppearance() - registerForTraitChanges([UITraitUserInterfaceStyle.self]) { - (self: Self, _: UITraitCollection) in - self.refreshAppearance() - } } @available(*, unavailable) required init?(coder _: NSCoder) { nil } + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + guard traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) else { + return + } + refreshAppearance() + } + func setRunning(_ running: Bool) { for (action, button) in actionButtons { button.isEnabled = running || action == .clear diff --git a/apps/swift-ios/Features/Usage/UsageView.swift b/apps/swift-ios/Features/Usage/UsageView.swift index 0231aa7f5230..87fc7d1b32ee 100644 --- a/apps/swift-ios/Features/Usage/UsageView.swift +++ b/apps/swift-ios/Features/Usage/UsageView.swift @@ -60,7 +60,7 @@ public struct UsageView: View { .frame(maxWidth: .infinity) .padding(.vertical, 64) } else if let errorMessage, environments.isEmpty { - ContentUnavailableView { + T3ContentUnavailableView { Label("Couldn’t load usage", systemImage: "exclamationmark.circle") } description: { Text(errorMessage) @@ -68,13 +68,13 @@ public struct UsageView: View { Button("Try again") { Task { await load() } } } } else if environments.isEmpty { - ContentUnavailableView { + T3ContentUnavailableView { Label("No environments", systemImage: "chart.bar.xaxis") } description: { Text("Connect an environment to see usage.") } } else if !hasCompatibleSummary { - ContentUnavailableView { + T3ContentUnavailableView { Label("Couldn’t load usage", systemImage: "exclamationmark.circle") } description: { Text("No compatible usage data is available.") diff --git a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift index 67866c69c76a..057ecc973532 100644 --- a/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift +++ b/apps/swift-ios/Features/Workspace/HomeThreadCollectionView.swift @@ -1033,7 +1033,7 @@ private struct HomeCollectionCellContent: View { .padding(.horizontal, 34) .frame(minHeight: T3Metrics.minimumTapTarget) case .searchEmpty: - ContentUnavailableView("No matching tasks", systemImage: "magnifyingglass") + T3ContentUnavailableView("No matching tasks", systemImage: "magnifyingglass") .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, minHeight: 160) case .pinnedDivider: diff --git a/apps/swift-ios/Features/Workspace/NewThreadView.swift b/apps/swift-ios/Features/Workspace/NewThreadView.swift index 76cfe9cf5f3f..8c92f7203492 100644 --- a/apps/swift-ios/Features/Workspace/NewThreadView.swift +++ b/apps/swift-ios/Features/Workspace/NewThreadView.swift @@ -3,7 +3,7 @@ import SwiftUI public struct NewThreadView: View { @SwiftUI.Environment(\.dismiss) private var dismiss @SwiftUI.Environment(\.scenePhase) private var scenePhase - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel let submit: (NewTaskRequest) async -> FeatureThread? let onCreated: (FeatureThread) -> Void let onCreateProject: @MainActor () -> Void @@ -118,8 +118,8 @@ public struct NewThreadView: View { selectInitialProject(initialID) } } - .onChange(of: projectID) { prepareProjectIfNeeded(projectID) } - .onChange(of: creationProjectIDs) { _, ids in + .t3OnChange(of: projectID) { prepareProjectIfNeeded(projectID) } + .t3OnChange(of: creationProjectIDs) { _, ids in guard !ids.contains(projectID) else { return } if projectID.isEmpty { let recentProject = DailyUXCreationContext.recentProjects( @@ -143,19 +143,19 @@ public struct NewThreadView: View { ?? creationProjectGroups.first?.projects.first selectInitialProject(replacement?.id ?? "") } - .onChange(of: model.homePresentationRevision) { _, _ in + .t3OnChange(of: model.homePresentationRevision) { _, _ in refreshAutomaticProjectIfNeeded() } - .onChange(of: prompt) { scheduleDraftSave() } - .onChange(of: selection) { scheduleDraftSave() } - .onChange(of: attachments) { scheduleDraftSave() } - .onChange(of: workspaceMode) { scheduleDraftSave() } - .onChange(of: selectedBranch) { scheduleDraftSave() } - .onChange(of: startFromOrigin) { scheduleDraftSave() } - .onChange(of: submissionValidationMessage) { _, _ in + .t3OnChange(of: prompt) { scheduleDraftSave() } + .t3OnChange(of: selection) { scheduleDraftSave() } + .t3OnChange(of: attachments) { scheduleDraftSave() } + .t3OnChange(of: workspaceMode) { scheduleDraftSave() } + .t3OnChange(of: selectedBranch) { scheduleDraftSave() } + .t3OnChange(of: startFromOrigin) { scheduleDraftSave() } + .t3OnChange(of: submissionValidationMessage) { _, _ in submissionValidationError = nil } - .onChange(of: scenePhase) { _, phase in + .t3OnChange(of: scenePhase) { _, phase in if phase != .active, !submittedSuccessfully { persistCurrentDraftImmediately() } @@ -1115,12 +1115,12 @@ private struct NewTaskProjectPicker: View { NavigationStack { Group { if groups.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( "No projects", systemImage: "folder" ) } else if filteredGroups.isEmpty { - ContentUnavailableView( + T3ContentUnavailableView( "No matching projects", systemImage: "magnifyingglass" ) @@ -1166,7 +1166,7 @@ private struct NewTaskProjectPicker: View { } } .presentationDetents([.medium, .large]) - .presentationBackground(T3Colors.background) + .t3PresentationBackground(T3Colors.background) } private func projectRow(_ group: DailyUXProjectGroup) -> some View { @@ -1250,7 +1250,7 @@ private struct NewTaskBranchPicker: View { .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) } else if filteredBranches.isEmpty { - ContentUnavailableView { + T3ContentUnavailableView { Label( loadFailed ? "Could not load branches" @@ -1331,7 +1331,7 @@ private struct NewTaskBranchPicker: View { } } .presentationDetents([.medium, .large]) - .presentationBackground(T3Colors.background) + .t3PresentationBackground(T3Colors.background) } private var filteredBranches: [FeatureWorkspaceBranch] { diff --git a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift index 89324b9adfda..678bd0891a87 100644 --- a/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift +++ b/apps/swift-ios/Features/Workspace/ProjectAndArchiveViews.swift @@ -24,7 +24,7 @@ public struct AddProjectView: View { } @SwiftUI.Environment(\.dismiss) private var dismiss - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel @State private var selectedEnvironmentID: String? @State private var mode = ProjectMode.folder @@ -86,7 +86,7 @@ public struct AddProjectView: View { } .scrollDismissesKeyboard(.interactively) } else { - ContentUnavailableView( + T3ContentUnavailableView( "Environment unavailable", systemImage: "server.rack", description: Text("Reconnect a T3 environment before adding a project.") @@ -104,17 +104,17 @@ public struct AddProjectView: View { } } .onAppear(perform: selectEnvironmentIfNeeded) - .onChange(of: model.snapshot.environments) { + .t3OnChange(of: model.snapshot.environments) { selectEnvironmentIfNeeded() } - .onChange(of: source) { + .t3OnChange(of: source) { resolvedRepository = nil pendingCloneRegistration = nil cloneRequestID = nil updateSuggestedDestination() errorMessage = nil } - .onChange(of: repositoryInput) { + .t3OnChange(of: repositoryInput) { resolvedRepository = nil pendingCloneRegistration = nil cloneRequestID = nil diff --git a/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift index ff321378597b..5545be28d5d9 100644 --- a/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift +++ b/apps/swift-ios/Features/Workspace/ProviderModelPicker.swift @@ -93,8 +93,8 @@ public struct ProviderModelPicker: View { ) } .onAppear(perform: materializeSelection) - .onChange(of: providers) { materializeSelection() } - .onChange(of: selection) { materializeSelection() } + .t3OnChange(of: providers) { materializeSelection() } + .t3OnChange(of: selection) { materializeSelection() } } private var selectedOption: DailyUXModelOption? { @@ -212,7 +212,7 @@ private struct ModelPickerSheet: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else if availableModelCount == 0 { - ContentUnavailableView( + T3ContentUnavailableView( emptyStateTitle, systemImage: emptyStateSymbol, description: Text(emptyStateMessage) @@ -230,14 +230,18 @@ private struct ModelPickerSheet: View { Button("Cancel") { dismiss() } } } - .navigationDestination(item: $configuring) { option in - ModelConfigurationView( - option: option, - currentSelection: selection - ) { configuredSelection in - selection = configuredSelection - recordRecent(option.id) - dismiss() + // `navigationDestination(item:)` needs iOS 17; the `isPresented:` form + // covers every supported OS with one code path. + .navigationDestination(isPresented: configuringPresented) { + if let option = configuring { + ModelConfigurationView( + option: option, + currentSelection: selection + ) { configuredSelection in + selection = configuredSelection + recordRecent(option.id) + dismiss() + } } } .t3NavigationChrome() @@ -245,8 +249,15 @@ private struct ModelPickerSheet: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) .onAppear(perform: revealSelectedLegacyModel) - .onChange(of: selection) { revealSelectedLegacyModel() } - .onChange(of: providers) { revealSelectedLegacyModel() } + .t3OnChange(of: selection) { revealSelectedLegacyModel() } + .t3OnChange(of: providers) { revealSelectedLegacyModel() } + } + + private var configuringPresented: Binding { + Binding( + get: { configuring != nil }, + set: { if !$0 { configuring = nil } } + ) } private var modelList: some View { @@ -334,7 +345,7 @@ private struct ModelPickerSheet: View { } if catalog.all.isEmpty { - ContentUnavailableView.search(text: query) + T3ContentUnavailableView.search(text: query) .listRowBackground(Color.clear) } } diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index 9aecaf49db89..b1b96496a4bf 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -19,8 +19,9 @@ struct FeatureWorkspaceNavigationRequest: Equatable, Sendable { public struct WorkspaceView: View { @SwiftUI.Environment(\.dynamicTypeSize) private var dynamicTypeSize + @SwiftUI.Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Bindable var model: FeatureRootModel + @ObservedObject var model: FeatureRootModel private let navigationRequest: FeatureWorkspaceNavigationRequest? private let onNavigationRequestConsumed: @MainActor (UUID) -> Void private let submitNewTask: (NewTaskRequest) async -> FeatureThread? @@ -43,7 +44,7 @@ public struct WorkspaceView: View { @State private var deletingThread: FeatureThread? @State private var renameTitle = "" @State private var sidebarBoundaryNow = Date.now - @State private var preferredCompactColumn = NavigationSplitViewColumn.sidebar + @State private var prefersCompactDetail = false @State private var homePresentationCache = HomePresentationCache() @FocusState private var isSearchFocused: Bool @@ -115,15 +116,37 @@ public struct WorkspaceView: View { } public var body: some View { - NavigationSplitView(preferredCompactColumn: $preferredCompactColumn) { - sidebar - .navigationSplitViewColumnWidth( - min: T3Metrics.minimumSidebarWidth, - ideal: T3Metrics.sidebarWidth, - max: T3Metrics.maximumSidebarWidth - ) - } detail: { - detail + Group { + if #available(iOS 17.0, *) { + NavigationSplitView(preferredCompactColumn: preferredCompactColumn) { + sidebar + .navigationSplitViewColumnWidth( + min: T3Metrics.minimumSidebarWidth, + ideal: T3Metrics.sidebarWidth, + max: T3Metrics.maximumSidebarWidth + ) + } detail: { + detail + } + } else if horizontalSizeClass == .compact { + NavigationStack { + sidebar + .navigationDestination(isPresented: selectedThreadPresented) { + detail + } + } + } else { + NavigationSplitView { + sidebar + .navigationSplitViewColumnWidth( + min: T3Metrics.minimumSidebarWidth, + ideal: T3Metrics.sidebarWidth, + max: T3Metrics.maximumSidebarWidth + ) + } detail: { + detail + } + } } .navigationSplitViewStyle(.balanced) .sheet(isPresented: $showingNewTask) { @@ -190,22 +213,19 @@ public struct WorkspaceView: View { } message: { thread in Text("\"\(thread.title)\" and its terminal history will be permanently deleted.") } - .onChange(of: selectedThreadIsAvailable) { _, isAvailable in + .t3OnChange(of: selectedThreadIsAvailable) { _, isAvailable in if !isAvailable { closeSelectedThread() } } - .onChange(of: selectedThreadID) { _, newValue in - preferredCompactColumn = newValue == nil ? .sidebar : .detail - } - .onChange(of: selectedProjectIsAvailable) { _, isAvailable in + .t3OnChange(of: selectedProjectIsAvailable) { _, isAvailable in if !isAvailable { selectedProjectID = nil } } - .onChange(of: navigationRequest?.id, initial: true) { _, _ in + .t3OnChange(of: navigationRequest?.id, initial: true) { _, _ in consumeNavigationRequest() } // A request that arrives before its thread or project exists in the // snapshot stays pending; retry it as data lands so cold-start deep // links are not silently stranded. - .onChange(of: model.homePresentationRevision) { _, _ in + .t3OnChange(of: model.homePresentationRevision) { _, _ in if navigationRequest != nil { consumeNavigationRequest() } } .task(id: nextSidebarBoundary) { @@ -236,7 +256,7 @@ public struct WorkspaceView: View { } .background(T3Colors.background) .toolbar(.hidden, for: .navigationBar) - .onChange(of: selectedProjectID) { + .t3OnChange(of: selectedProjectID) { settledLimit = 12 } } @@ -587,6 +607,23 @@ public struct WorkspaceView: View { return model.snapshot.threads.contains { $0.id == selectedThreadID } } + private var selectedThreadPresented: Binding { + Binding( + get: { selectedThreadID != nil }, + set: { if !$0 { closeSelectedThread() } } + ) + } + + // Stored as a Bool because `NavigationSplitViewColumn` is an iOS 17 type and + // cannot appear in this view's stored state while iOS 16 is supported. + @available(iOS 17.0, *) + private var preferredCompactColumn: Binding { + Binding( + get: { prefersCompactDetail ? .detail : .sidebar }, + set: { prefersCompactDetail = $0 == .detail } + ) + } + private var selectedProjectIsAvailable: Bool { guard let selectedProjectID else { return true } return model.snapshot.projects.contains { $0.id == selectedProjectID } @@ -594,12 +631,12 @@ public struct WorkspaceView: View { private func openThread(_ id: String) { selectedThreadID = id - preferredCompactColumn = .detail + prefersCompactDetail = true } private func closeSelectedThread() { selectedThreadID = nil - preferredCompactColumn = .sidebar + prefersCompactDetail = false } @MainActor diff --git a/apps/swift-ios/README.md b/apps/swift-ios/README.md index 379ed70ca0c5..7d048f93a5df 100644 --- a/apps/swift-ios/README.md +++ b/apps/swift-ios/README.md @@ -1,13 +1,13 @@ # T3 Code (SwiftUI) -A native SwiftUI client for T3 Code. The project targets iOS 17 and later on +A native SwiftUI client for T3 Code. The project targets iOS 16.2 and later on iPhone and iPad. It has its own bundle identifier and can be installed beside the React Native T3 Code app. ## Requirements - A current Xcode release with an iOS Simulator runtime. -- iOS 17 or later for physical-device builds. +- iOS 16.2 or later for physical-device builds. - A T3 pairing URL for direct connections. T3 Connect builds additionally need the cloud settings below. diff --git a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj index a497e45a214b..8f3bc085d616 100644 --- a/apps/swift-ios/T3Code.xcodeproj/project.pbxproj +++ b/apps/swift-ios/T3Code.xcodeproj/project.pbxproj @@ -634,7 +634,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -690,7 +690,7 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -730,7 +730,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -747,7 +747,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; PRODUCT_BUNDLE_IDENTIFIER = com.t3tools.t3code.swiftui.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -768,7 +768,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Extensions/Widgets/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -799,7 +799,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Extensions/Widgets/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -832,7 +832,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Extensions/Share/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -863,7 +863,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Extensions/Share/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -939,10 +939,10 @@ /* Begin XCRemoteSwiftPackageReference section */ B20000000000000000000001 /* XCRemoteSwiftPackageReference "clerk-ios" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/clerk/clerk-ios.git"; + repositoryURL = "https://github.com/Yash-Singh1/clerk-ios.git"; requirement = { - kind = exactVersion; - version = 1.2.0; + kind = revision; + revision = e66998a138be2106cf7eb0e671155ae8d3cf797d; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 828e8e704d0a..383c10a03bfe 100644 --- a/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/swift-ios/T3Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "0da9fa23290c75a06cf2d88ffeab37061a648ea0656f8262ec633d415fa7466a", + "originHash" : "b9b9bb158004bf007a3029927116953fe619f5e2dafb129e0577a3c7c177e38b", "pins" : [ { "identity" : "clerk-ios", "kind" : "remoteSourceControl", - "location" : "https://github.com/clerk/clerk-ios.git", + "location" : "https://github.com/Yash-Singh1/clerk-ios.git", "state" : { - "revision" : "d0a5f2231dcb4b66e091a514ce2d0bead9056404", - "version" : "1.2.0" + "revision" : "e66998a138be2106cf7eb0e671155ae8d3cf797d" } }, { @@ -22,10 +21,10 @@ { "identity" : "phonenumberkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/PhoneNumberKit", + "location" : "https://github.com/PhoneNumberKit/PhoneNumberKit", "state" : { - "revision" : "169ab10234347fb19b37441f2867ace896a284b0", - "version" : "4.3.0" + "revision" : "754d0d5597593ba8bcdc2b4ddf5f85fc3a4e799f", + "version" : "5.0.7" } } ], diff --git a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift index d4cd0e294a11..4a09eb106772 100644 --- a/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/FeatureRootModelTests.swift @@ -1,10 +1,27 @@ +import Combine import Foundation -import Observation import SwiftUI import Testing import UIKit @testable import T3Code +@MainActor +private func waitForChange( + _ publisher: P, + while operation: () -> Void +) async where P.Output: Equatable, P.Failure == Never { + var cancellable: AnyCancellable? + await withCheckedContinuation { continuation in + cancellable = publisher + .removeDuplicates() + .dropFirst() + .first() + .sink { _ in continuation.resume() } + operation() + } + withExtendedLifetime(cancellable) {} +} + @MainActor @Suite("Feature root model") struct FeatureRootModelTests { @@ -491,12 +508,11 @@ struct FeatureRootModelTests { var acknowledged = try #require(model.snapshot.threads.first) acknowledged.title = "Accepted on the server" acknowledged.state = .working - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.snapshot.threads.first(where: { $0.id == acknowledged.id })?.state - } onChange: { - continuation.resume() + await waitForChange( + model.$snapshot.map { snapshot in + snapshot.threads.first(where: { $0.id == acknowledged.id })?.state } + ) { client.emit(.thread(acknowledged)) } } @@ -1544,12 +1560,7 @@ struct FeatureRootModelTests { let model = testRootModel(client: client) let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[thread.id] - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[thread.id] }) { client.emit(.detail(live)) } } @@ -1688,12 +1699,7 @@ struct FeatureRootModelTests { let model = testRootModel(client: client) let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.detailRevisions[thread.id] - } onChange: { - continuation.resume() - } + await waitForChange(model.$detailRevisions.map { $0[thread.id] }) { client.emit(.threadRemoved(id: thread.id)) } } @@ -1731,20 +1737,10 @@ struct FeatureRootModelTests { client.threadDetail = refreshed let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[thread.id]?.thread - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[thread.id]?.thread }) { client.emit(.thread(intermediateThread)) } - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[thread.id]?.thread - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[thread.id]?.thread }) { client.emit(.thread(thread)) } } @@ -1772,12 +1768,7 @@ struct FeatureRootModelTests { let run = Task { await model.start() } client.createdThread = created client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[original.id]?.thread - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[original.id]?.thread }) { client.emit(.thread(live)) } _ = await model.createThread(projectID: original.projectID, title: nil, selection: nil) @@ -1804,12 +1795,7 @@ struct FeatureRootModelTests { client.threadDetail = FeatureThreadDetail(thread: refreshed) let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.snapshot.connection - } onChange: { - continuation.resume() - } + await waitForChange(model.$snapshot.map(\.connection)) { client.emit(.thread(original)) client.emit(.connection(.init(state: .connected))) } @@ -1861,12 +1847,7 @@ struct FeatureRootModelTests { let model = testRootModel(client: client) let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.detailRevisions[thread.id] - } onChange: { - continuation.resume() - } + await waitForChange(model.$detailRevisions.map { $0[thread.id] }) { client.emit(.snapshot(FeatureSnapshot())) } } @@ -1904,20 +1885,10 @@ struct FeatureRootModelTests { client.threadDetail = refreshed let run = Task { await model.start() } client.beforeLoadThreadReturn = { - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[thread.id]?.thread - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[thread.id]?.thread }) { client.emit(.snapshot(FeatureSnapshot(threads: [intermediateThread]))) } - await withCheckedContinuation { continuation in - withObservationTracking { - _ = model.details[thread.id]?.thread - } onChange: { - continuation.resume() - } + await waitForChange(model.$details.map { $0[thread.id]?.thread }) { client.emit(.snapshot(FeatureSnapshot(threads: [thread]))) } } diff --git a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift index 61d3473665bb..33178a9a68a5 100644 --- a/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/HomeThreadSwipeActionTests.swift @@ -1,9 +1,26 @@ +import Combine import Foundation -import Observation import Testing import UIKit @testable import T3Code +@MainActor +private func waitForSwipeModelChange( + _ publisher: P, + while operation: () -> Void +) async where P.Output: Equatable, P.Failure == Never { + var cancellable: AnyCancellable? + await withCheckedContinuation { continuation in + cancellable = publisher + .removeDuplicates() + .dropFirst() + .first() + .sink { _ in continuation.resume() } + operation() + } + withExtendedLifetime(cancellable) {} +} + @MainActor @Suite("Home row trailing swipe actions") struct HomeThreadSwipeActionTests { @@ -359,28 +376,22 @@ struct HomeThreadSwipeActionTests { var authoritative = active authoritative.title = "Updated on the server" - let changed = AsyncStream.makeStream() - withObservationTracking { - _ = model.snapshot.threads.first?.title - } onChange: { - changed.continuation.yield() - } - - switch event { - case .thread: - client.emit(.thread(authoritative)) - case .detail: - client.emit(.detail(FeatureThreadDetail(thread: authoritative))) - case .detailDelta: - client.emit(.detailDelta( - FeatureThreadDetail(thread: authoritative), - FeatureDetailDelta(changedMessages: []) - )) + await waitForSwipeModelChange( + model.$snapshot.map { $0.threads.first?.title } + ) { + switch event { + case .thread: + client.emit(.thread(authoritative)) + case .detail: + client.emit(.detail(FeatureThreadDetail(thread: authoritative))) + case .detailDelta: + client.emit(.detailDelta( + FeatureThreadDetail(thread: authoritative), + FeatureDetailDelta(changedMessages: []) + )) + } } - var changes = changed.stream.makeAsyncIterator() - await changes.next() - let updated = model.snapshot.threads.first #expect(updated?.title == "Updated on the server") #expect(updated?.isSettled == true) diff --git a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift index e09d9863bf1d..05dcfa7f8003 100644 --- a/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift +++ b/apps/swift-ios/Tests/FeatureTests/MarkdownDocumentTests.swift @@ -324,6 +324,21 @@ struct MarkdownDocumentTests { #expect(runs.contains { $0.link == URL(string: "https://example.com") }) } + @Test + func legacyLinkInteractionOnlyHandlesDefaultActionBeforeIOS17() { + let handlesDefaultAction = MarkdownLinkInteractionPolicy.shouldOpenURL( + for: .invokeDefaultAction + ) + + if #available(iOS 17.0, *) { + #expect(!handlesDefaultAction) + } else { + #expect(handlesDefaultAction) + } + #expect(!MarkdownLinkInteractionPolicy.shouldOpenURL(for: .presentActions)) + #expect(!MarkdownLinkInteractionPolicy.shouldOpenURL(for: .preview)) + } + @Test @MainActor func selectableTextAttributesPreserveInlineFormatting() throws { let revision = MarkdownContentRevision( diff --git a/artifacts/live-activity-fixed-dark.jpg b/artifacts/live-activity-fixed-dark.jpg new file mode 100644 index 000000000000..159646be5c54 Binary files /dev/null and b/artifacts/live-activity-fixed-dark.jpg differ diff --git a/artifacts/live-activity-fixed.jpg b/artifacts/live-activity-fixed.jpg new file mode 100644 index 000000000000..e2d8e4646229 Binary files /dev/null and b/artifacts/live-activity-fixed.jpg differ