-
-
Save jmb/c6087d5299c1b795aa01684511c341ab to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} | |
} | |
} | |
} | |
} | |
} |
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
}
}
}
}
}
}
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
Color.buttonColours contains a [String] of named colours from my asset catalog.