Files
byre/mac/Sources/Byre/Phone.swift
T

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