crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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 }