| 1 | import RastrilloApple |
| 2 | import SwiftUI |
| 3 | |
| 4 | @MainActor public struct SheetsWorkspace: View { |
| 5 | @State private var model: SheetModel |
| 6 | @State private var column = NavigationSplitViewColumn.sidebar |
| 7 | private struct Panel: Identifiable { |
| 8 | let name: String |
| 9 | var id: String { name } |
| 10 | } |
| 11 | @State private var panel: Panel? |
| 12 | @State private var query = "" |
| 13 | @State private var pendingSheet: SheetSummary? |
| 14 | @State private var askDiscard = false |
| 15 | @FocusState private var formulaFocused: Bool |
| 16 | @Environment(\.scenePhase) private var scenePhase |
| 17 | @AppStorage("sheets.appearance") private var appearance = "auto" |
| 18 | private let updateRecents: (([(UInt64, String)]) -> Void)? |
| 19 | private let canSignOut: Bool |
| 20 | @Binding private var accountsRequested: Bool |
| 21 | @Binding private var createRequested: Bool |
| 22 | private let documentID: UInt64? |
| 23 | private let openDocument: ((UInt64, String) -> Void)? |
| 24 | private let signOut: () -> Void |
| 25 | private let accounts: (() -> Void)? |
| 26 | public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: UInt64? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((UInt64, String) -> Void)? = nil, updateRecents: (([(UInt64, String)]) -> Void)? = nil) { |
| 27 | self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument |
| 28 | _model = State(initialValue: SheetModel(client: client)) |
| 29 | self.signOut = signOut |
| 30 | self.accounts = accounts |
| 31 | } |
| 32 | public var body: some View { |
| 33 | Group { |
| 34 | #if os(macOS) |
| 35 | if documentID != nil { editor } else { home } |
| 36 | #else |
| 37 | NavigationSplitView(preferredCompactColumn: $column) { |
| 38 | library |
| 39 | } detail: { |
| 40 | if model.page != nil { |
| 41 | editor |
| 42 | } else { |
| 43 | ContentUnavailableView( |
| 44 | "Choose a sheet", systemImage: "tablecells", |
| 45 | description: Text("Create a spreadsheet or open one from the list.")) |
| 46 | } |
| 47 | } |
| 48 | #endif |
| 49 | } |
| 50 | .onChange(of: model.sheets.map { "\($0.ID)\u{0}\($0.Title)" }) { |
| 51 | if query.isEmpty { updateRecents?(model.sheets.map { ($0.ID, $0.Title) }) } |
| 52 | } |
| 53 | .onChange(of: accountsRequested && !model.busy, initial: true) { _, ready in |
| 54 | if ready { accountsRequested = false; accounts?() } |
| 55 | } |
| 56 | .onChange(of: createRequested, initial: true) { _, requested in |
| 57 | if requested && documentID == nil { createRequested = false; Task { await createSheet() } } |
| 58 | } |
| 59 | .task { |
| 60 | if let documentID { |
| 61 | await model.open(SheetSummary(ID: documentID, Title: "", TabID: 0, TabName: "")) |
| 62 | } else { await model.loadList() } |
| 63 | } |
| 64 | .onChange(of: model.page?.Sheet.ID) { _, value in if value != nil { column = .detail } } |
| 65 | .onChange(of: scenePhase) { _, value in |
| 66 | if value != .active { model.clearCopyProvenance() } |
| 67 | } |
| 68 | .safeAreaInset(edge: .top, spacing: 0) { |
| 69 | NativeOfflineBanner(connectivity: model.connectivity, origin: model.client.origin, |
| 70 | message: sheetsMacString("You’re offline. Waiting for a connection.")) |
| 71 | } |
| 72 | .task(id: "\(model.page?.Sheet.ID ?? 0)-\(scenePhase == .active)") { |
| 73 | if scenePhase == .active { await model.watch() } |
| 74 | } |
| 75 | .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) |
| 76 | #if os(macOS) |
| 77 | .background(SheetCloseGuard(model: model).frame(width: 0, height: 0)) |
| 78 | #endif |
| 79 | .sheet(item: $panel) { item in SheetPanels(model: model, panel: item.name, openImported: openDocument) } |
| 80 | .confirmationDialog("Keep your cell edit?", isPresented: $askDiscard, titleVisibility: .visible) |
| 81 | { |
| 82 | Button("Save and continue") { |
| 83 | Task { |
| 84 | await model.saveAll() |
| 85 | if !model.hasUnconfirmedChanges, let next = pendingSheet { await model.open(next) } |
| 86 | } |
| 87 | } |
| 88 | Button("Discard and continue", role: .destructive) { |
| 89 | model.discardAll() |
| 90 | if let next = pendingSheet { Task { await model.open(next) } } |
| 91 | } |
| 92 | Button("Cancel", role: .cancel) { pendingSheet = nil } |
| 93 | } |
| 94 | } |
| 95 | private func createSheet() async { |
| 96 | #if os(macOS) |
| 97 | if let sheet = await model.create(openCreated: false) { openDocument?(sheet.ID, sheet.Title) } |
| 98 | #else |
| 99 | await model.create() |
| 100 | #endif |
| 101 | } |
| 102 | private var filteredSheets: [SheetSummary] { |
| 103 | query.isEmpty ? model.sheets : model.sheets.filter { $0.Title.localizedStandardContains(query) } |
| 104 | } |
| 105 | private var library: some View { |
| 106 | List { |
| 107 | if !model.error.isEmpty { |
| 108 | Label(model.error, systemImage: "exclamationmark.circle").foregroundStyle(.red) |
| 109 | } |
| 110 | ForEach(filteredSheets) { sheet in |
| 111 | Button { |
| 112 | if model.hasUnconfirmedChanges { |
| 113 | pendingSheet = sheet |
| 114 | askDiscard = true |
| 115 | } else { Task { await model.open(sheet) } } |
| 116 | } label: { |
| 117 | HStack(spacing: 16) { |
| 118 | SheetThumbnail(sheet.Preview) |
| 119 | VStack(alignment: .leading, spacing: 4) { |
| 120 | Text(verbatim: sheet.Title).font(.headline).foregroundStyle(.primary) |
| 121 | Text(verbatim: sheet.TabName).font(.subheadline).foregroundStyle(.secondary) |
| 122 | } |
| 123 | Spacer(minLength: 0) |
| 124 | Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) |
| 125 | }.padding(.vertical, 6) |
| 126 | }.disabled(model.busy).accessibilityElement(children: .combine) |
| 127 | .accessibilityLabel(sheet.Title).accessibilityValue(sheet.TabName) |
| 128 | } |
| 129 | } |
| 130 | .listStyle(.plain) |
| 131 | .overlay { |
| 132 | if model.listState == .loading { ProgressView() } |
| 133 | if model.listState == .loaded && model.sheets.isEmpty { |
| 134 | ContentUnavailableView("No spreadsheets", systemImage: "tablecells", |
| 135 | description: Text("Create a spreadsheet or import a workbook.")) |
| 136 | } else if filteredSheets.isEmpty && !query.isEmpty { |
| 137 | ContentUnavailableView.search(text: query) |
| 138 | } |
| 139 | } |
| 140 | .navigationTitle("Sheets") |
| 141 | .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets")) |
| 142 | .refreshable { await model.loadList() } |
| 143 | .toolbar { |
| 144 | if let accounts { |
| 145 | ToolbarItem(placement: .navigation) { |
| 146 | Button(sheetsMacString("Accounts"), systemImage: "chevron.backward", action: accounts) |
| 147 | .disabled(model.hasUnconfirmedChanges || model.busy) |
| 148 | } |
| 149 | } |
| 150 | ToolbarItem(placement: .primaryAction) { |
| 151 | Menu { |
| 152 | Button("Import", systemImage: "square.and.arrow.down") { show("Import") } |
| 153 | .disabled(model.hasUnconfirmedChanges || model.busy) |
| 154 | Picker("Appearance", selection: $appearance) { |
| 155 | Text("System").tag("auto") |
| 156 | Text("Light").tag("light") |
| 157 | Text("Dark").tag("dark") |
| 158 | } |
| 159 | Button(sheetsMacString("Sign out"), systemImage: "rectangle.portrait.and.arrow.right", role: .destructive, |
| 160 | action: signOut).disabled(model.hasUnconfirmedChanges || model.busy) |
| 161 | } label: { |
| 162 | VStack(spacing: 3) { |
| 163 | Circle().frame(width: 3, height: 3) |
| 164 | Circle().frame(width: 3, height: 3) |
| 165 | Circle().frame(width: 3, height: 3) |
| 166 | }.frame(width: 20, height: 20) |
| 167 | }.accessibilityLabel("More") |
| 168 | } |
| 169 | #if os(iOS) |
| 170 | DefaultToolbarItem(kind: .search, placement: .bottomBar) |
| 171 | ToolbarSpacer(.flexible, placement: .bottomBar) |
| 172 | ToolbarItem(placement: .bottomBar) { newSheet } |
| 173 | #else |
| 174 | ToolbarItem { newSheet } |
| 175 | #endif |
| 176 | } |
| 177 | } |
| 178 | private var newSheet: some View { |
| 179 | Button(sheetsMacString("New spreadsheet"), systemImage: "plus") { Task { await createSheet() } } |
| 180 | .disabled(model.hasUnconfirmedChanges || model.busy) |
| 181 | } |
| 182 | private func show(_ name: String) { |
| 183 | panel = Panel(name: name) |
| 184 | } |
| 185 | @ViewBuilder private var editorCommands: some View { |
| 186 | Menu("File") { |
| 187 | Button("Rename") { show("Rename") }.disabled(!model.mayWrite) |
| 188 | Button("Export workbook") { show("Export") }.disabled(model.busy || model.dirty) |
| 189 | Button("Version history") { show("History") } |
| 190 | if model.page?.MayShare == true { Button("Share") { show("Share") } } |
| 191 | } |
| 192 | Menu("Edit") { |
| 193 | Button("Undo my last value edit") { Task { await model.command("/undo") } }.disabled( |
| 194 | !model.mayWrite |
| 195 | ) |
| 196 | .keyboardShortcut("z") |
| 197 | Button(sheetsMacString("Copy")) { model.copy() }.keyboardShortcut("c") |
| 198 | Button(sheetsMacString("Cut")) { |
| 199 | model.copy() |
| 200 | Task { await model.clear() } |
| 201 | }.keyboardShortcut("x").disabled(!model.mayWrite) |
| 202 | Button(sheetsMacString("Paste")) { Task { await model.paste() } }.keyboardShortcut("v").disabled( |
| 203 | !model.mayWrite) |
| 204 | Button(sheetsMacString("Clear contents")) { Task { await model.clear() } }.disabled(!model.mayWrite) |
| 205 | Button(sheetsMacString("Fill down")) { Task { await model.fill(down: true) } }.keyboardShortcut("d").disabled( |
| 206 | !model.mayWrite) |
| 207 | Button(sheetsMacString("Fill right")) { Task { await model.fill(down: false) } }.disabled(!model.mayWrite) |
| 208 | Button(sheetsMacString("Select all")) { |
| 209 | model.rangeText = "A1:" + Coordinate(r: model.rows - 1, c: model.cols - 1).ref |
| 210 | model.selectRange() |
| 211 | }.keyboardShortcut("a") |
| 212 | }.disabled(model.hasUnconfirmedChanges || model.busy) |
| 213 | Menu("Insert function") { |
| 214 | ForEach(["SUM", "AVERAGE", "COUNT", "MIN", "MAX"], id: \.self) { name in |
| 215 | Button(name) { Task { await model.insertFunction(name) } } |
| 216 | } |
| 217 | }.disabled(!model.mayWrite || model.hasUnconfirmedChanges || model.busy) |
| 218 | SheetFormatMenu(model: model) |
| 219 | Button("Sizes") { show("Sizes") }.disabled( |
| 220 | !model.mayWrite || model.hasUnconfirmedChanges || model.busy) |
| 221 | Button("Sort and filter") { show("Data") }.disabled( |
| 222 | model.hasUnconfirmedChanges || model.busy) |
| 223 | Button("Comments") { show("Comments") } |
| 224 | Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.refresh() } } |
| 225 | } |
| 226 | private var editor: some View { |
| 227 | VStack(spacing: 0) { |
| 228 | #if os(iOS) |
| 229 | ViewThatFits(in: .horizontal) { |
| 230 | HStack { editorCommands }.fixedSize(horizontal: true, vertical: false) |
| 231 | Menu("Sheet actions", systemImage: "ellipsis.circle") { editorCommands } |
| 232 | .frame(maxWidth: .infinity, alignment: .leading) |
| 233 | }.padding(8) |
| 234 | #endif |
| 235 | SheetFormulaBar(model: model, formulaFocused: $formulaFocused) |
| 236 | if !model.failedWrites.isEmpty { |
| 237 | HStack { |
| 238 | Button("Retry pending edits") { Task { await model.retryWrites() } }.disabled(model.busy) |
| 239 | Button("Discard pending edits", role: .destructive) { |
| 240 | model.failedWrites = [] |
| 241 | model.discard() |
| 242 | }.disabled(model.busy) |
| 243 | }.padding(8) |
| 244 | } |
| 245 | if !model.error.isEmpty { |
| 246 | Text(model.error).foregroundStyle(.red).padding(8).accessibilityAddTraits( |
| 247 | .updatesFrequently) |
| 248 | } |
| 249 | if model.showTable { |
| 250 | SheetTable(model: model) |
| 251 | } else { |
| 252 | SheetGrid(model: model, beginEditing: { formulaFocused = true }) |
| 253 | } |
| 254 | SheetStatusBar(model: model) |
| 255 | if model.page?.Truncated == true { |
| 256 | Text("This grid shows up to 500 rows and 200 columns. Export includes the complete sheet.") |
| 257 | .font(.caption).padding(4) |
| 258 | } |
| 259 | if !(model.page?.Warned ?? []).isEmpty { |
| 260 | Text( |
| 261 | "Imported colours have low contrast in: " |
| 262 | + (model.page?.Warned ?? []).joined(separator: ", ") |
| 263 | ).font(.caption).padding(4) |
| 264 | } |
| 265 | }.navigationTitle(model.page?.Sheet.Title ?? "Sheets") |
| 266 | #if os(macOS) |
| 267 | .focusedSceneValue(\.sheetEditor, SheetEditorContext(model: model, panel: Binding(get: { panel?.name }, set: { panel = $0.map { Panel(name: $0) } }))) |
| 268 | #endif |
| 269 | #if os(iOS) |
| 270 | .navigationBarTitleDisplayMode(.inline) |
| 271 | #endif |
| 272 | } |
| 273 | #if os(macOS) |
| 274 | private var home: some View { |
| 275 | NavigationSplitView { |
| 276 | List { Label(sheetsMacString("Recent"), systemImage: "clock").listRowBackground(Color.accentColor.opacity(0.12)) } |
| 277 | .navigationSplitViewColumnWidth(min: 160, ideal: 190, max: 240) |
| 278 | } detail: { |
| 279 | ScrollView { |
| 280 | VStack(alignment: .leading, spacing: 24) { |
| 281 | Text(sheetsMacString("Recent spreadsheets")).font(.largeTitle.bold()) |
| 282 | if !model.error.isEmpty { Text(model.error).foregroundStyle(.red) } |
| 283 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: 260))], spacing: 24) { |
| 284 | ForEach(filteredSheets) { sheet in |
| 285 | Button { openDocument?(sheet.ID, sheet.Title) } label: { |
| 286 | VStack(alignment: .leading, spacing: 10) { |
| 287 | SheetThumbnail(sheet.Preview, width: 200, height: 150) |
| 288 | .background(.background).clipShape(.rect(cornerRadius: 5)) |
| 289 | Text(sheet.Title).font(.headline).lineLimit(2) |
| 290 | Text(sheet.TabName).font(.caption).foregroundStyle(.secondary) |
| 291 | }.frame(maxWidth: .infinity, alignment: .leading).padding(12) |
| 292 | }.buttonStyle(.plain) |
| 293 | } |
| 294 | } |
| 295 | if model.listState == .loading { ProgressView().frame(maxWidth: .infinity) } |
| 296 | if model.listState == .loaded && model.sheets.isEmpty { |
| 297 | ContentUnavailableView("No spreadsheets", systemImage: "tablecells", description: Text("Create a spreadsheet or import a workbook.")) |
| 298 | } |
| 299 | }.padding(28) |
| 300 | } |
| 301 | .navigationTitle(sheetsMacString("Home")) |
| 302 | .searchable(text: $query, prompt: sheetsMacString("Search spreadsheets")) |
| 303 | .focusedSceneValue(\.sheetsAccountActions, SheetsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.busy, canSignOut: canSignOut && !model.busy)) |
| 304 | .toolbar { |
| 305 | newSheet |
| 306 | Button(sheetsMacString("Import workbook")) { show("Import") } |
| 307 | Button(sheetsMacString("Refresh"), systemImage: "arrow.clockwise") { Task { await model.loadList() } } |
| 308 | } |
| 309 | } |
| 310 | } |
| 311 | #endif |
| 312 | |
| 313 | } |
| 314 | |