Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions apps/swift-ios/App/Cloud/T3ConnectCapability.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Combine
import ClerkKit
import Foundation
import Observation
import OSLog

public extension Notification.Name {
Expand Down Expand Up @@ -50,16 +50,15 @@ 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"
)
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] = [:]
Expand All @@ -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?
Expand Down
9 changes: 4 additions & 5 deletions apps/swift-ios/App/Platform/PlatformIncomingShare.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Combine
import Foundation
import Observation
import SwiftUI

enum PlatformIncomingShareError: LocalizedError, Equatable {
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions apps/swift-ios/App/Platform/PlatformRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/swift-ios/App/T3CodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
186 changes: 186 additions & 0 deletions apps/swift-ios/DesignSystem/T3Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<LabelContent: View, DescriptionContent: View>(
@ViewBuilder label: () -> LabelContent,
@ViewBuilder description: () -> DescriptionContent
) {
self.label = AnyView(label())
self.description = AnyView(description())
actions = AnyView(EmptyView())
searchText = nil
}

init<LabelContent: View, DescriptionContent: View, ActionsContent: View>(
@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<Value: Equatable>(
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<Value: Equatable>(
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<Value: Equatable>(
of value: Value,
initial: Bool = false,
perform action: @escaping () -> Void
) -> some View {
t3OnChange(of: value, initial: initial) { _, _ in action() }
}
}

private struct T3OnChangeModifier<Value: Equatable>: 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)
}
}
}
24 changes: 23 additions & 1 deletion apps/swift-ios/Extensions/Widgets/RecentTasksWidget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,34 @@ 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.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}

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
Expand Down Expand Up @@ -88,6 +108,7 @@ private struct T3TaskWidgetView: View {
}
}
.widgetURL(orderedTasks.first?.nativeDeepLinkURL ?? T3WidgetURLs.newTask)
.t3LegacyWidgetContentMargins()
}

private var mediumView: some View {
Expand Down Expand Up @@ -128,6 +149,7 @@ private struct T3TaskWidgetView: View {
}
}
}
.t3LegacyWidgetContentMargins()
}

private var accessoryView: some View {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,11 +263,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,
Expand Down
Loading
Loading