RecordEditorView.swift (8866B)
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 var body: some View { 27 Form { 28 Section("Lookup") { 29 Picker("Scope", selection: $scope) { 30 Text("Private").tag(CKDatabase.Scope.private) 31 Text("Shared").tag(CKDatabase.Scope.shared) 32 } 33 .pickerStyle(.segmented) 34 plainField("Zone name", text: $zoneName) 35 plainField("Owner (blank = self)", text: $ownerName) 36 plainField("Record name", text: $recordName) 37 Button { 38 Task { await fetch() } 39 } label: { 40 HStack { 41 Text("Fetch") 42 if isWorking { 43 Spacer() 44 ProgressView() 45 } 46 } 47 } 48 .disabled(isWorking || zoneName.isEmpty || recordName.isEmpty) 49 } 50 51 if let record { 52 Section("Fields") { 53 let keys = record.allKeys().sorted() 54 if keys.isEmpty { 55 Text("(no fields)").foregroundStyle(.secondary) 56 } else { 57 ForEach(keys, id: \.self) { key in 58 FieldRow( 59 key: key, 60 value: record[key], 61 stringBinding: stringBinding(for: key, in: record) 62 ) 63 } 64 } 65 } 66 67 Section("Add string field") { 68 plainField("Name", text: $newFieldName) 69 plainField("Value", text: $newFieldValue) 70 } 71 72 Section("Metadata") { 73 metaRow("Type", record.recordType) 74 metaRow("Zone", record.recordID.zoneID.zoneName) 75 metaRow("Owner", record.recordID.zoneID.ownerName) 76 if let mod = record.modificationDate { 77 metaRow("Modified", ISO8601DateFormatter().string(from: mod)) 78 } 79 if let tag = record.recordChangeTag { 80 metaRow("ChangeTag", tag) 81 } 82 } 83 84 Section { 85 Button("Save changes") { 86 Task { await save() } 87 } 88 .disabled(isWorking || !hasPendingEdits) 89 } 90 } 91 92 if !status.isEmpty { 93 Section("Status") { 94 Text(status) 95 .font(.body.monospaced()) 96 .textSelection(.enabled) 97 } 98 } 99 } 100 .navigationTitle("Record Editor") 101 .navigationBarTitleDisplayMode(.inline) 102 } 103 104 // MARK: - Subviews 105 106 @ViewBuilder 107 private func plainField(_ placeholder: String, text: Binding<String>) -> some View { 108 TextField(placeholder, text: text) 109 .textInputAutocapitalization(.never) 110 .autocorrectionDisabled() 111 .font(.body.monospaced()) 112 } 113 114 @ViewBuilder 115 private func metaRow(_ title: String, _ value: String) -> some View { 116 VStack(alignment: .leading, spacing: 2) { 117 Text(title).font(.caption).foregroundStyle(.secondary) 118 Text(value).font(.caption.monospaced()).textSelection(.enabled) 119 } 120 } 121 122 // MARK: - Bindings / state 123 124 private var hasPendingEdits: Bool { 125 !stringEdits.isEmpty || !newFieldName.isEmpty 126 } 127 128 private func stringBinding(for key: String, in record: CKRecord) -> Binding<String>? { 129 guard record[key] is String else { return nil } 130 return Binding( 131 get: { stringEdits[key] ?? (record[key] as? String) ?? "" }, 132 set: { stringEdits[key] = $0 } 133 ) 134 } 135 136 // MARK: - Actions 137 138 private func fetch() async { 139 guard let syncEngine else { return } 140 isWorking = true 141 defer { isWorking = false } 142 status = "Fetching…" 143 do { 144 let owner = ownerName.isEmpty ? CKCurrentUserDefaultName : ownerName 145 let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: owner) 146 let recordID = CKRecord.ID(recordName: recordName, zoneID: zoneID) 147 let fetched = try await syncEngine.fetchRecordForEdit( 148 scope: scope, 149 recordID: recordID 150 ) 151 record = fetched 152 stringEdits = [:] 153 newFieldName = "" 154 newFieldValue = "" 155 status = "Fetched." 156 } catch { 157 record = nil 158 status = describe(error) 159 } 160 } 161 162 private func save() async { 163 guard let syncEngine, let record else { return } 164 isWorking = true 165 defer { isWorking = false } 166 status = "Saving…" 167 168 for (key, value) in stringEdits { 169 record[key] = value.isEmpty ? nil : (value as CKRecordValue) 170 } 171 let trimmedNewName = newFieldName.trimmingCharacters(in: .whitespaces) 172 if !trimmedNewName.isEmpty { 173 record[trimmedNewName] = newFieldValue.isEmpty 174 ? nil 175 : (newFieldValue as CKRecordValue) 176 } 177 178 do { 179 let saved = try await syncEngine.saveRecordForEdit(scope: scope, record: record) 180 self.record = saved 181 stringEdits = [:] 182 newFieldName = "" 183 newFieldValue = "" 184 status = "Saved." 185 } catch { 186 status = describe(error) 187 } 188 } 189 190 private func describe(_ error: Error) -> String { 191 let nsError = error as NSError 192 return "Error: domain=\(nsError.domain) code=\(nsError.code) — \(nsError.localizedDescription)" 193 } 194 } 195 196 private struct FieldRow: View { 197 let key: String 198 let value: CKRecordValue? 199 let stringBinding: Binding<String>? 200 201 var body: some View { 202 VStack(alignment: .leading, spacing: 4) { 203 HStack { 204 Text(key).font(.caption.bold()) 205 Spacer() 206 Text(typeName).font(.caption2).foregroundStyle(.secondary) 207 } 208 if let stringBinding { 209 TextField("", text: stringBinding, axis: .vertical) 210 .font(.body.monospaced()) 211 .textInputAutocapitalization(.never) 212 .autocorrectionDisabled() 213 } else { 214 Text(displayValue) 215 .font(.body.monospaced()) 216 .textSelection(.enabled) 217 .foregroundStyle(.secondary) 218 } 219 } 220 } 221 222 private var typeName: String { 223 guard let value else { return "nil" } 224 if value is String { return "String" } 225 if value is Date { return "Date" } 226 if value is Data { return "Data" } 227 if value is CKAsset { return "Asset" } 228 if value is CKRecord.Reference { return "Reference" } 229 if value is [Any] { return "Array" } 230 if value is NSNumber { return "Number" } 231 return String(describing: type(of: value)) 232 } 233 234 private var displayValue: String { 235 guard let value else { return "(nil)" } 236 if let s = value as? String { return s } 237 if let d = value as? Date { return ISO8601DateFormatter().string(from: d) } 238 if let data = value as? Data { return "\(data.count) bytes" } 239 if let asset = value as? CKAsset { 240 return "asset(\(asset.fileURL?.lastPathComponent ?? "?"))" 241 } 242 if let ref = value as? CKRecord.Reference { 243 return "ref(\(ref.recordID.recordName))" 244 } 245 if let arr = value as? [Any] { return "[\(arr.count) items]" } 246 return "\(value)" 247 } 248 }