| 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` struct | Defines the `EditEntryView` struct. Conforms to View. |