← Back to index

EditEntryView

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import SwiftUI import UniformTypeIdentifiers import PhotosUIFramework importsImports SwiftUI, UniformTypeIdentifiers, PhotosUI.
struct EditEntryView: View { @Environment(\.dismiss) var dismiss @Binding var entry: SpotEntry var onSave: (() -> Void)? = nil var onAddressChanged: ((UUID) -> Void)? = nil var isImported: Bool = false @State private var playName: String @State private var dateTime: Date @State private var cuisine: String @State private var address: String @State private var phone: String @State private var website: String @State private var hours: String @State private var neighborhood: String @State private var confirmationLink: String @State private var isTargeted = false @State private var selectedPhoto: PhotosPickerItem? @State private var imageData: Data? @State private var imageChanged = false // true when user has replaced the image this session @State private var imageURL = "" @State private var isLoadingImage = false @State private var isSearchingImage = false @State private var cachedImageURLs: [String] = [] @State private var showImageGridPicker = false @State private var showFullscreenImage = false @State private var rating: Int @State private var beenThere: Bool @State private var starRating: Int @State private var remindersEnabled: Bool @State private var notes: String @State private var showDatePicker: Bool @State private var consider: Bool @State private var savedDateTime: Date @State private var calendarStatus: CalendarStatus = .notAdded @State private var showingCalendarAlert = false @State private var calendarAlertMessage = "" enum CalendarStatus { case notAdded, added } init(entry: Binding<SpotEntry>, onSave: (() -> Void)? = nil, onAddressChanged: ((UUID) -> Void)? = nil, isImported: Bool = false) { self.onSave = onSave self.onAddressChanged = onAddressChanged self.isImported = isImported _entry = entry _playName = State(initialValue: entry.wrappedValue.playName) _dateTime = State(initialValue: entry.wrappedValue.dateTime) _cuisine = State(initialValue: entry.wrappedValue.cuisine) _address = State(initialValue: entry.wrappedValue.address) _phone = State(initialValue: entry.wrappedValue.phone) _website = State(initialValue: entry.wrappedValue.website) _hours = State(initialValue: entry.wrappedValue.hours) _neighborhood = State(initialValue: entry.wrappedValue.neighborhood) _confirmationLink = State(initialValue: entry.wrappedValue.confirmationLink) _imageData = State(initialValue: entry.wrappedValue.imageData) _rating = State(initialValue: entry.wrappedValue.rating) _beenThere = State(initialValue: entry.wrappedValue.beenThere) _starRating = State(initialValue: entry.wrappedValue.starRating) _remindersEnabled = State(initialValue: entry.wrappedValue.remindersEnabled) _notes = State(initialValue: entry.wrappedValue.notes) _showDatePicker = State(initialValue: entry.wrappedValue.hasCustomDate) _consider = State(initialValue: entry.wrappedValue.consider) _savedDateTime = State(initialValue: entry.wrappedValue.dateTime) } @ViewBuilder var imageSection: some View { Section(header: Text("Image").font(.system(size: 12))) { if isSearchingImage { HStack(spacing: 8) { ProgressView().scaleEffect(0.7) Text("Searching for image…") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } Group { if isLoadingImage { ProgressView().frame(width: 160, height: 235) } else if let data = imageData, let uiImage = UIImage(data: data) { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 160, height: 235) .clipped() .cornerRadius(6) .onTapGesture { showFullscreenImage = true } } else { Rectangle() .fill(Color(.systemGray5)) .frame(width: 160, height: 235) .cornerRadius(6) } } .frame(maxWidth: .infinity, alignment: .center) .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8)) Button { showImageGridPicker = true } label: { HStack(spacing: 6) { Image(systemName: "photo.stack").font(.system(size: 15)) Text("Load from Web").font(.system(size: 15)) } } .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) PhotosPicker(selection: $selectedPhoto, matching: .images) { HStack(spacing: 6) { Image(systemName: "photo").font(.system(size: 15)) Text("Load from Photos").font(.system(size: 15)) } } .onChange(of: selectedPhoto) { _, newValue in Task { if let data = try? await newValue?.loadTransferable(type: Data.self) { imageData = data imageChanged = true } } } .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) HStack { if isLoadingImage { ProgressView().scaleEffect(0.7) } else { Button { guard !imageURL.isEmpty else { return } loadImageFromURL() } label: { HStack(spacing: 4) { Image(systemName: "link").font(.system(size: 13)) Text("Load from URL").font(.system(size: 14)) } } } TextField("URL", text: $imageURL) .font(.system(size: 15)) .textInputAutocapitalization(.never) .autocorrectionDisabled() } .listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16)) } } var body: some View { NavigationStack { Form { if entry.entryMode == .show || entry.entryMode == .event { Section(header: Text(entry.entryMode == .show ? "Show Information" : "Event Information").font(.system(size: 12))) { LabeledContent("Show") { TextField("Name", text: $playName) .font(.system(size: 15)) .multilineTextAlignment(.trailing) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Date & Time") { DatePicker("", selection: $dateTime, displayedComponents: [.date, .hourAndMinute]) .labelsHidden() .opacity(consider ? 0 : 1) .disabled(consider) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Venue") { TextField("", text: $cuisine) .font(.system(size: 15)) .multilineTextAlignment(.trailing) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Address") { TextField("", text: $address) .font(.system(size: 15)) .multilineTextAlignment(.trailing) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Phone") { TextField("", text: $phone) .font(.system(size: 15)) .multilineTextAlignment(.trailing) .keyboardType(.phonePad) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Website") { TextField("", text: $website) .font(.system(size: 15)) .multilineTextAlignment(.trailing) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Row / Seat") { TextField("", text: $neighborhood) .font(.system(size: 15)) .multilineTextAlignment(.trailing) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) HStack { Button { if !consider { savedDateTime = dateTime var c = Calendar.current.dateComponents([.year, .month, .day], from: Date()) c.hour = 19; c.minute = 0 dateTime = Calendar.current.date(from: c) ?? Date() } else { dateTime = savedDateTime } consider.toggle() } label: { Text("Consider") .font(.system(size: 14)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(consider ? Color.orange : Color(.systemGray5)) .foregroundColor(consider ? .white : .primary) .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.borderless) Spacer() HStack(spacing: 4) { ForEach(1...5, id: \.self) { star in Button { starRating = (starRating == star) ? 0 : star } label: { Image(systemName: star <= starRating ? "star.fill" : "star") .font(.system(size: 18)) .foregroundColor(star <= starRating ? .yellow : .gray.opacity(0.4)) } .buttonStyle(.borderless) } } .id(starRating) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } else { Section(header: Text(entry.entryMode == .food ? String(localized: "Restaurant Information") : entry.entryMode == .shop ? String(localized: "Shop Information") : String(localized: "Place Information")).font(.system(size: 12))) { TextField("Name", text: $playName) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField(entry.entryMode == .food ? String(localized: "Cuisine") : String(localized: "Category"), text: $cuisine) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField("Neighborhood", text: $neighborhood) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField("Address", text: $address) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField("Phone", text: $phone) .font(.system(size: 15)) .keyboardType(.phonePad) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField("Website", text: $website) .font(.system(size: 15)) .keyboardType(.URL) .textInputAutocapitalization(.never) .autocorrectionDisabled() .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) TextField("Hours", text: $hours) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) LabeledContent("Priority") { HStack(spacing: 4) { ForEach(1...5, id: \.self) { dot in Button { rating = (rating == dot) ? dot - 1 : dot } label: { Image(systemName: dot <= rating ? "circle.fill" : "circle") .font(.system(size: 14)) .foregroundColor(dot <= rating ? .primary : .gray.opacity(0.4)) } .buttonStyle(.plain) } } } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) HStack { Text("Been").font(.system(size: 15)) Spacer() Toggle("", isOn: $beenThere).labelsHidden() } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) if beenThere { HStack { Text("Rating").font(.system(size: 15)) Spacer() HStack(spacing: 4) { ForEach(1...5, id: \.self) { star in Button { starRating = (starRating == star) ? star - 1 : star } label: { Image(systemName: star <= starRating ? "star.fill" : "star") .font(.system(size: 14)) .foregroundColor(star <= starRating ? .yellow : .gray.opacity(0.4)) } .buttonStyle(.borderless) } } .id(starRating) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } } imageSection Section(header: Text("Calendar").font(.system(size: 12))) { if entry.entryMode != .show && entry.entryMode != .event { if showDatePicker { DatePicker("Date & Time", selection: $dateTime, displayedComponents: [.date, .hourAndMinute]) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } else { Button(action: { showDatePicker = true }) { HStack { Image(systemName: "calendar.badge.plus") .font(.system(size: 14)) .foregroundColor(.blue) Text("Add to Calendar") .font(.system(size: 15)) .foregroundColor(.blue) Spacer() } } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } if ((entry.entryMode == .show || entry.entryMode == .event) && !consider) || (entry.entryMode != .show && entry.entryMode != .event && showDatePicker) { Button(action: { addToCalendar() }) { HStack { Image(systemName: calendarStatus == .added ? "checkmark.circle.fill" : "calendar.badge.plus") .font(.system(size: 14)) .foregroundColor(calendarStatus == .added ? .green : .blue) Text(calendarStatus == .added ? "Added to Calendar" : "Add to Calendar") .font(.system(size: 15)) Spacer() if calendarStatus != .added { Image(systemName: "plus.circle") .font(.system(size: 14)) .foregroundColor(.blue) } } } .disabled(calendarStatus == .added) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } Section(header: Text("More Info").font(.system(size: 12))) { TextField("More Info Link", text: $confirmationLink) .font(.system(size: 15)) .textInputAutocapitalization(.never) .background(isTargeted ? Color.blue.opacity(0.1) : Color.clear) .onDrop(of: [.url, .text], isTargeted: $isTargeted) { providers in handleDrop(providers: providers) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } Section(header: Text("Reminders").font(.system(size: 12))) { Toggle(isOn: $remindersEnabled) { HStack { Image(systemName: "bell.fill") .font(.system(size: 14)) .foregroundColor(remindersEnabled ? .orange : .gray) Text("Enable Reminders") .font(.system(size: 15)) } } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) if remindersEnabled { Text("Reminders: 1 week, 1 day, 6 hours, 1 hour before.") .font(.system(size: 13)) .foregroundColor(.secondary) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } Section(header: Text("Notes").font(.system(size: 12))) { TextField("Add notes...", text: $notes, axis: .vertical) .font(.system(size: 15)) .lineLimit(3...6) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } Section { LabeledContent("Tab") { Picker("", selection: $entry.entryMode) { Text("Food").tag(EntryMode.food) Text("Shows").tag(EntryMode.show) Text("Sites").tag(EntryMode.place) Text("Shops").tag(EntryMode.shop) Text("Events").tag(EntryMode.event) } .pickerStyle(.menu) } .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } } .listSectionSpacing(4) .environment(\.defaultMinListRowHeight, 0) .navigationTitle("Edit") .navigationBarTitleDisplayMode(.inline) .sheet(isPresented: $showImageGridPicker) { ImageGridPickerSheet( initialURLs: cachedImageURLs, searchName: playName, searchYear: Calendar.current.component(.year, from: dateTime), searchCuisine: cuisine ) { data in imageData = data imageChanged = true } } .fullScreenCover(isPresented: $showFullscreenImage) { if let data = imageData, let uiImage = UIImage(data: data) { ZStack(alignment: .topTrailing) { Color.black.ignoresSafeArea() Image(uiImage: uiImage) .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) Button { showFullscreenImage = false } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 30)) .foregroundStyle(.white, .black.opacity(0.5)) .padding(16) } } } } .task { // Lazy-load the existing image if it wasn't populated at startup if imageData == nil, let filename = entry.imageFilename, !filename.isEmpty { imageData = EntryStore.loadImageData(filename: filename) } // Auto-search for images whenever an entry is opened for editing if !playName.isEmpty { await searchForShowImage() } } .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .font(.system(size: 16)) } ToolbarItem(placement: .confirmationAction) { Button("Save") { // Dismiss keyboard so any active field commits its value first. UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) entry.playName = playName entry.dateTime = dateTime entry.hasCustomDate = (entry.entryMode == .show || entry.entryMode == .event) ? !consider : showDatePicker entry.consider = consider entry.cuisine = cuisine // Normalise state abbreviation locally (no network needed) let normalizedAddress = EntryStore.normalizeStateAbbreviation(in: address) ?? address let addressChanged = entry.address != normalizedAddress entry.address = normalizedAddress // Clear stored coordinates if address changed so they get re-geocoded if addressChanged { entry.latitude = nil entry.longitude = nil onAddressChanged?(entry.id) } entry.phone = phone entry.website = website entry.hours = hours entry.neighborhood = neighborhood entry.rating = rating entry.beenThere = beenThere entry.starRating = (entry.entryMode == .show || entry.entryMode == .event) ? starRating : (beenThere ? starRating : 0) entry.confirmationLink = SpotEntry.fixMessageLink(confirmationLink) entry.imageData = imageData.map { EntryStore.resizedImageData($0, maxDimension: 1200) } if imageData != nil { if imageChanged || entry.imageFilename == nil || entry.imageFilename!.isEmpty { entry.imageFilename = "\(UUID().uuidString).jpg" } } else { entry.imageFilename = nil } entry.remindersEnabled = remindersEnabled entry.notes = notes if remindersEnabled { NotificationManager.shared.scheduleReminders(for: entry) } else { NotificationManager.shared.cancelReminders(for: entry) } // On the first save of an imported Theater entry, automatically // add it to the calendar so the user doesn't have to do it manually. // isImported is true only when the entry arrived via the share extension. if isImported && (entry.entryMode == .show || entry.entryMode == .event) && !consider { addToCalendar() } onSave?() dismiss() } .font(.system(size: 16)) .disabled(playName.isEmpty) } } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } } } private func addToCalendar() { Task { var snapshot = entry snapshot.playName = playName snapshot.dateTime = dateTime let result = await CalendarManager.shared.addEntryToCalendar(snapshot) await MainActor.run { switch result { case .success: calendarStatus = .added calendarAlertMessage = String(format: String(localized: "\"%@\" has been added to your calendar."), playName) showingCalendarAlert = true case .failure(let error): calendarAlertMessage = error.localizedDescription showingCalendarAlert = true } } } } func handleDrop(providers: [NSItemProvider]) -> Bool { guard let provider = providers.first else { return false } if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, error in if let data = item as? Data, let url = URL(dataRepresentation: data, relativeTo: nil) { DispatchQueue.main.async { confirmationLink = url.absoluteString } } } return true } else if provider.hasItemConformingToTypeIdentifier(UTType.text.identifier) { provider.loadItem(forTypeIdentifier: UTType.text.identifier, options: nil) { item, error in if let text = item as? String { DispatchQueue.main.async { confirmationLink = text } } } return true } return false } /// Download and validate an image from a URL string. Returns true on success. @discardableResult func loadImage(from urlString: String) async -> Bool { guard let url = URL(string: urlString) else { return false } isLoadingImage = true defer { isLoadingImage = false } do { let (data, _) = try await URLSession.shared.data(from: url) guard UIImage(data: data) != nil else { print("⚠️ URL did not return a valid image: \(urlString.prefix(80))") return false } imageData = data imageURL = urlString imageChanged = true return true } catch { print("⚠️ Image download failed: \(error.localizedDescription)") return false } } /// Wrapper used by the manual "Load" button in the Image URL field. func loadImageFromURL() { Task { await loadImage(from: imageURL) } } func searchForShowImage() async { isSearchingImage = true defer { isSearchingImage = false } // One Google call — cache up to 20 URLs for the grid picker let year = Calendar.current.component(.year, from: dateTime) let urls = await ImageSearchManager.searchImageURLs(for: playName, year: year, cuisine: cuisine, maxResults: 20) cachedImageURLs = urls // Fallback: extract an image from the .eml confirmation email if imageData == nil && confirmationLink.hasSuffix(".eml") && !confirmationLink.hasPrefix("http") { if let extracted = await EmlParser.extractFirstImage(from: confirmationLink) { imageData = extracted } } } }`EditEntryView` structDefines the `EditEntryView` struct. Conforms to View.
▶ IMAGE GRID PICKER SHEET (LOAD FROM WEB)
struct ImageGridPickerSheet: View { let searchName: String let searchYear: Int let searchCuisine: String let onSelect: (Data) -> Void @State private var displayedURLs: [String] @State private var currentPage = 1 @State private var isLoadingMore = false @State private var hasMore = true @Environment(\.dismiss) private var dismiss private let columns = [GridItem(.adaptive(minimum: 100), spacing: 8)] init(initialURLs: [String], searchName: String, searchYear: Int, searchCuisine: String, onSelect: @escaping (Data) -> Void) { self.searchName = searchName self.searchYear = searchYear self.searchCuisine = searchCuisine self.onSelect = onSelect _displayedURLs = State(initialValue: initialURLs) } var body: some View { NavigationView { ScrollView { LazyVGrid(columns: columns, spacing: 8) { ForEach(Array(displayedURLs.enumerated()), id: \.offset) { _, urlString in AsyncImage(url: URL(string: urlString)) { phase in switch phase { case .success(let image): image .resizable() .scaledToFill() .frame(width: 100, height: 148) .clipped() .cornerRadius(6) .onTapGesture { Task { guard let url = URL(string: urlString), let (data, _) = try? await URLSession.shared.data(from: url), UIImage(data: data) != nil else { return } onSelect(data) dismiss() } } case .failure: Rectangle() .fill(Color(.systemGray5)) .frame(width: 100, height: 148) .cornerRadius(6) .overlay( Image(systemName: "photo.slash") .foregroundColor(.gray) ) default: Rectangle() .fill(Color(.systemGray6)) .frame(width: 100, height: 148) .cornerRadius(6) .overlay(ProgressView()) } } } } .padding() if hasMore { Button { guard !isLoadingMore else { return } isLoadingMore = true Task { let nextPage = currentPage + 1 let newURLs = await ImageSearchManager.searchImageURLs( for: searchName, year: searchYear, cuisine: searchCuisine, maxResults: 20, page: nextPage) if newURLs.isEmpty { hasMore = false } else { displayedURLs.append(contentsOf: newURLs) currentPage = nextPage } isLoadingMore = false } } label: { if isLoadingMore { ProgressView() .frame(maxWidth: .infinity) .padding() } else { Text("More…") .font(.system(size: 15)) .frame(maxWidth: .infinity) .padding() } } .disabled(isLoadingMore) } } .navigationTitle("Choose Image") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } }`ImageGridPickerSheet` structDefines the `ImageGridPickerSheet` struct. Conforms to View.