← Back to index

EntryDetailView

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import SwiftUI import EventKit import MapKit import QuickLook import ImageIOFramework importsImports SwiftUI, EventKit, MapKit, QuickLook, ImageIO.
struct EntryDetailView: View { @Binding var entry: SpotEntry var onAddressChanged: ((UUID) -> Void)? = nil var onOpenInAppMap: ((String, UUID) -> Void)? = nil // opens address in the app's map; passes entry ID to auto-open bubble var onOpenSubwayMap: ((String) -> Void)? = nil // opens map with subway layer on var onOpenBusMap: ((String) -> Void)? = nil // opens map with bus layer on, subway off @Environment(\.openURL) var openURL @State private var calendarStatus: CalendarStatus = .notAdded @State private var showingCalendarAlert = false @State private var calendarAlertMessage = "" @State private var showDatePicker: Bool @State private var rating: Int @State private var beenThere: Bool @State private var starRating: Int @State private var activeSheet: ActiveSheet? @State private var detailImage: UIImage? @State private var showFullscreenImage = false @State private var docInteractionController: UIDocumentInteractionController? @State private var isFetchingPhone = false @State private var isFetchingWebsite = false @State private var isFetchingReservation = false @State private var isFetchingIBDB = false @State private var isFetchingSpectra = false @State private var nearbyBusRoutes: [String] = [] @State private var busRoutesLoaded = false @State private var nearbySubwayLines: [String] = [] @State private var subwayLinesLoaded = false private static let detailDateFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "EEE, MMM d · h:mm a" return f }() enum CalendarStatus { case notAdded, added, checking } enum ActiveSheet: Identifiable { case edit case eml(URL) case pdf(URL) var id: String { switch self { case .edit: return "edit" case .eml(let url): return "eml:\(url.path)" case .pdf(let url): return "pdf:\(url.path)" } } } init(entry: Binding<SpotEntry>, onAddressChanged: ((UUID) -> Void)? = nil, onOpenInAppMap: ((String, UUID) -> Void)? = nil, onOpenSubwayMap: ((String) -> Void)? = nil, onOpenBusMap: ((String) -> Void)? = nil) { _entry = entry self.onAddressChanged = onAddressChanged self.onOpenInAppMap = onOpenInAppMap self.onOpenSubwayMap = onOpenSubwayMap self.onOpenBusMap = onOpenBusMap _showDatePicker = State(initialValue: entry.wrappedValue.hasCustomDate) _rating = State(initialValue: entry.wrappedValue.rating) _beenThere = State(initialValue: entry.wrappedValue.beenThere) _starRating = State(initialValue: entry.wrappedValue.starRating) } @ViewBuilder private var infoSection: some View { if entry.entryMode == .show || entry.entryMode == .event { Section(header: Text(entry.entryMode == .show ? "Show Information" : "Event Information").font(.system(size: 12))) { LabeledContent { Text(entry.playName).font(.system(size: 15, weight: .bold)).foregroundColor(.primary).multilineTextAlignment(.trailing) } label: { Text("Show").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if !entry.consider { Button(action: { let interval = Int(entry.dateTime.timeIntervalSince1970) - 978307200 if let url = URL(string: "calshow:\(interval)") { openURL(url) } }) { Text(Self.detailDateFormatter.string(from: entry.dateTime)) .font(.system(size: 15)) .foregroundColor(.blue) .multilineTextAlignment(.trailing) } .buttonStyle(.plain) } } label: { Text("Date & Time").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if !entry.cuisine.isEmpty { mapMenu(label: entry.cuisine, query: entry.address.isEmpty ? entry.cuisine : entry.address) } } label: { Text("Venue").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if entry.address.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { mapMenu(label: entry.address, query: entry.address) } } label: { Text("Address").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if isFetchingPhone { ProgressView().scaleEffect(0.7) } else if entry.phone.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { Button(action: { callPhone(entry.phone) }) { Text(entry.phone).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing) } .buttonStyle(.plain) } } label: { Text("Phone").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if isFetchingWebsite { ProgressView().scaleEffect(0.7) } else if entry.website.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { Button(action: { openWebsite(entry.website) }) { Text(entry.website).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing).lineLimit(1) } .buttonStyle(.plain) } } label: { Text("Website").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) if entry.entryMode == .food { LabeledContent { reservationButton } label: { Text("Reserve").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } LabeledContent { Text(entry.neighborhood.isEmpty ? "—" : entry.neighborhood) .font(.system(size: 15)) .foregroundColor(entry.neighborhood.isEmpty ? .secondary : .primary) .multilineTextAlignment(.trailing) } label: { Text("Row/Seat").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) HStack { Button { entry.consider.toggle() entry.hasCustomDate = !entry.consider } label: { Text("Consider") .font(.system(size: 14)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(entry.consider ? Color.orange : Color(.systemGray5)) .foregroundColor(entry.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: 6, leading: 16, bottom: 6, trailing: 16)) } } else { Section(header: Text(entry.entryMode == .food ? String(localized: "Restaurant Information") : String(localized: "Place Information")).font(.system(size: 12))) { LabeledContent { Text(entry.playName).font(.system(size: 15, weight: .bold)).foregroundColor(.primary).multilineTextAlignment(.trailing) } label: { Text("Spot").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if entry.address.isEmpty && !entry.cuisine.isEmpty { mapMenu(label: entry.cuisine, query: entry.cuisine) } else { Text(entry.cuisine).font(.system(size: 15)).foregroundColor(.primary) } } label: { Text("Type").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { Text(entry.neighborhood.isEmpty ? "—" : entry.neighborhood) .font(.system(size: 15)) .foregroundColor(entry.neighborhood.isEmpty ? .secondary : .primary) .multilineTextAlignment(.trailing) } label: { Text("Neighborhood").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if entry.address.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { mapMenu(label: entry.address, query: entry.address) } } label: { Text("Address").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if isFetchingPhone { ProgressView().scaleEffect(0.7) } else if entry.phone.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { Button(action: { callPhone(entry.phone) }) { Text(entry.phone).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing) }.buttonStyle(.plain) } } label: { Text("Phone").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) LabeledContent { if isFetchingWebsite { ProgressView().scaleEffect(0.7) } else if entry.website.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing) } else { Button(action: { openWebsite(entry.website) }) { Text(entry.website).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing).lineLimit(1) }.buttonStyle(.plain) } } label: { Text("Website").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) if entry.entryMode == .food { LabeledContent { reservationButton } label: { Text("Reserve").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } LabeledContent { Text(entry.hours.isEmpty ? "—" : entry.hours) .font(.system(size: 15)) .foregroundColor(entry.hours.isEmpty ? .secondary : .primary) .multilineTextAlignment(.trailing) } label: { Text("Hours").font(.system(size: 12)).foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) HStack { Text("Priority").font(.system(size: 12)).foregroundColor(.secondary) Spacer() 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(.borderless) } }.id(rating) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) HStack { Text("Been").font(.system(size: 12)).foregroundColor(.secondary) Spacer() Toggle("", isOn: $beenThere).labelsHidden() } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) if beenThere { HStack { Text("Rating").font(.system(size: 12)).foregroundColor(.secondary) 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: 6, leading: 16, bottom: 6, trailing: 16)) } } } } @ViewBuilder private var reservationButton: some View { if isFetchingReservation { ProgressView().scaleEffect(0.7) } else if entry.reservation.isEmpty { Text("—").font(.system(size: 15)).foregroundColor(.secondary) } else { let platform = ReservationService.platform(for: entry.reservation) Button(action: { if let url = URL(string: entry.reservation) { openURL(url) } }) { Text(platform == .resy ? "Resy" : "OpenTable") .font(.system(size: 13, weight: .semibold)) .foregroundColor(.white) .padding(.horizontal, 12) .padding(.vertical, 5) .background(platform == .resy ? Color(red: 0.89, green: 0.17, blue: 0.17) : Color(red: 0.85, green: 0.22, blue: 0.25)) .clipShape(RoundedRectangle(cornerRadius: 7)) } .buttonStyle(.plain) } } @ViewBuilder private var imageSection: some View { Section(header: Text("Image").font(.system(size: 12))) { if let uiImage = detailImage { HStack { Spacer() Image(uiImage: uiImage).resizable().scaledToFill() .frame(width: 120, height: 185).clipped().cornerRadius(6) .onTapGesture { showFullscreenImage = true } Spacer() } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } else { Text("No image").font(.system(size: 15)).foregroundColor(.secondary) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } @ViewBuilder private var moreInfoSection: some View { Section(header: Text("More Info").font(.system(size: 12))) { if !entry.confirmationLink.isEmpty { if isLocalAttachment(entry.confirmationLink) { Button(action: { openAttachment(entry.confirmationLink) }) { HStack { Image(systemName: attachmentIcon(for: entry.confirmationLink)).font(.system(size: 14)) Text(attachmentLabel(for: entry.confirmationLink)).font(.system(size: 15)) Spacer() Image(systemName: "arrow.up.right.square").font(.system(size: 12)) } } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } else { Button(action: { if let url = URL(string: entry.confirmationLink) { openURL(url) } }) { HStack { Text("View More Info").font(.system(size: 15)) Spacer() Image(systemName: "arrow.up.right.square").font(.system(size: 12)) } } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } else { Text("No confirmation link").font(.system(size: 15)).foregroundColor(.secondary) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } if entry.entryMode == .show || entry.entryMode == .event { HStack(spacing: 16) { Text("Info").font(.system(size: 12)).foregroundColor(.secondary) Button { guard !entry.playName.isEmpty, !isFetchingIBDB else { return } isFetchingIBDB = true Task { defer { isFetchingIBDB = false } if let url = await IBDBService.shared.findShowURL(named: entry.playName) { openWebsite(url.absoluteString) } } } label: { if isFetchingIBDB { ProgressView().scaleEffect(0.7) } else { Text("IBDB") } } .buttonStyle(.plain) .foregroundColor(entry.playName.isEmpty ? .secondary : .blue) .disabled(entry.playName.isEmpty || isFetchingIBDB) Button { guard !entry.playName.isEmpty, !isFetchingSpectra else { return } isFetchingSpectra = true Task { defer { isFetchingSpectra = false } if let url = await SpectraService.shared.findShowURL(named: entry.playName) { openWebsite(url.absoluteString) } } } label: { if isFetchingSpectra { ProgressView().scaleEffect(0.7) } else { Text("Spectra") } } .buttonStyle(.plain) .foregroundColor(entry.playName.isEmpty ? .secondary : .blue) .disabled(entry.playName.isEmpty || isFetchingSpectra) Spacer() } .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } if entry.entryMode == .food { HStack(spacing: 16) { Text("Reviews").font(.system(size: 12)).foregroundColor(.secondary) Button("Yelp") { openWebsite(yelpSearchURL) } .buttonStyle(.plain) .foregroundColor(entry.playName.isEmpty ? .secondary : .blue) .disabled(entry.playName.isEmpty) Button("Eater NY") { openWebsite(eaterNYSearchURL) } .buttonStyle(.plain) .foregroundColor(entry.playName.isEmpty ? .secondary : .blue) .disabled(entry.playName.isEmpty) Button("The Infatuation") { openWebsite(infatuationSearchURL) } .buttonStyle(.plain) .foregroundColor(entry.playName.isEmpty ? .secondary : .blue) .disabled(entry.playName.isEmpty) Spacer() } .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } @ViewBuilder private var remindersCalendarNotesSection: some View { Section(header: Text("Reminders").font(.system(size: 12))) { HStack { Image(systemName: entry.remindersEnabled ? "bell.fill" : "bell.slash") .font(.system(size: 14)).foregroundColor(entry.remindersEnabled ? .orange : .gray) Text(entry.remindersEnabled ? "Reminders On" : "Reminders Off").font(.system(size: 15)) Spacer() if entry.remindersEnabled && entry.dateTime > Date() { Text("1wk • 1d • 6h • 1h").font(.system(size: 13)).foregroundColor(.secondary) } } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } Section(header: Text("Calendar").font(.system(size: 12))) { if entry.entryMode == .show || entry.entryMode == .event { if !entry.consider { 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: 6, leading: 16, bottom: 6, trailing: 16)) } else { Text("No date — marked as Consider") .font(.system(size: 15)).foregroundColor(.secondary) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } else { if showDatePicker { DatePicker("Date & Time", selection: $entry.dateTime, displayedComponents: [.date, .hourAndMinute]) .font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) 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: 6, leading: 16, bottom: 6, trailing: 16)) } else { Button(action: { showDatePicker = true; entry.hasCustomDate = 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: 6, leading: 16, bottom: 6, trailing: 16)) } } } Section(header: Text("Notes").font(.system(size: 12))) { if !entry.notes.isEmpty { if isLocalAttachment(entry.notes) { Button(action: { openAttachment(entry.notes) }) { HStack { Image(systemName: "envelope").font(.system(size: 14)).foregroundColor(.blue) Text("Open Email").font(.system(size: 15)).foregroundColor(.blue) Spacer() Image(systemName: "arrow.up.right.square").font(.system(size: 12)).foregroundColor(.blue) } } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } else if let url = extractURL(from: entry.notes) { Button(action: { openURL(url) }) { HStack { Text(entry.notes).font(.system(size: 15)).foregroundColor(.blue).lineLimit(3) Spacer() Image(systemName: "arrow.up.right.square").font(.system(size: 12)).foregroundColor(.blue) } } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } else { Text(entry.notes).font(.system(size: 15)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } else { Text("No notes").font(.system(size: 15)).foregroundColor(.secondary) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } @ViewBuilder private var nearbyBusesSection: some View { if busRoutesLoaded && !nearbyBusRoutes.isEmpty { Section(header: Text("Nearby Buses").font(.system(size: 12))) { let columns = [GridItem(.adaptive(minimum: 50), spacing: 6)] LazyVGrid(columns: columns, alignment: .leading, spacing: 6) { ForEach(nearbyBusRoutes, id: \.self) { route in Text(route) .font(.system(size: 11, weight: .bold)) .foregroundColor(.white) .padding(.horizontal, 6) .padding(.vertical, 3) .background(busRouteColor(for: route), in: RoundedRectangle(cornerRadius: 4)) } } .padding(.vertical, 4) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contentShape(Rectangle()) .onTapGesture { onOpenBusMap?(entry.address) } } } } @ViewBuilder private var nearbySubwaysSection: some View { if subwayLinesLoaded && !nearbySubwayLines.isEmpty { Section(header: Text("Nearby Subways").font(.system(size: 12))) { let columns = [GridItem(.adaptive(minimum: 32), spacing: 6)] LazyVGrid(columns: columns, alignment: .leading, spacing: 6) { ForEach(nearbySubwayLines, id: \.self) { line in Text(line) .font(.system(size: 11, weight: .bold)) .foregroundColor(subwayLineTextColor(for: line)) .frame(minWidth: 24, minHeight: 24) .padding(.horizontal, 4) .background(subwayLineColor(for: line), in: Circle()) } } .padding(.vertical, 4) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contentShape(Rectangle()) .onTapGesture { onOpenSubwayMap?(entry.address) } } } } private var detailList: some View { List { infoSection imageSection moreInfoSection nearbySubwaysSection nearbyBusesSection remindersCalendarNotesSection } .listStyle(.insetGrouped) .listSectionSpacing(4) .environment(\.defaultMinListRowHeight, 0) .onChange(of: rating) { _, v in entry.rating = v } .onChange(of: entry.rating){ _, v in if rating != v { rating = v } } .onChange(of: beenThere) { _, v in entry.beenThere = v } .onChange(of: entry.beenThere){ _, v in if beenThere != v { beenThere = v } } .onChange(of: starRating) { _, v in entry.starRating = v } .onChange(of: entry.starRating){ _, v in if starRating != v { starRating = v } } .navigationTitle("Details") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Edit") { activeSheet = .edit }.font(.system(size: 16)) } } } var body: some View { detailList .sheet(item: $activeSheet) { sheet in switch sheet { case .edit: EditEntryView(entry: $entry, onAddressChanged: onAddressChanged) case .eml(let url): ShareSheet(activityItems: [url]) case .pdf(let url): QuickLookPreview(url: url) } }`EntryDetailView` structDefines the `EntryDetailView` struct. Conforms to View.
.fullScreenCover(isPresented: $showFullscreenImage) { if let uiImage = detailImage { 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) } } } } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } .task { copyAttachmentToICloudIfNeeded(entry.confirmationLink) } .task { // Silently back-fill phone and/or website if missing and a More Info link exists let needsPhone = entry.phone.isEmpty let needsWebsite = entry.website.isEmpty guard (needsPhone || needsWebsite), !entry.confirmationLink.isEmpty else { return } if needsPhone { isFetchingPhone = true } if needsWebsite { isFetchingWebsite = true } PhoneExtractor.extract(for: entry) { phone, website in if needsPhone { isFetchingPhone = false } if needsWebsite { isFetchingWebsite = false } if !phone.isEmpty { entry.phone = phone } if !website.isEmpty { entry.website = website } } } .task { // Back-fill reservation URL for existing food entries that don't have one yet. guard entry.entryMode == .food, entry.reservation.isEmpty else { return } isFetchingReservation = true if let resURL = await ReservationService.lookup(for: entry) { entry.reservation = resURL } isFetchingReservation = false } .task(id: entry.reservation) { // Once a reservation URL is known, fetch the restaurant description and // put it in Notes — but only if Notes are currently empty. guard entry.entryMode == .food, entry.notes.isEmpty, !entry.reservation.isEmpty else { return } if let desc = await ReservationService.fetchDescription(for: entry) { entry.notes = desc } } .task { // Load nearby bus routes from the bundled GTFS data. // Parses address via geocoder if no lat/lon is stored yet. guard !entry.address.isEmpty || entry.latitude != nil else { return } let svc = BusStopService.shared // Ensure the service data is loaded (it's a background load; spin-wait briefly) var attempts = 0 while !svc.isLoaded && attempts < 20 { // up to 2s try? await Task.sleep(nanoseconds: 100_000_000) attempts += 1 } guard svc.isLoaded else { return } let coordinate: CLLocationCoordinate2D if let lat = entry.latitude, let lon = entry.longitude { coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) } else if !entry.address.isEmpty { // Geocode the address let searchReq = MKLocalSearch.Request() searchReq.naturalLanguageQuery = entry.address guard let response = try? await MKLocalSearch(request: searchReq).start(), let item = response.mapItems.first else { return } coordinate = item.location.coordinate } else { return } let routes = svc.routes(near: coordinate, radiusMeters: 400) await MainActor.run { nearbyBusRoutes = routes busRoutesLoaded = true } } .task { // Load nearby subway lines. guard !entry.address.isEmpty || entry.latitude != nil else { return } let svc = SubwayStationService.shared var attempts = 0 while !svc.isLoaded && attempts < 40 { // up to 4s (subway data loads slower) try? await Task.sleep(nanoseconds: 100_000_000) attempts += 1 } guard svc.isLoaded else { return } let coordinate: CLLocationCoordinate2D if let lat = entry.latitude, let lon = entry.longitude { coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) } else if !entry.address.isEmpty { let searchReq = MKLocalSearch.Request() searchReq.naturalLanguageQuery = entry.address guard let response = try? await MKLocalSearch(request: searchReq).start(), let item = response.mapItems.first else { return } coordinate = item.location.coordinate } else { return } let lines = svc.lines(near: coordinate, radiusMeters: 400) await MainActor.run { nearbySubwayLines = lines subwayLinesLoaded = true } } .task(id: entry.imageFilename ?? "") { // Re-runs whenever imageFilename changes (e.g. after editing and saving a new image). // 130-pt display × 3× screen = 390 px; use 400 px for headroom. if let filename = entry.imageFilename, !filename.isEmpty { // Try disk first; fall back to in-memory imageData if the file hasn't been // written yet (race between Save dismiss and the async store.save() write). if let img = await EntryStore.loadThumbnail(filename: filename, maxPixelSize: 400) { detailImage = img } else if let data = entry.imageData { detailImage = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 400) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value } } else if let data = entry.imageData { detailImage = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 400) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value } } }Code blockSee source code for full implementation.
▶ HELPERS
private func isLocalAttachment(_ path: String) -> Bool { (path.hasSuffix(".eml") || path.hasSuffix(".pdf")) && !path.hasPrefix("http") && !path.hasPrefix("message:") } private func attachmentLabel(for path: String) -> String { path.hasSuffix(".pdf") ? String(localized: "View More Info (PDF)") : String(localized: "View More Info") } private func attachmentIcon(for path: String) -> String { path.hasSuffix(".pdf") ? "doc.richtext" : "envelope" } private func openAttachment(_ path: String) { let resolvedPath = EmlParser.resolveActualPath(for: path) ?? path let url = URL(fileURLWithPath: resolvedPath) if path.hasSuffix(".eml") { openEmlViaDocumentInteraction(url: url) } else { activeSheet = .pdf(url) // → QuickLookPreview } } // FUTURE: emlviewerpro://open?base64=<data>&filename=<name> does reach the app and // creates a document, but EML Viewer Pro renders the base64 string as raw content // rather than decoding it — the URL scheme API is undocumented. Revisit if they ever // publish docs. Sketch of the approach: // let base64 = data.base64EncodedString() // components.queryItems = [URLQueryItem(name: "base64", value: base64), // URLQueryItem(name: "filename", value: url.lastPathComponent)] // UIApplication.shared.open(schemeURL) { if !$0 { fallback to UDIC } } private func openEmlViaDocumentInteraction(url: URL) { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController else { activeSheet = .eml(url) return } let controller = UIDocumentInteractionController(url: url) docInteractionController = controller let presented = controller.presentOpenInMenu( from: CGRect(x: rootVC.view.bounds.midX, y: rootVC.view.bounds.midY, width: 1, height: 1), in: rootVC.view, animated: true ) if !presented { activeSheet = .eml(url) } } private func checkCalendarStatus() { calendarStatus = .checking calendarStatus = CalendarManager.shared.eventExists(for: entry) ? .added : .notAdded } private func addToCalendar() { Task { let result = await CalendarManager.shared.addEntryToCalendar(entry) await MainActor.run { switch result { case .success: calendarStatus = .added calendarAlertMessage = String(format: String(localized: "\"%@\" has been added to your calendar."), entry.playName) showingCalendarAlert = true case .failure(let error): calendarAlertMessage = error.localizedDescription showingCalendarAlert = true } } } } private func callPhone(_ number: String) { let digits = number.components(separatedBy: CharacterSet.decimalDigits.inverted).joined() if let url = URL(string: "tel://\(digits)") { openURL(url) } }`isLocalAttachment()` functionImplements `isLocalAttachment`. Returns `Bool`.
▶ COMPUTED SEARCH URLS (BUILT AT TAP TIME FROM THE ENTRY NAME)
private var encodedName: String { entry.playName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? entry.playName } private var yelpSearchURL: String { "https://www.yelp.com/search?find_desc=%22\(encodedName)%22&find_loc=New+York%2C+NY" } private var eaterNYSearchURL: String { "https://ny.eater.com/search?q=\(encodedName)" } private var infatuationSearchURL: String { "https://www.theinfatuation.com/finder?query=\(encodedName)&postType=POST_TYPE_UNSPECIFIED&canonicalPath=%2Fnew-york&geoBounds=41.04951461479453%2C-73.55284322553655%2C40.491392484127125%2C-74.28508412148558&location=New+York" } private func openWebsite(_ urlString: String) { var normalized = urlString if !normalized.hasPrefix("http://") && !normalized.hasPrefix("https://") { normalized = "https://" + normalized } if let url = URL(string: normalized) { openURL(url) } } // NOTE: kept for easy revert — called only when onOpenInAppMap is nil private func openInMaps(_ address: String) { let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? address if let url = URL(string: "https://maps.google.com/maps?q=\(encoded)") { openURL(url) } } /// Address tap: presents a small menu to open in Apple Maps, Google Maps, or the in-app Spots map. @ViewBuilder private func mapMenu(label: String, query: String) -> some View { let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query Menu { Button { if let url = URL(string: "maps://?q=\(encoded)") { openURL(url) } } label: { Label("Apple Maps", systemImage: "map.fill") } Button { if let url = URL(string: "comgooglemaps://?q=\(encoded)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else if let url = URL(string: "https://maps.google.com/maps?q=\(encoded)") { openURL(url) } } label: { Label("Google Maps", systemImage: "globe") } if let openInAppMap = onOpenInAppMap { Button { openInAppMap(query, entry.id) } label: { Label("Spots Map", systemImage: "mappin.circle.fill") } } } label: { Text(label) .font(.system(size: 15)) .foregroundColor(.blue) .multilineTextAlignment(.trailing) } .buttonStyle(.plain) } private func copyAttachmentToICloudIfNeeded(_ path: String) { guard isLocalAttachment(path) else { return } let filename = (path as NSString).lastPathComponent guard !filename.isEmpty else { return } DispatchQueue.global(qos: .utility).async { guard let icloudDocsURL = FileManager.default.url(forUbiquityContainerIdentifier: "iCloud.nkm.Spots")? .appendingPathComponent("Documents") else { return } let icloudDestURL = icloudDocsURL.appendingPathComponent(filename) // Already in iCloud — nothing to do if FileManager.default.fileExists(atPath: icloudDestURL.path) { return } // Find local copy guard let localDocsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } let localFileURL = localDocsURL.appendingPathComponent(filename) guard FileManager.default.fileExists(atPath: localFileURL.path) else { return } do { try FileManager.default.createDirectory(at: icloudDocsURL, withIntermediateDirectories: true) try FileManager.default.copyItem(at: localFileURL, to: icloudDestURL) print("✅ Copied local-only attachment to iCloud: \(filename)") } catch { print("❌ Failed to copy attachment to iCloud: \(error)") } } } // Created once and reused — NSDataDetector is expensive to instantiate private static let linkDetector: NSDataDetector? = try? NSDataDetector( types: NSTextCheckingResult.CheckingType.link.rawValue ) private func extractURL(from text: String) -> URL? { if text.hasPrefix("message:") { return URL(string: text.trimmingCharacters(in: .whitespacesAndNewlines)) } let matches = Self.linkDetector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) if let match = matches?.first, let range = Range(match.range, in: text) { return URL(string: String(text[range])) } return nil } }`encodedName` varProperty `encodedName`. Type: `String`.
▶ SHARE SHEET WRAPPER (EML)
struct ShareSheet: UIViewControllerRepresentable { let activityItems: [Any] func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: activityItems, applicationActivities: nil) } func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} }`ShareSheet` structDefines the `ShareSheet` struct. Conforms to UIViewControllerRepresentable.
▶ "OPEN IN" MENU WRAPPER (.EML)
// Shows only apps registered to handle the file type (e.g. EML Viewer). // To revert: change the .eml case in the sheet switch back to ShareSheet(activityItems: [url])Documentation commentDescribes the following declaration.
struct OpenInView: UIViewControllerRepresentable { let url: URL @Environment(\.dismiss) private var dismiss func makeCoordinator() -> Coordinator { Coordinator(dismiss: dismiss) } func makeUIViewController(context: Context) -> UIViewController { UIViewController() } func updateUIViewController(_ vc: UIViewController, context: Context) { guard !context.coordinator.presented else { return } context.coordinator.presented = true let dic = UIDocumentInteractionController(url: url) dic.delegate = context.coordinator context.coordinator.dic = dic DispatchQueue.main.async { let shown = dic.presentOpenInMenu(from: .zero, in: vc.view, animated: true) if !shown { // No apps registered for this type — fall back to options menu dic.presentOptionsMenu(from: .zero, in: vc.view, animated: true) } } } class Coordinator: NSObject, UIDocumentInteractionControllerDelegate { var presented = false var dic: UIDocumentInteractionController? let dismiss: DismissAction init(dismiss: DismissAction) { self.dismiss = dismiss } func documentInteractionControllerDidDismissOpenInMenu(_ controller: UIDocumentInteractionController) { dismiss() } func documentInteractionControllerDidDismissOptionsMenu(_ controller: UIDocumentInteractionController) { dismiss() } } }`OpenInView` structDefines the `OpenInView` struct. Conforms to UIViewControllerRepresentable.
▶ QUICK LOOK WRAPPER (PDF)
struct QuickLookPreview: UIViewControllerRepresentable { let url: URL func makeCoordinator() -> Coordinator { Coordinator(url: url) } func makeUIViewController(context: Context) -> QLPreviewController { let controller = QLPreviewController() controller.dataSource = context.coordinator return controller } func updateUIViewController(_ uiViewController: QLPreviewController, context: Context) {} class Coordinator: NSObject, QLPreviewControllerDataSource { let url: URL init(url: URL) { self.url = url } func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 } func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { url as NSURL } } }`QuickLookPreview` structDefines the `QuickLookPreview` struct. Conforms to UIViewControllerRepresentable.