oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1import RastrilloApple
2import 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