import RastrilloApple import SwiftUI @MainActor public struct SheetsWorkspace: View { @State private var model: SheetModel @State private var column = NavigationSplitViewColumn.sidebar private struct Panel: Identifiable { let name: String var id: String { name } } @State private var panel: Panel? @State private var query = "" @State private var pendingSheet: SheetSummary? @State private var askDiscard = false @FocusState private var formulaFocused: Bool @Environment(\.scenePhase) private var scenePhase @AppStorage("sheets.appearance") private var appearance = "auto" private let updateRecents: (([(UInt64, String)]) -> Void)? private let canSignOut: Bool @Binding private var accountsRequested: Bool @Binding private var createRequested: Bool private let documentID: UInt64? private let openDocument: ((UInt64, String) -> Void)? private let signOut: () -> Void private let accounts: (() -> Void)? public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: UInt64? = nil, canSignOut: Bool = true, createRequested: Binding = .constant(false), accountsRequested: Binding = .constant(false), openDocument: ((UInt64, String) -> Void)? = nil, updateRecents: (([(UInt64, String)]) -> Void)? = nil) { self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument _model = State(initialValue: SheetModel(client: client)) self.signOut = signOut self.accounts = accounts } public var body: some View { Group { #if os(macOS) if documentID != nil { editor } else { home } #else NavigationSplitView(preferredCompactColumn: $column) { library } detail: { if model.page != nil { editor } else { ContentUnavailableView( "Choose a sheet", systemImage: "tablecells", description: Text("Create a spreadsheet or open one from the list.")) } } #endif } .onChange(of: model.sheets.map { "\($0.ID)\u{0}\($0.Title)" }) { if query.isEmpty { updateRecents?(model.sheets.map { ($0.ID, $0.Title) }) } } .onChange(of: accountsRequested && !model.busy, initial: true) { _, ready in if ready { accountsRequested = false; accounts?() } } .onChange(of: createRequested, initial: true) { _, requested in if requested && documentID == nil { createRequested = false; Task { await createSheet() } } } .task { if let documentID { await model.open(SheetSummary(ID: documentID, Title: "", TabID: 0, TabName: "")) } else { await model.loadList() } } .onChange(of: model.page?.Sheet.ID) { _, value in if value != nil { column = .detail } } .onChange(of: scenePhase) { _, value in if value != .active { model.clearCopyProvenance() } } .safeAreaInset(edge: .top, spacing: 0) { NativeOfflineBanner(connectivity: model.connectivity, origin: model.client.origin, message: sheetsMacString("You’re offline. Waiting for a connection.")) } .task(id: "\(model.page?.Sheet.ID ?? 0)-\(scenePhase == .active)") { if scenePhase == .active { await model.watch() } } .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) #if os(macOS) .background(SheetCloseGuard(model: model).frame(width: 0, height: 0)) #endif .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name, openImported: openDocument) } .confirmationDialog("Keep your cell edit?", isPresented: $askDiscard, titleVisibility: .visible) { Button("Save and continue") { Task { await model.saveAll() if !model.hasUnconfirmedChanges, let next = pendingSheet { await model.open(next) } } } Button("Discard and continue", role: .destructive) { model.discardAll() if let next = pendingSheet { Task { await model.open(next) } } } Button("Cancel", role: .cancel) { pendingSheet = nil } } } private func createSheet() async { #if os(macOS) if let sheet = await model.create(openCreated: false) { openDocument?(sheet.ID, sheet.Title) } #else await model.create() #endif } private var filteredSheets: [SheetSummary] { query.isEmpty ? model.sheets : model.sheets.filter { $0.Title.localizedStandardContains(query) } } private var library: some View { List { if !model.error.isEmpty { Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) } ForEach(filteredSheets) { sheet in Button { if model.hasUnconfirmedChanges { pendingSheet = sheet askDiscard = true } else { Task { await model.open(sheet) } } } label: { HStack(spacing: 16) { SheetThumbnail(sheet.Preview) VStack(alignment: .leading, spacing: 4) { Text(verbatim: sheet.Title).font(.headline).foregroundStyle(.primary) Text(verbatim: sheet.TabName).font(.subheadline).foregroundStyle(.secondary) } Spacer(minLength: 0) Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) }.padding(.vertical, 6) }.disabled(model.busy).accessibilityElement(children: .combine) .accessibilityLabel(sheet.Title).accessibilityValue(sheet.TabName) } } .listStyle(.plain) .overlay { if model.listState == .loading { ProgressView() } if model.listState == .loaded && model.sheets.isEmpty { ContentUnavailableView("No spreadsheets", systemImage: "tablecells", description: Text("Create a spreadsheet or import a workbook.")) } else if filteredSheets.isEmpty && !query.isEmpty { ContentUnavailableView.search(text: query) } } .navigationTitle("Sheets") .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets")) .refreshable { await model.loadList() } .toolbar { if let accounts { ToolbarItem(placement: .navigation) { Button(sheetsMacString("Accounts"), systemImage: "chevron.backward", action: accounts) .disabled(model.hasUnconfirmedChanges || model.busy) } } ToolbarItem(placement: .primaryAction) { Menu { Button("Import", systemImage: "square.and.arrow.down") { show("Import") } .disabled(model.hasUnconfirmedChanges || model.busy) Picker("Appearance", selection: $appearance) { Text("System").tag("auto") Text("Light").tag("light") Text("Dark").tag("dark") } Button(sheetsMacString("Sign out"), systemImage: "rectangle.portrait.and.arrow.right", role: .destructive, action: signOut).disabled(model.hasUnconfirmedChanges || model.busy) } label: { VStack(spacing: 3) { Circle().frame(width: 3, height: 3) Circle().frame(width: 3, height: 3) Circle().frame(width: 3, height: 3) }.frame(width: 20, height: 20) }.accessibilityLabel("More") } #if os(iOS) DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.flexible, placement: .bottomBar) ToolbarItem(placement: .bottomBar) { newSheet } #else ToolbarItem { newSheet } #endif } } private var newSheet: some View { Button(sheetsMacString("New spreadsheet"), systemImage: "plus") { Task { await createSheet() } } .disabled(model.hasUnconfirmedChanges || model.busy) } private func show(_ name: String) { panel = Panel(name: name) } @ViewBuilder private var editorCommands: some View { Menu("File") { Button("Rename") { show("Rename") }.disabled(!model.mayWrite) Button("Export workbook") { show("Export") }.disabled(model.busy || model.dirty) Button("Version history") { show("History") } if model.page?.MayShare == true { Button("Share") { show("Share") } } } Menu("Edit") { Button("Undo my last value edit") { Task { await model.command("/undo") } }.disabled( !model.mayWrite ) .keyboardShortcut("z") Button(sheetsMacString("Copy")) { model.copy() }.keyboardShortcut("c") Button(sheetsMacString("Cut")) { model.copy() Task { await model.clear() } }.keyboardShortcut("x").disabled(!model.mayWrite) Button(sheetsMacString("Paste")) { Task { await model.paste() } }.keyboardShortcut("v").disabled( !model.mayWrite) Button(sheetsMacString("Clear contents")) { Task { await model.clear() } }.disabled(!model.mayWrite) Button(sheetsMacString("Fill down")) { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled( !model.mayWrite) Button(sheetsMacString("Fill right")) { Task { await model.fill(down: false) } }.disabled(!model.mayWrite) Button(sheetsMacString("Select all")) { model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref model.selectRange() }.keyboardShortcut("a") }.disabled(model.hasUnconfirmedChanges || model.busy) Menu("Insert function") { ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in Button(name) { Task { await model.insertFunction(name) } } } }.disabled(!model.mayWrite || model.hasUnconfirmedChanges || model.busy) SheetFormatMenu(model: model) Button("Sizes") { show("Sizes") }.disabled( !model.mayWrite || model.hasUnconfirmedChanges || model.busy) Button("Sort and filter") { show("Data") }.disabled( model.hasUnconfirmedChanges || model.busy) Button("Comments") { show("Comments") } Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.refresh() } } } private var editor: some View { VStack(spacing: 0) { #if os(iOS) ViewThatFits(in: .horizontal) { HStack { editorCommands }.fixedSize(horizontal: true, vertical: false) Menu("Sheet actions", systemImage: "ellipsis.circle") { editorCommands } .frame(maxWidth: .infinity, alignment: .leading) }.padding(8) #endif SheetFormulaBar(model: model, formulaFocused: $formulaFocused) if !model.failedWrites.isEmpty { HStack { Button("Retry pending edits") { Task { await model.retryWrites() } }.disabled(model.busy) Button("Discard pending edits", role: .destructive) { model.failedWrites = [] model.discard() }.disabled(model.busy) }.padding(8) } if !model.error.isEmpty { Text(model.error).foregroundStyle(.red).padding(8).accessibilityAddTraits( .updatesFrequently) } if model.showTable { SheetTable(model: model) } else { SheetGrid(model: model, beginEditing: { formulaFocused = true }) } SheetStatusBar(model: model) if model.page?.Truncated == true { Text("This grid shows up to 500 rows and 200 columns. Export includes the complete sheet.") .font(.caption).padding(4) } if !(model.page?.Warned ?? []).isEmpty { Text( "Imported colours have low contrast in: " + (model.page?.Warned ?? []).joined(separator: ", ") ).font(.caption).padding(4) } }.navigationTitle(model.page?.Sheet.Title ?? "Sheets") #if os(macOS) .focusedSceneValue(\.sheetEditor, SheetEditorContext(model: model, panel: Binding(get: { panel?.name }, set: { panel = $0.map { Panel(name: $0) } }))) #endif #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif } #if os(macOS) private var home: some View { NavigationSplitView { List { Label(sheetsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) } .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240) } detail: { ScrollView { VStack(alignment: .leading, spacing: 24) { Text(sheetsMacString("Recent spreadsheets")).font(.largeTitle.bold()) if !model.error.isEmpty { Text(model.error).foregroundStyle(.red) } LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: 260))], spacing: 24) { ForEach(filteredSheets) { sheet in Button { openDocument?(sheet.ID, sheet.Title) } label: { VStack(alignment: .leading, spacing: 10) { SheetThumbnail(sheet.Preview, width: 200, height: 150) .background(.background).clipShape(.rect(cornerRadius: 5)) Text(sheet.Title).font(.headline).lineLimit(2) Text(sheet.TabName).font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, alignment: .leading).padding(12) }.buttonStyle(.plain) } } if model.listState == .loading { ProgressView().frame(maxWidth: .infinity) } if model.listState == .loaded && model.sheets.isEmpty { ContentUnavailableView("No spreadsheets", systemImage: "tablecells", description: Text("Create a spreadsheet or import a workbook.")) } }.padding(28) } .navigationTitle(sheetsMacString("Home")) .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets")) .focusedSceneValue(\.sheetsAccountActions, SheetsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.busy, canSignOut: canSignOut && !model.busy)) .toolbar { newSheet Button(sheetsMacString("Import workbook")) { show("Import") } Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.loadList() } } } } } #endif }