RecordEditorView.swift (14409B)
1 import CloudKit 2 import SwiftUI 3 4 /// In-app substitute for CloudKit Console for accounts that lack developer 5 /// console access. Fetches a record by `(scope, zone, owner, recordName)`, 6 /// displays its fields, allows editing String fields and adding new String 7 /// fields, and saves back via the CKDatabase API. Non-String fields are shown 8 /// read-only — extend the type dispatch below if a future migration needs to 9 /// edit numbers, dates, etc. 10 struct RecordEditorView: View { 11 @Environment(\.syncEngine) private var syncEngine 12 13 @State private var scope: CKDatabase.Scope = .private 14 @State private var zoneName: String = "" 15 @State private var ownerName: String = "" 16 @State private var recordName: String = "" 17 18 @State private var record: CKRecord? 19 @State private var stringEdits: [String: String] = [:] 20 @State private var newFieldName: String = "" 21 @State private var newFieldValue: String = "" 22 23 @State private var status: String = "" 24 @State private var isWorking: Bool = false 25 26 @State private var browseRecordType: String = "Ping" 27 @State private var browsedZones: [CKRecordZone.ID] = [] 28 @State private var browsedRecords: [CKRecord] = [] 29 30 var body: some View { 31 Form { 32 Section("Lookup") { 33 Picker("Scope", selection: $scope) { 34 Text("Private").tag(CKDatabase.Scope.private) 35 Text("Shared").tag(CKDatabase.Scope.shared) 36 } 37 .pickerStyle(.segmented) 38 plainField("Zone name", text: $zoneName) 39 plainField("Owner (blank = self)", text: $ownerName) 40 plainField("Record name", text: $recordName) 41 Button { 42 Task { await fetch() } 43 } label: { 44 HStack { 45 Text("Fetch") 46 if isWorking { 47 Spacer() 48 ProgressView() 49 } 50 } 51 } 52 .disabled(isWorking || zoneName.isEmpty || recordName.isEmpty) 53 } 54 55 Section("Browse zones") { 56 Button { 57 Task { await listZones() } 58 } label: { 59 Text("List zones in \(scope == .shared ? "shared" : "private") database") 60 } 61 .disabled(isWorking) 62 63 if !browsedZones.isEmpty { 64 ForEach(browsedZones, id: \.self) { zoneID in 65 Button { 66 zoneName = zoneID.zoneName 67 ownerName = zoneID.ownerName == CKCurrentUserDefaultName 68 ? "" 69 : zoneID.ownerName 70 browsedRecords = [] 71 } label: { 72 VStack(alignment: .leading, spacing: 2) { 73 Text(zoneID.zoneName) 74 .font(.caption.monospaced()) 75 .foregroundStyle(.primary) 76 Text("owner: \(zoneID.ownerName)") 77 .font(.caption2.monospaced()) 78 .foregroundStyle(.secondary) 79 } 80 .frame(maxWidth: .infinity, alignment: .leading) 81 } 82 } 83 } 84 } 85 86 Section("Browse records") { 87 plainField("Record type (e.g. Ping)", text: $browseRecordType) 88 Button { 89 Task { await listRecords() } 90 } label: { 91 Text("List records in zone") 92 } 93 .disabled(isWorking || zoneName.isEmpty || browseRecordType.isEmpty) 94 95 if !browsedRecords.isEmpty { 96 ForEach(browsedRecords, id: \.recordID) { record in 97 BrowsedRecordRow(record: record) { 98 recordName = record.recordID.recordName 99 Task { await fetch() } 100 } 101 .swipeActions(edge: .trailing) { 102 Button(role: .destructive) { 103 Task { await deleteBrowsedRecord(record) } 104 } label: { 105 Label("Delete", systemImage: "trash") 106 } 107 } 108 } 109 } 110 } 111 112 if let record { 113 Section("Fields") { 114 let keys = record.allKeys().sorted() 115 if keys.isEmpty { 116 Text("(no fields)").foregroundStyle(.secondary) 117 } else { 118 ForEach(keys, id: \.self) { key in 119 FieldRow( 120 key: key, 121 value: record[key], 122 stringBinding: stringBinding(for: key, in: record) 123 ) 124 } 125 } 126 } 127 128 Section("Add string field") { 129 plainField("Name", text: $newFieldName) 130 plainField("Value", text: $newFieldValue) 131 } 132 133 Section("Metadata") { 134 metaRow("Type", record.recordType) 135 metaRow("Zone", record.recordID.zoneID.zoneName) 136 metaRow("Owner", record.recordID.zoneID.ownerName) 137 if let mod = record.modificationDate { 138 metaRow("Modified", ISO8601DateFormatter().string(from: mod)) 139 } 140 if let tag = record.recordChangeTag { 141 metaRow("ChangeTag", tag) 142 } 143 } 144 145 Section { 146 Button("Save changes") { 147 Task { await save() } 148 } 149 .disabled(isWorking || !hasPendingEdits) 150 } 151 } 152 153 if !status.isEmpty { 154 Section("Status") { 155 Text(status) 156 .font(.body.monospaced()) 157 .textSelection(.enabled) 158 } 159 } 160 } 161 .navigationTitle("Record Editor") 162 .navigationBarTitleDisplayMode(.inline) 163 } 164 165 // MARK: - Subviews 166 167 @ViewBuilder 168 private func plainField(_ placeholder: String, text: Binding<String>) -> some View { 169 TextField(placeholder, text: text) 170 .textInputAutocapitalization(.never) 171 .autocorrectionDisabled() 172 .font(.body.monospaced()) 173 } 174 175 @ViewBuilder 176 private func metaRow(_ title: String, _ value: String) -> some View { 177 VStack(alignment: .leading, spacing: 2) { 178 Text(title).font(.caption).foregroundStyle(.secondary) 179 Text(value).font(.caption.monospaced()).textSelection(.enabled) 180 } 181 } 182 183 // MARK: - Bindings / state 184 185 private var hasPendingEdits: Bool { 186 !stringEdits.isEmpty || !newFieldName.isEmpty 187 } 188 189 private func stringBinding(for key: String, in record: CKRecord) -> Binding<String>? { 190 guard record[key] is String else { return nil } 191 return Binding( 192 get: { stringEdits[key] ?? (record[key] as? String) ?? "" }, 193 set: { stringEdits[key] = $0 } 194 ) 195 } 196 197 // MARK: - Actions 198 199 private func fetch() async { 200 guard let syncEngine else { return } 201 isWorking = true 202 defer { isWorking = false } 203 status = "Fetching…" 204 do { 205 let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName 206 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 207 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 208 let fetched = try await syncEngine.fetchRecordForEdit( 209 scope: scope, 210 recordID: recordID 211 ) 212 record = fetched 213 stringEdits = [:] 214 newFieldName = "" 215 newFieldValue = "" 216 status = "Fetched." 217 } catch { 218 record = nil 219 status = describe(error) 220 } 221 } 222 223 private func save() async { 224 guard let syncEngine, let record else { return } 225 isWorking = true 226 defer { isWorking = false } 227 status = "Saving…" 228 229 for (key, value) in stringEdits { 230 record[key] = value.isEmpty ? nil : (value as CKRecordValue) 231 } 232 let trimmedNewName = newFieldName.trimmingCharacters(in: .whitespaces) 233 if !trimmedNewName.isEmpty { 234 record[trimmedNewName] = newFieldValue.isEmpty 235 ? nil 236 : (newFieldValue as CKRecordValue) 237 } 238 239 do { 240 let saved = try await syncEngine.saveRecordForEdit(scope: scope, record: record) 241 self.record = saved 242 stringEdits = [:] 243 newFieldName = "" 244 newFieldValue = "" 245 status = "Saved." 246 } catch { 247 status = describe(error) 248 } 249 } 250 251 private func listZones() async { 252 guard let syncEngine else { return } 253 isWorking = true 254 defer { isWorking = false } 255 status = "Listing zones…" 256 do { 257 browsedZones = try await syncEngine.listZonesForEdit(scope: scope) 258 status = "Found \(browsedZones.count) zone(s)." 259 } catch { 260 browsedZones = [] 261 status = describe(error) 262 } 263 } 264 265 private func listRecords() async { 266 guard let syncEngine else { return } 267 isWorking = true 268 defer { isWorking = false } 269 status = "Listing records…" 270 let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName 271 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 272 do { 273 browsedRecords = try await syncEngine.queryRecordsForEdit( 274 scope: scope, 275 zoneID: zoneID, 276 recordType: browseRecordType 277 ) 278 status = "Found \(browsedRecords.count) \(browseRecordType) record(s)." 279 } catch { 280 browsedRecords = [] 281 status = describe(error) 282 } 283 } 284 285 private func deleteBrowsedRecord(_ record: CKRecord) async { 286 guard let syncEngine else { return } 287 isWorking = true 288 defer { isWorking = false } 289 status = "Deleting \(record.recordID.recordName)…" 290 do { 291 try await syncEngine.deleteRecordForEdit(scope: scope, recordID: record.recordID) 292 browsedRecords.removeAll { $0.recordID == record.recordID } 293 if self.record?.recordID == record.recordID { 294 self.record = nil 295 stringEdits = [:] 296 } 297 status = "Deleted \(record.recordID.recordName)." 298 } catch { 299 status = describe(error) 300 } 301 } 302 303 private func describe(_ error: Error) -> String { 304 let nsError = error as NSError 305 return "Error: domain=\(nsError.domain) code=\(nsError.code) — \(nsError.localizedDescription)" 306 } 307 } 308 309 private struct BrowsedRecordRow: View { 310 let record: CKRecord 311 let onTap: () -> Void 312 313 var body: some View { 314 Button(action: onTap) { 315 VStack(alignment: .leading, spacing: 2) { 316 Text(record.recordID.recordName) 317 .font(.caption.monospaced()) 318 .foregroundStyle(.primary) 319 .lineLimit(2) 320 HStack(spacing: 8) { 321 if let kind = record["kind"] as? String { 322 Text("kind=\(kind)") 323 .font(.caption2.monospaced()) 324 .foregroundStyle(.secondary) 325 } 326 if let mod = record.modificationDate { 327 Text(ISO8601DateFormatter().string(from: mod)) 328 .font(.caption2.monospaced()) 329 .foregroundStyle(.secondary) 330 } 331 } 332 } 333 .frame(maxWidth: .infinity, alignment: .leading) 334 } 335 } 336 } 337 338 private struct FieldRow: View { 339 let key: String 340 let value: CKRecordValue? 341 let stringBinding: Binding<String>? 342 343 var body: some View { 344 VStack(alignment: .leading, spacing: 4) { 345 HStack { 346 Text(key).font(.caption.bold()) 347 Spacer() 348 Text(typeName).font(.caption2).foregroundStyle(.secondary) 349 } 350 if let stringBinding { 351 TextField("", text: stringBinding, axis: .vertical) 352 .font(.body.monospaced()) 353 .textInputAutocapitalization(.never) 354 .autocorrectionDisabled() 355 } else { 356 Text(displayValue) 357 .font(.body.monospaced()) 358 .textSelection(.enabled) 359 .foregroundStyle(.secondary) 360 } 361 } 362 } 363 364 private var typeName: String { 365 guard let value else { return "nil" } 366 if value is String { return "String" } 367 if value is Date { return "Date" } 368 if value is Data { return "Data" } 369 if value is CKAsset { return "Asset" } 370 if value is CKRecord.Reference { return "Reference" } 371 if value is [Any] { return "Array" } 372 if value is NSNumber { return "Number" } 373 return String(describing: type(of: value)) 374 } 375 376 private var displayValue: String { 377 guard let value else { return "(nil)" } 378 if let s = value as? String { return s } 379 if let d = value as? Date { return ISO8601DateFormatter().string(from: d) } 380 if let data = value as? Data { return "\(data.count) bytes" } 381 if let asset = value as? CKAsset { 382 return "asset(\(asset.fileURL?.lastPathComponent ?? "?"))" 383 } 384 if let ref = value as? CKRecord.Reference { 385 return "ref(\(ref.recordID.recordName))" 386 } 387 if let arr = value as? [Any] { return "[\(arr.count) items]" } 388 return "\(value)" 389 } 390 }