Add an iPhone client sharing the macOS app's SwiftUI code
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
final class PhoneDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
Notifier.shared.install()
|
||||
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 enum MoreRoute: Hashable {
|
||||
case notes, workflows, status, settings
|
||||
}
|
||||
|
||||
private let moreTitles: [MoreRoute: String] = [
|
||||
.notes: "Notes",
|
||||
.workflows: "Workflows",
|
||||
.status: "Status",
|
||||
.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] = []
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
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()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle().fill(Palette.border).frame(height: 1)
|
||||
}
|
||||
}
|
||||
.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: tab == .tasks && !path.isEmpty, initial: true) { _, shown in
|
||||
store.paneFocus.setShown(shown)
|
||||
}
|
||||
.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 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 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)
|
||||
Text(store.me?.displayName ?? "")
|
||||
.foregroundStyle(Palette.muted)
|
||||
}
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TaskListScreen: View {
|
||||
@Environment(Store.self) private var store
|
||||
@Environment(Overlays.self) private var overlays
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(store.projects, id: \.id) { project in
|
||||
let active = (store.tasks[project.id] ?? []).filter { $0.status == .active }
|
||||
Button {
|
||||
store.sheet = nil
|
||||
store.focusProject(project.id)
|
||||
} label: {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(project.name)
|
||||
.font(Fonts.font(13, weight: 700))
|
||||
Text(project.defaultBranch)
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(Fonts.font(11))
|
||||
.foregroundStyle(Palette.muted)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if active.isEmpty {
|
||||
Text("No active tasks.")
|
||||
.foregroundStyle(Palette.muted)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
TaskRows(active: active)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
Button("New task") {
|
||||
overlays.openSwitcher(store)
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
.onGeometryChange(for: CGRect.self) { g in
|
||||
g.frame(in: .named("window"))
|
||||
} action: { frame in
|
||||
overlays.pickerFrame = frame
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.background(Palette.bg)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle().fill(Palette.border).frame(height: 1)
|
||||
}
|
||||
}
|
||||
.background(Palette.bg)
|
||||
.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 = store.sheet.flatMap { $0.owner == .lead ? nil : $0.tab } ?? store.primaryTab
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MoreScreen: View {
|
||||
@ViewState private var route = MoreRoute.notes
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach([MoreRoute.notes, .workflows, .status, .settings], id: \.self) { r in
|
||||
PaneTabButton(label: moreTitles[r]!, active: route == r) {
|
||||
route = r
|
||||
}
|
||||
}
|
||||
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
|
||||
Reference in New Issue
Block a user