Skip to content

Instantly share code, notes, and snippets.

@jmb
Last active June 5, 2024 05:04
Show Gist options
  • Save jmb/c6087d5299c1b795aa01684511c341ab to your computer and use it in GitHub Desktop.
Save jmb/c6087d5299c1b795aa01684511c341ab to your computer and use it in GitHub Desktop.
import Foundation
import SwiftUI
import SwiftData
// MARK - Model
@Model
class Validation: Hashable, Identifiable {
var id: String
var name: String
var colourName: String
var expiry: Date = Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date.distantPast
@Transient var isNew: Bool = false
init(id: String = "", name: String = "", colourName: String = "", expiry: Date = Date()) {
self.id = id.uppercased()
self.name = name
self.colourName = colourName
self.expiry = expiry
}
init() {
self.id = ""
self.name = ""
self.colourName = ""
self.isNew = true
}
var isEmpty: Bool {
self.id.isEmpty && self.name.isEmpty
}
var daysUntilExpiry: Int {
let expiry = Calendar.current.startOfDay(for: self.expiry)
let today = Calendar.current.startOfDay(for: .now)
let days = Calendar.current.dateComponents([.day], from: today, to: expiry).day
return days ?? -1
}
var isExpired: Bool {
self.daysUntilExpiry < 0
}
}
// MARK - Main View for settings
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.modelContext) var modelContext
@Query var validationEntries: [Validation]
@State private var selectedValidation: Validation?
@State private var addEditValidationID: PersistentIdentifier? = nil
var body: some View {
NavigationStack {
List {
Section(
header:
HStack{
Text("Validations")
Spacer()
EditButton()
}
) {
ForEach(validationEntries) { validation in
ValidationSummaryRow(validation: validation)
.onTapGesture {
selectedValidation = validation
addEditValidationID = validation.persistentModelID
}
}
.onDelete(perform: deleteValidations)
Button {
selectedValidation = nil
addEditValidationID = Validation().persistentModelID
} label: {
HStack {
Image(systemName: "plus")
Text("Add Validation")
}
}
}
}
.font(.headline)
.listStyle(.grouped)
.navigationTitle("Settings")
.sheet(item: $addEditValidationID, onDismiss: {
let count = validationEntries.count
print("Validation entries count: \(count)")
}) { addEditID in
do {
print("Presenting sheet for \(addEditID)")
return AddEditValidationView(validationID: addEditID, in: modelContext.container)
}
}
}
}
func validationRow(validation: Validation) -> some View {
HStack(spacing: 20){
ValidationButton(text: validation.id, colourName: validation.colourName)
Text(validation.name)
.frame(maxWidth: .infinity, alignment: .leading)
if validation.isExpired {
HStack {
Text("Expired")
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(.red)
} else {
Text("\(validation.daysUntilExpiry) days")
.foregroundStyle(
validation.daysUntilExpiry < 15 ? .red :
validation.daysUntilExpiry < 30 ? .orange :
validation.daysUntilExpiry < 90 ? .green : .primary
)
}
}
}
}
// MARK - Add/Edit Validation
struct AddEditValidationView: View {
@Environment(\.dismiss) var dismiss
@Bindable var validation: Validation
var modelContext: ModelContext
private var editorTitle: String = "New Validation"
init(validationID: PersistentIdentifier, in container: ModelContainer) {
print("Initialising AddEditValidationView with ID: \(validationID)")
modelContext = ModelContext(container)
modelContext.autosaveEnabled = false
print("Created new ModelContext")
if var editValidation: Validation = try? modelContext.existingModel(for: validationID) {
print("Looked up ID in modelContext and got: \(editValidation)")
validation = editValidation
editorTitle = "Edit Validation"
} else {
print("New validation")
validation = Validation()
modelContext.insert(validation)
}
}
@Query var allValidations: [Validation]
private var disableSave: Bool {
return validation.id.isEmpty || validation.name.isEmpty
}
var body: some View {
NavigationStack {
Form {
TextField("ID", text: $validation.id)
TextField("Name", text: $validation.name)
DatePicker("Expiry Date", selection: $validation.expiry, displayedComponents: [.date])
ColourPicker(selectedColour: $validation.colourName)
}
.toolbar {
ToolbarItem(placement: .principal) {
Text(editorTitle)
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
withAnimation {
save()
dismiss()
}
}
.disabled(disableSave)
}
ToolbarItem(placement: .cancellationAction) {
Button("Cancel", role: .cancel) {
dismiss()
}
}
}
}
}
func cancel() {
dismiss()
}
private func save() {
let expiryStartOfDay = Calendar.current.startOfDay(for: validation.expiry)
validation.expiry = expiryStartOfDay
try? modelContext.save()
}
}
// MARK - Extra helper views
struct ValidationSummaryRow: View {
@State var validation: Validation
var body: some View {
HStack(spacing: 20){
ValidationButton(text: validation.id, colourName: validation.colourName)
Text(validation.name)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(.secondary.opacity(0.0001)) // make the space in the row tappable
if validation.isExpired {
HStack {
Text("Expired")
Image(systemName: "exclamationmark.triangle.fill")
}
.foregroundStyle(.red)
} else {
Text("\(validation.daysUntilExpiry) days")
.foregroundStyle(
validation.daysUntilExpiry < 15 ? .red :
validation.daysUntilExpiry < 30 ? .orange :
validation.daysUntilExpiry < 90 ? .green : .primary
)
}
}
}
}
struct ColourPicker: View {
@Binding var selectedColour: String
@Namespace private var colourPickerNamespace
var body: some View {
HStack {
ForEach(Color.buttonColours, id: \.self) { colour in
ZStack {
if selectedColour == colour {
RoundedRectangle(cornerRadius: 6)
.fill(selectedColour == colour ? Color(colour) : .white)
.matchedGeometryEffect(id: "picker", in: colourPickerNamespace)
}
RoundedRectangle(cornerRadius: 6)
.stroke(Color(colour), lineWidth: 2)
.fill(Color(colour).opacity(0.1))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture {
withAnimation(.spring){
if selectedColour == colour {
selectedColour = ""
} else {
selectedColour = colour
}
}
}
}
}
}
}
@helje5
Copy link

helje5 commented Apr 28, 2024

Works now. SwiftData at its best.

import Foundation
import SwiftUI
import SwiftData

@main
struct TheApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationStack {
        SettingsView()
      }
      .modelContainer(for: [ Validation.self ])
    }
  }
}

// MARK - Model
@Model
class Validation: Hashable, Identifiable {
  var id          = ""
  var name        = ""
  var colourName  = ""
  var expiry      = Calendar.current.date(byAdding: .year, value: 1, to: Date())
                 ?? Date.distantPast

  init() {}
  
  var isEmpty: Bool { id.isEmpty && name.isEmpty && colourName.isEmpty }
  
  var daysUntilExpiry: Int {
    let calendar = Calendar.current
    let expiry   = calendar.startOfDay(for: expiry)
    let today    = calendar.startOfDay(for: .now)
    return calendar.dateComponents([.day], from: today, to: expiry).day ?? -1
  }
  
  var isExpired: Bool { daysUntilExpiry < 0 }
  
  enum ExpirationSeverity { case distant, cool, getOnIt, DOITNOW }
  
  var severity : ExpirationSeverity {
    if      daysUntilExpiry < 15 { .DOITNOW }
    else if daysUntilExpiry < 30 { .getOnIt }
    else if daysUntilExpiry < 90 { .cool    }
    else                         { .distant }
  }
}

extension Validation {
  
  var severityColor: Color {
    switch severity {
      case .DOITNOW : Color.red
      case .getOnIt : Color.orange
      case .cool    : Color.green
      case .distant : Color.primary
    }
  }
}

// MARK - Main View for settings
struct SettingsView: View {
  
  @Environment(\.dismiss)      private var dismiss
  @Environment(\.modelContext) private var modelContext
  
  @Query private var validationEntries   : [ Validation ]
  @State private var selectedValidation  : Validation?
  @State private var addEditValidationID : PersistentIdentifier?
  
  private func deleteValidations(_ indices: IndexSet) {
    fatalError("Not implemented")
  }
  
  private func add() {
    selectedValidation  = nil
    addEditValidationID = Validation().persistentModelID
  }
  private func edit(_ validation: Validation) {
    selectedValidation  = validation
    addEditValidationID = validation.persistentModelID
  }
  private func refresh() {
    validationEntries.forEach { $0.colourName = $0.colourName } // Yes, really
  }
  
  var body: some View {
    List {
      Section(header:
        HStack {
          Text("Validations")
            .frame(maxWidth: .infinity, alignment: .leading)
          EditButton()
        }
      )
      {
        ForEach(validationEntries) { validation in
          ValidationSummaryRow(validation: validation)
            .onTapGesture { edit(validation) }
        }
        .onDelete(perform: deleteValidations)
        
        Button(action: add) {
          Label("Add Validation", systemImage: "plus")
        }
      }
    }
    .font(.headline)
    .listStyle(.grouped)
    .navigationTitle("Settings")
    .sheet(item: $addEditValidationID, onDismiss: refresh) { addEditID in
      AddEditValidationView(validationID: addEditID,
                            in: modelContext.container)
    }
  }
  
  private func validationRow(validation: Validation) -> some View {
    HStack(spacing: 20) {
      ValidationButton(text: validation.id, colourName: validation.colourName)
      
      Text(validation.name)
        .frame(maxWidth: .infinity, alignment: .leading)
      Text(validation.colourName)
        .frame(maxWidth: .infinity, alignment: .leading)

      if validation.isExpired {
        HStack {
          Text("Expired")
          Image(systemName: "exclamationmark.triangle.fill")
        }
        .foregroundStyle(.red)
      } 
      else {
        Text("\(validation.daysUntilExpiry) days")
          .foregroundStyle(validation.severityColor)
      }
    }
  }
}

extension ModelContext {
  
  func existingModel<T>(for objectID: PersistentIdentifier, type: T.Type) 
         throws -> T?
    where T: PersistentModel
  {
    if let registered: T = registeredModel(for: objectID) {
      return registered
    }
    
    let fetchDescriptor = FetchDescriptor<T>(
      predicate: #Predicate { $0.persistentModelID == objectID }
    )
    return try fetch(fetchDescriptor).first
  }
}

// MARK - Add/Edit Validation
struct AddEditValidationView: View {
  
  @Environment(\.dismiss) private var dismiss
  @Bindable var validation: Validation
  
  @State private var modelContext: ModelContext
  private let editorTitle: String
  
  init(validationID: PersistentIdentifier, in container: ModelContainer) {
    let modelContext = ModelContext(container)
    modelContext.autosaveEnabled = false
    self.modelContext = modelContext

    if let editValidation =
      try! modelContext.existingModel(for: validationID, type: Validation.self)
    {
      validation  = editValidation
      editorTitle = "Edit Validation"
    }
    else {
      validation = Validation()
      editorTitle = "New Validation"
      modelContext.insert(validation)
    }
  }
  
  @Query var allValidations: [Validation]
  
  private var disableSave: Bool {
    validation.id.isEmpty || validation.name.isEmpty
  }
  
  var body: some View {
    
    NavigationStack {
      Form {
        TextField("ID",   text: $validation.id)
        TextField("Name", text: $validation.name)
        DatePicker("Expiry Date", selection: $validation.expiry,
                   displayedComponents: [.date])
        ColourPicker(selectedColour: $validation.colourName)
      }
      .toolbar {
        ToolbarItem(placement: .principal) {
          Text(editorTitle)
        }
        
        ToolbarItem(placement: .confirmationAction) {
          Button("Save") {
            withAnimation {
              save()
              dismiss()
            }
          }
          .disabled(disableSave)
        }
        
        ToolbarItem(placement: .cancellationAction) {
          Button("Cancel", role: .cancel) { dismiss() }
        }
      }
    }
  }
  
  func cancel() {
    dismiss()
  }
  
  private func save() {
    let expiryStartOfDay = Calendar.current.startOfDay(for: validation.expiry)
    validation.expiry = expiryStartOfDay
    try! modelContext.save()
  }
}


// MARK - Extra helper views
struct ValidationSummaryRow: View {
  
  let validation: Validation
  
  var body: some View {
    HStack(spacing: 20) {
      ValidationButton(text: validation.id, colourName: validation.colourName)
      
      Text(validation.name)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
      
      if validation.isExpired {
        HStack {
          Text("Expired")
          Image(systemName: "exclamationmark.triangle.fill")
        }
        .foregroundStyle(.red)
      } 
      else {
        Text("\(validation.daysUntilExpiry) days")
          .foregroundStyle(validation.severityColor)
      }
    }
    .contentShape(Rectangle())
  }
}

struct ValidationButton: View {
  
  let text       : String
  let colourName : String
  
  var body: some View {
    Button("\(text) \(colourName)") {
      print("Do something?")
    }
  }
}

extension Color {
  static let buttonColours = [ "red", "green", "blue" ]
}

struct ColourPicker: View {

  @Binding           var selectedColour: String
  @Namespace private var colourPickerNamespace
  
  private func color(for colour: String) -> Color {
    switch colour {
      case "red"   : .red
      case "green" : .green
      case "blue"  : .blue
      default      : .white
    }
  }
  
  var body: some View {
    HStack {
      ForEach(Color.buttonColours, id: \.self) { colour in
        let color      = color(for: colour)
        let isSelected = selectedColour == colour
        ZStack {
          if isSelected {
            RoundedRectangle(cornerRadius: 6)
              .fill(color)
              .matchedGeometryEffect(id: "picker", in: colourPickerNamespace)
          }
          RoundedRectangle(cornerRadius: 6)
            .stroke(color, lineWidth: 2)
            .fill(color.opacity(0.1))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .onTapGesture {
          withAnimation(.spring) {
            selectedColour = isSelected ? "" : colour
          }
        }
      }
    }
  }
}

@jmb
Copy link
Author

jmb commented Apr 28, 2024

Wow, thank you for the suggestions! I tried the first of your versions and it seems to work, so no idea why my original in my project was blocking editing the text.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment