crossmate

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

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 }