345 lines
11 KiB
Swift
345 lines
11 KiB
Swift
#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 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
|