#if os(iOS) import SwiftUI final class PhoneDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { Notifier.shared.install() UITextView.appearance().textContainerInset = .zero return true } } @main struct PhoneApp: App { @UIApplicationDelegateAdaptor private var delegate: PhoneDelegate var body: some Scene { WindowGroup { ServerWindow() } } } private enum PhoneTab: Hashable { case lead, tasks, more } private enum PhoneRoute: Hashable { case task } private struct PhonePage: Equatable { var tab: PhoneTab var depth: Int var task: String? var taskTab: TaskTab? } @MainActor private func taskTab(_ store: Store) -> TaskTab { store.sheet.flatMap { $0.owner == .lead ? nil : $0.tab } ?? store.primaryTab } private enum MoreRoute: Hashable { case status, notes, workflows, settings } private let moreTitles: [MoreRoute: String] = [ .status: "Status", .notes: "Notes", .workflows: "Workflows", .settings: "Settings", ] struct PhoneView: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays @ViewState private var tab = PhoneTab.lead @ViewState private var path: [PhoneRoute] = [] @ViewState private var moreRoute = MoreRoute.status var body: some View { VStack(spacing: 0) { IdentityBanner() ZStack { shown(.lead) { LeadScreen() } shown(.tasks) { NavigationStack(path: $path) { TaskListScreen() .navigationDestination(for: PhoneRoute.self) { _ in PhoneTask() } } } shown(.more) { MoreScreen(route: $moreRoute) } } .frame(maxWidth: .infinity, maxHeight: .infinity) tabBar } .scrollDismissesKeyboard(.interactively) .overlay { OverlayHost() } .coordinateSpace(.named("window")) .onGeometryChange(for: CGRect.self) { g in g.frame(in: .named("window")) } action: { frame in overlays.paneFrame = frame } .environment(\.openURL, OpenURLAction { url in openAppLink(url, store, overlays) }) .onChange(of: store.reveals, initial: true) { if store.reveals == 0 { return } tab = .tasks if path.isEmpty { path = [.task] } } .onChange(of: overlays.leadFocus) { tab = .lead } .onChange(of: overlays.tabFocus) { switch overlays.leftTab { case .lead, .server: tab = .lead case .status: tab = .more moreRoute = .status case .notes: tab = .more moreRoute = .notes case .workflows: tab = .more moreRoute = .workflows case .settings: tab = .more moreRoute = .settings } } .onChange(of: tab == .tasks && !path.isEmpty, initial: true) { _, shown in store.paneFocus.setShown(shown) } .onChange(of: page) { dismissKeyboard() } .fullScreenCover(isPresented: Binding(get: { zoomed != nil }, set: { shown in if !shown { store.sheet = nil } })) { if let zoomed { ZoomedView(view: zoomed) .environment(store) .environment(overlays) } } .onAppear { store.paneFocus.attach() } .onDisappear { let store = store Task { await store.paneFocus.leave() } } } private var tabBar: some View { HStack(spacing: 4) { tabButton("Lead", .lead) HStack(spacing: 0) { tabButton("Tasks", .tasks) if !store.inbox.isEmpty { Badge(count: store.inbox.count) } } tabButton("More", .more) Spacer(minLength: 0) ConnectionMark() Button { overlays.openSwitcher(store) } label: { Image(systemName: "plus") .font(Fonts.font(17, weight: 600)) .foregroundStyle(Palette.accent) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("New task") Button { tab = .tasks path = [] } label: { Image(systemName: "list.bullet") .font(Fonts.font(17, weight: 600)) .foregroundStyle(Palette.accent) .padding(.horizontal, 10) .padding(.vertical, 8) .contentShape(Rectangle()) } .buttonStyle(.plain) .accessibilityLabel("Task list") } .padding(.horizontal, 12) .padding(.vertical, 4) .overlay(alignment: .top) { Rectangle().fill(Palette.border).frame(height: 1) } } private func shown(_ which: PhoneTab, @ViewBuilder _ content: () -> some View) -> some View { content() .opacity(tab == which ? 1 : 0) .allowsHitTesting(tab == which) .accessibilityHidden(tab != which) } private func tabButton(_ label: String, _ which: PhoneTab) -> some View { PaneTabButton(label: label, active: tab == which) { if tab == which && which == .tasks { path = [] } tab = which } } private var page: PhonePage { if tab != .tasks || path.isEmpty { return PhonePage(tab: tab, depth: path.count) } return PhonePage(tab: tab, depth: path.count, task: store.selectedTaskId, taskTab: taskTab(store)) } private var zoomed: SheetView? { switch store.sheet?.view { case .image, .diagram: store.sheet?.view default: nil } } } private struct ZoomedView: View { let view: SheetView @Environment(Store.self) private var store var body: some View { VStack(spacing: 0) { HStack { Spacer() Button("Done") { store.sheet = nil } .font(Fonts.font(15, weight: 700)) .padding(.horizontal, 16) .padding(.vertical, 10) } SheetContent(view: view) .frame(maxWidth: .infinity, maxHeight: .infinity) } .foregroundStyle(Palette.text) .tint(Palette.accent) .background(Palette.bg) .font(Fonts.font(13)) } } private struct LeadScreen: View { var body: some View { ChatView(kind: .lead) .background(Palette.bg) } } private struct ConnectionMark: View { @Environment(Store.self) private var store var body: some View { HStack(spacing: 8) { if !store.connected { Text("reconnecting…") .foregroundStyle(Palette.muted) } Circle() .fill(store.connected ? Palette.ok : Palette.muted) .frame(width: 8, height: 8) } .lineLimit(1) } } private struct TaskListScreen: View { var body: some View { TaskList() .toolbar(.hidden, for: .navigationBar) } } private struct PhoneTask: View { @Environment(Store.self) private var store @Environment(\.dismiss) private var dismiss var body: some View { let selected = store.selectedTaskId.flatMap { store.findTask($0) } let accent = selected.map { taskPalette[$0.color] } ?? Palette.accent let tab = taskTab(store) VStack(spacing: 0) { TaskBar() .overlay(alignment: .leading) { Button { dismiss() } label: { Image(systemName: "chevron.left") .font(Fonts.font(15, weight: 600)) .foregroundStyle(accent) .padding(.horizontal, 14) .frame(maxHeight: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) } if store.selectedTaskId == nil { ProjectView() } else { TabStrip() TaskTabBody(tab: tab) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .background(Palette.bg) .environment(\.paneAccent, accent) .environment(\.inTask, true) .toolbar(.hidden, for: .navigationBar) } } @MainActor private func dismissKeyboard() { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) } private struct MoreScreen: View { @Binding var route: MoreRoute var body: some View { VStack(spacing: 0) { HStack(spacing: 4) { ForEach([MoreRoute.status, .notes, .workflows, .settings], id: \.self) { r in PaneTabButton(label: moreTitles[r]!, active: route == r) { route = r dismissKeyboard() } } Spacer(minLength: 0) } .padding(.horizontal, 12) .overlay(alignment: .bottom) { Rectangle().fill(Palette.border).frame(height: 1) } Group { switch route { case .notes: NotesView() case .workflows: WorkflowsView() case .status: StatusView() case .settings: SettingsView() } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .background(Palette.bg) } } #endif