Files
Termind/TFI/Features/MobileRootView.swift
T
2026-09-11 21:07:44 +08:00

220 lines
8.1 KiB
Swift

import SwiftUI
import TermindDomain
import UIKit
import struct TermindDomain.Host
/// iPhone has four destinations. iPad retains the sidebar with individual library routes.
struct MobileRootView: View {
let app: MobileAppModel
@Environment(\.scenePhase) private var scenePhase
@State private var sceneID = UUID()
@State private var tab: MobileTab = .vault
@State private var selection: MobileDestination? = .vault
@State private var workspaceID: UUID?
@State private var selectingConnection = false
@State private var unavailable = false
var body: some View {
Group {
if UIDevice.current.userInterfaceIdiom != .pad {
TabView(selection: $tab) {
NavigationStack { vault }
.tabItem { Label("Vault", systemImage: "server.rack") }.tag(MobileTab.vault)
NavigationStack { workspaceList }
.tabItem { Label("Workspaces", systemImage: "terminal") }.tag(MobileTab.workspaces)
NavigationStack { assistant }
.tabItem { Label("Assistant", systemImage: "sparkles") }.tag(MobileTab.assistant)
NavigationStack { settings }
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(MobileTab.settings)
}
} else {
NavigationSplitView {
List(selection: $selection) {
ForEach(MobileDestination.allCases) { destination in
NavigationLink(value: destination) {
Label(destination.title, systemImage: destination.symbol)
}
}
}.navigationTitle("Termind")
} detail: {
NavigationStack {
switch selection {
case .vault: vault
case .assistant: assistant
case .settings: settings
case .metrics: metrics
case .workspaces: workspaceList
default: vault
}
}
}
}
}
.sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) {
MobileProView(pro: app.pro)
}
.onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(sceneID, phase: phase) }
.onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
.onDisappear { app.removeScene(sceneID) }
.onOpenURL { url in
if WorkspaceActivityAttributes.accepts(url) {
tab = .workspaces
selection = .workspaces
workspaceID = nil
return
}
guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return }
tab = .workspaces
selection = .workspaces
if let workspace = app.workspaces.first(where: { $0.id == id }) {
workspace.activateWhenOpened()
workspaceID = id
app.liveActivities.focus(id, in: app.workspaces)
workspace.terminal.requestFocus()
} else {
unavailable = true
}
}
.alert(
unavailable ? String(localized: "Workspace Unavailable") : String(localized: "Operation Failed"),
isPresented: Binding(
get: { unavailable || app.error != nil },
set: {
if !$0 {
unavailable = false
app.error = nil
}
})
) {
Button("OK", role: .cancel) {
unavailable = false
app.error = nil
}
} message: {
Text(
unavailable ? String(localized: "This workspace was closed or its host was removed.") : app.error ?? "")
}
}
private var vault: some View {
MobileVaultView(app: app, open: open, executeSnippet: executeSnippet)
}
private var workspaceList: some View {
List {
Section {
NavigationLink {
MobileTransfersView(transfers: app.transfers)
} label: {
Label("Transfers", systemImage: "arrow.up.arrow.down")
}
}
Section("Workspaces") { workspaceRows }
}
.navigationTitle("Workspaces")
.toolbar {
MobileWorkspaceToolbar(
hasWorkspaces: !app.workspaces.isEmpty,
closeAll: closeAllWorkspaces,
connect: { selectingConnection = true })
}
.navigationDestination(item: $workspaceID) { id in
if let workspace = app.workspaces.first(where: { $0.id == id }) {
MobileWorkspaceView(
library: app.library, pro: app.pro, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: app.workspaces,
selectWorkspace: selectWorkspace, closeWorkspace: closeWorkspace
)
.onAppear { workspace.activateWhenOpened() }
}
}
.sheet(isPresented: $selectingConnection) {
NavigationStack {
MobileConnectionPicker(
library: app.library,
connect: { hostID, kind in
selectingConnection = false
open(hostID, kind)
},
cancel: { selectingConnection = false })
}
.mobileSheetPresentation(compact: true)
}
}
@ViewBuilder private var workspaceRows: some View {
if app.workspaces.isEmpty {
Text("Open a host from Vault to start a workspace.").foregroundStyle(.secondary)
}
ForEach(app.workspaces) { workspace in
Button {
selectWorkspace(workspace.id)
} label: {
Label {
VStack(alignment: .leading) {
Text(workspace.descriptor.title)
Text(workspace.status).font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: workspace.descriptor.kind.symbol)
}
}
.swipeActions {
Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) }
}
.contextMenu {
Button("Close", systemImage: "xmark", role: .destructive) { closeWorkspace(workspace.id) }
}
}
}
private var assistant: some View {
MobileAssistantView(app: app, model: app.assistant)
}
private var metrics: some View {
MobileMetricsView(app: app)
}
private var settings: some View {
MobileSettingsView(app: app)
}
private func open(_ hostID: UUID, _ kind: MobileWorkspaceDescriptor.Kind) {
workspaceID = app.open(hostID: hostID, kind: kind)
tab = .workspaces
selection = .workspaces
}
private func executeSnippet(_ snippet: Snippet, on hostIDs: [Host.ID]) {
guard let first = app.execute(snippet, on: hostIDs).first else { return }
workspaceID = first
tab = .workspaces
selection = .workspaces
app.liveActivities.focus(first, in: app.workspaces)
}
private func selectWorkspace(_ id: UUID) {
guard let workspace = app.workspaces.first(where: { $0.id == id }) else { return }
workspace.activateWhenOpened()
workspaceID = id
app.liveActivities.focus(id, in: app.workspaces)
}
private func closeWorkspace(_ id: UUID) {
app.close(id)
if workspaceID == id { workspaceID = nil }
}
private func closeAllWorkspaces() {
app.closeAll()
workspaceID = nil
}
}
nonisolated enum MobileTab: String, CaseIterable {
case vault, workspaces, assistant, settings
}