← Back to index

ContentView

Spots
CodeWhat It DoesHow It Does It
▶ IMPORTS
import SwiftUI import UIKit import UserNotifications import EventKit import ContactsUI import CoreLocation import MapKit import ImageIOFramework importsImports SwiftUI, UIKit, UserNotifications, EventKit, ContactsUI, CoreLocation, MapKit, ImageIO.
enum SortOrder { case `default`, cuisine, neighborhood, distance, ratings }`SortOrder` enumDefines the `SortOrder` enum.
/// Top-level tab selection. Distinct from EntryMode so Maps can be a tab /// without being tied to a data-model entry category. enum MainTab: Hashable { case map, food, shows, sites, shops /// The EntryMode that corresponds to this tab, or nil for the Map tab. var entryMode: EntryMode? { switch self { case .map: return nil case .food: return .food case .shows: return .show case .sites: return .place case .shops: return .shop } } }Documentation commentDescribes the following declaration.
enum ContentSheet: Identifiable { case addEntry case editImportedEntry(UUID) var id: String { switch self { case .addEntry: return "addEntry" case .editImportedEntry(let uuid): return "editImported-\(uuid)" } } }`ContentSheet` enumDefines the `ContentSheet` enum. Conforms to Identifiable.
// Plain UILabel wrapper — bypasses SwiftUI's automatic bordered-button container // that UIKit applies to all ToolbarItem content in the navigation bar. private struct SpotsTitleLabel: UIViewRepresentable { func makeUIView(context: Context) -> UILabel { let label = UILabel() label.text = "Spots" label.font = .systemFont(ofSize: 17, weight: .semibold) label.textColor = UIColor(red: 0/255, green: 45/255, blue: 114/255, alpha: 1) label.setContentHuggingPriority(.required, for: .horizontal) label.setContentCompressionResistancePriority(.required, for: .horizontal) return label } func updateUIView(_ uiView: UILabel, context: Context) {} }Documentation commentDescribes the following declaration.
▶ CONTACT PICKER + EDITOR
/// Step 1: CNContactPickerViewController (out-of-process, no permission needed). /// On selection passes the contact identifier back via binding, then dismisses. private struct ContactPickerView: UIViewControllerRepresentable { @Binding var isPresented: Bool @Binding var contactToEdit: String? // set to identifier when user taps a contact func makeUIViewController(context: Context) -> CNContactPickerViewController { let vc = CNContactPickerViewController() vc.delegate = context.coordinator return vc } func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented, contactToEdit: $contactToEdit) } final class Coordinator: NSObject, CNContactPickerDelegate { @Binding var isPresented: Bool @Binding var contactToEdit: String? init(isPresented: Binding<Bool>, contactToEdit: Binding<String?>) { self._isPresented = isPresented self._contactToEdit = contactToEdit } func contactPickerDidCancel(_ picker: CNContactPickerViewController) { isPresented = false } func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) { // Dismiss picker first, then signal the editor sheet to open. isPresented = false contactToEdit = contact.identifier } } }Documentation commentDescribes the following declaration.
/// Step 2: CNContactViewController wrapped in UINavigationController so the /// nav bar (Done / Cancel) renders correctly. Runs in-process with full edit access. private struct ContactEditorView: UIViewControllerRepresentable { let contactID: String @Binding var isPresented: Bool func makeUIViewController(context: Context) -> UINavigationController { let store = CNContactStore() let keys = [CNContactViewController.descriptorForRequiredKeys()] guard let contact = try? store.unifiedContact(withIdentifier: contactID, keysToFetch: keys) else { return UINavigationController() } let vc = CNContactViewController(for: contact) vc.allowsEditing = true vc.allowsActions = true vc.delegate = context.coordinator return UINavigationController(rootViewController: vc) } func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} func makeCoordinator() -> Coordinator { Coordinator(isPresented: $isPresented) } final class Coordinator: NSObject, CNContactViewControllerDelegate { @Binding var isPresented: Bool init(isPresented: Binding<Bool>) { self._isPresented = isPresented } func contactViewController(_ vc: CNContactViewController, didCompleteWith contact: CNContact?) { isPresented = false } } }Documentation commentDescribes the following declaration.
struct ContentView: View { @StateObject private var store = EntryStore() @StateObject private var locationManager = LocationManager() @EnvironmentObject var sharedDataManager: SharedDataManager @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State private var activeContentSheet: ContentSheet? @State private var showingPendingReminders = false @State private var pendingReminders: [String] = [] @State private var showingCalendarAlert = false @State private var calendarAlertMessage = "" @State private var showingBulkDeleteAlert = false @State private var showingBulkLoadAlert = false @State private var showingIntegrityAlert = false @State private var iPhoneShowsGrid = false // view toggle: false = list, true = grid @State private var iPhoneEventsGrid = false // view toggle for Events tab @State private var eventsRatingsSort: Bool = false @State private var searchText = "" @State private var mapInitialSearch: String = "" /// Injected into the live Map tab to trigger a search without pushing a sub-screen. /// Set by openMapFromDetail; NearbyMapView consumes and resets it on mount. @State private var mapTabSearchQuery: String = "" @State private var mapInitialCenter: CLLocationCoordinate2D? @State private var mapInitialSpan: MKCoordinateSpan? @State private var mapInitialName: String? @State private var mapInitialTheaters: [TheaterAnnotation] = [] @State private var mapInitialShowSubway: Bool = true @State private var mapInitialShowBus: Bool = false @State private var iPhoneShowingRatings = false @State private var showingMap = true @State private var sortOrder: SortOrder = .default @State private var showsRatingsSort: Bool = false @State private var nearestFirst: Bool = false @State private var geocodedCoordinates: [UUID: CLLocationCoordinate2D] = [:] @State private var geocodingInProgress = false @State private var selectedTab: MainTab = .food @State private var lastContentTab: MainTab? = nil @State private var showingContactPicker = false @State private var contactToEdit: String? // contact identifier for editor sheet @FocusState private var filterFieldFocused: Bool`ContentView` structDefines the `ContentView` struct. Conforms to View.
▶ PDF EXPORT SELECTION
@State private var isSelectingForPDF = false @State private var pdfSelectedIDs = Set<UUID>() // showingPDFShare / pdfShareURL replaced by direct UIKit presentation (see generateAndSharePDF) @State private var mapTabFocusEntryID: UUID? // entry whose bubble should auto-open on the map tab var body: some View { sharedDataObservers }`isSelectingForPDF` varProperty `isSelectingForPDF`.
▶ MODE HELPERS
private var modeNavigationTitle: String { switch selectedTab { case .map: return String(localized: "Map") case .food: return String(localized: "Food") case .shows: return String(localized: "Shows") case .sites: return String(localized: "Sites") case .shops: return String(localized: "Shops") } } private var modeEmptyIcon: String { switch selectedTab { case .map: return "map" case .food: return "fork.knife" case .shows: return "theatermasks" case .sites: return "mappin" case .shops: return "bag" } } private var modeEmptyMessage: String { switch selectedTab { case .map: return "" case .food: return String(localized: "Add your first restaurant to get started") case .shows: return String(localized: "Add your first show to get started") case .sites: return String(localized: "Add your first site to get started") case .shops: return String(localized: "Add your first shop to get started") } } private var modeSearchPrompt: String { switch selectedTab { case .map: return String(localized: "Search") case .food: return String(localized: "Search food, cuisine, neighborhood") case .shows: return String(localized: "Search shows, venue, neighborhood") case .sites: return String(localized: "Search sites, category, neighborhood") case .shops: return String(localized: "Search shops, category, neighborhood") } } /// All entries belonging to the currently selected mode tab. /// Returns all entries for the Map tab (it uses the full store directly). var modeFilteredEntries: [SpotEntry] { guard let mode = selectedTab.entryMode else { return store.entries } return store.entries.filter { $0.entryMode == mode } } // Split into two @ViewBuilder properties so the type-checker handles each // half of the modifier chain independently (avoids "too complex" error). // DO NOT add modifiers to either layer — doing so causes silent codegen // errors that manifest as runtime stack overflows in unrelated views. @ViewBuilder private var sharedDataObservers: some View { storeObservers .onChange(of: sharedDataManager.pendingEntry?.name) { _, _ in createEntryFromSharedData() } .onChange(of: sharedDataManager.shouldImportSharedEntry) { _, shouldImport in if shouldImport { sharedDataManager.shouldImportSharedEntry = false Task { await store.load() importSharedEntry() } } } .onChange(of: sharedDataManager.pendingMapCenter) { _, coord in handlePendingMapCenter(coord) } .sheet(item: $activeContentSheet) { sheet in contentSheetView(for: sheet) } .sheet(isPresented: $showingContactPicker) { ContactPickerView(isPresented: $showingContactPicker, contactToEdit: $contactToEdit) } .sheet(isPresented: Binding( get: { contactToEdit != nil }, set: { if !$0 { contactToEdit = nil } } )) { if let id = contactToEdit { ContactEditorView(contactID: id, isPresented: Binding( get: { contactToEdit != nil }, set: { if !$0 { contactToEdit = nil } } )) } } // PDF share sheet is now presented directly via UIKit in generateAndSharePDF() } // AnyView erasure is applied per-tab-content (not around the whole TabView). // Wrapping each content in AnyView keeps the TupleView's generic parameter // shallow (ModifiedContent<AnyView,...> × 5) so the Swift runtime's TypeDecoder // never overflows the call stack. Wrapping the TabView itself in AnyView caused // SwiftUI to lose the TabView's identity and show incorrect tab content. private var spotsTabView: some View { TabView(selection: $selectedTab) { AnyView(mapTabContent) .tabItem { Label("Map", systemImage: "map") } .tag(MainTab.map) AnyView(shopsTabContent) .tabItem { Label("Shops", systemImage: "bag") } .tag(MainTab.shops) AnyView(Group { if horizontalSizeClass == .regular { iPadLayout } else { iPhoneLayout } }) .tabItem { Label("Sites", systemImage: "mappin") } .tag(MainTab.sites) AnyView(iPhoneShowsLayout) .tabItem { Label("Shows", systemImage: "theatermasks") } .tag(MainTab.shows) AnyView(Group { if horizontalSizeClass == .regular { iPadLayout } else { iPhoneLayout } }) .tabItem { Label("Food", systemImage: "fork.knife") } .tag(MainTab.food) } } @ViewBuilder private var storeObservers: some View { spotsTabView // Shared lifecycle modifiers — run regardless of layout .onAppear { // Set entriesProvider immediately so watch requests answered during // async load still capture the closure (store ref is stable). PhoneConnectivityManager.shared.entriesProvider = { store.entries } } .task { // Pre-warm subway lines on a background thread so the data is ready // by the time the user opens the map — avoids the first-open freeze. Task { await SubwayLinesService.shared.loadIfNeeded() } EntryStore.backupOnLaunch() // snapshot before anything can overwrite await store.load() // must finish before importSharedEntry() runs store.normalizeAllStateAbbreviations() // one-time migration, no-op once done store.startWatchingICloud() performVisitMigrationIfNeeded() // silent one-time Visit import (no-op once done) performVisitImageFixupIfNeeded() // wire images copied from VisitImages → SpotsImages performMindTheShowMigrationIfNeeded() // silent one-time MindTheShow import (no-op once done) performCoordinatePatchIfNeeded() // silently back-fills lat/lon for migrated entries store.migrateShopsFromSites() // one-time: move shop-category Sites entries → Shops tab store.backfillMissingWebsites() // one-time website back-fill (no-op once done) importSharedEntry() _ = await NotificationManager.shared.requestAuthorization() await NotificationManager.shared.scheduleRemindersIfNeeded(for: store.entries) // Push loaded entries to watch now that we have real data PhoneConnectivityManager.shared.sendEntries(store.entries, force: true) } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in // Sequence load then import — importSharedEntry must not run until // entries are fully loaded, or it would append to an empty array // and the subsequent save() would overwrite the real data. Task { await store.load() importSharedEntry() // Briefly restart the iCloud watcher to pick up any changes made on // another device while this app was in the background. It auto-stops // after 30 seconds so it doesn't accumulate memory while idle. store.startWatchingICloud() } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in print("⚠️ ContentView: iOS memory warning — releasing image data and subway data from store") store.clearImageData() // Subway data can be 10–15 MB. Release it on memory pressure; // NearbyMapView will reload on next open via loadIfNeeded(). SubwayLinesService.shared.clearData() SubwayStationService.shared.clearData() } .onChange(of: selectedTab) { lastContentTab = selectedTab // Cancel any in-progress PDF selection when switching tabs if isSelectingForPDF { isSelectingForPDF = false pdfSelectedIDs.removeAll() } } .onChange(of: store.entries) { store.save() PhoneConnectivityManager.shared.sendEntries(store.entries) } .onChange(of: store.blockedRemovalCount) { _, count in if count > 0 { showingBulkDeleteAlert = true } } .onChange(of: store.blockedLoadCount) { _, count in if count > 0 { showingBulkLoadAlert = true } } .onChange(of: store.showingIntegrityWarning) { _, warning in if warning { showingIntegrityAlert = true } } .onReceive(NotificationCenter.default.publisher(for: .watchRequestedEntries)) { _ in // Watch requested a fresh sync — force bypasses deduplication PhoneConnectivityManager.shared.sendEntries(store.entries, force: true) } .onReceive(NotificationCenter.default.publisher(for: .wcSessionActivated)) { _ in // WCSession just became active on iPhone — proactively push to Watch PhoneConnectivityManager.shared.sendEntries(store.entries, force: true) } } private func handlePendingMapCenter(_ coord: CLLocationCoordinate2D?) { guard let coord else { return } mapInitialCenter = coord mapInitialSpan = sharedDataManager.pendingMapSpan mapInitialName = sharedDataManager.pendingMapName mapInitialTheaters = sharedDataManager.pendingVenues.map { TheaterAnnotation(coordinate: CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon), name: $0.name) } mapInitialSearch = "" locationManager.requestLocation() startGeocoding() showingMap = true sharedDataManager.pendingMapCenter = nil sharedDataManager.pendingMapSpan = nil sharedDataManager.pendingMapName = nil sharedDataManager.pendingVenues = [] }`modeNavigationTitle` varProperty `modeNavigationTitle`. Type: `String`.
▶ MAPS TAB
private var mapTabContent: some View { NavigationStack { Group { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView( entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: "", initialCenter: nil, initialSpan: nil, initialName: nil, initialTheaters: [], initialEntryMode: lastContentTab.flatMap { $0.entryMode }, tabSearchQuery: $mapTabSearchQuery, tabFocusEntryID: $mapTabFocusEntryID ) .id(lastContentTab) } else { ContentUnavailableView( "Location Not Available", systemImage: "location.slash", description: Text("Enable location access to view the map") ) } } .navigationTitle("Maps") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { UIApplication.shared.open(URL(string: "calshow://")!) } label: { Image(systemName: "calendar") } } ToolbarItem(placement: .navigationBarLeading) { Button { showingContactPicker = true } label: { Image(systemName: "rectangle.stack.person.crop") } } ToolbarItem(placement: .navigationBarLeading) { Button { if let url = URL(string: "shoebox://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "creditcard.fill") } } ToolbarItem(placement: .navigationBarLeading) { Button { if let url = URL(string: "accuweather://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "cloud.sun.fill") } } ToolbarItem(placement: .navigationBarLeading) { Button { if let url = URL(string: "info.mta.mymta://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "tram.fill") } } } } .onAppear { locationManager.requestLocation() startGeocoding() } }`mapTabContent` varProperty `mapTabContent`. Type: `some`.
▶ IPHONE LAYOUT
private var iPhoneLayout: some View { NavigationStack { VStack(spacing: 0) { Group { if iPhoneShowsGrid { iPhoneGridContent } else { iPhoneListContent } } foodListMapControls } .alert("Pending Reminders", isPresented: $showingPendingReminders) { Button("OK", role: .cancel) { } } message: { Text(pendingReminders.isEmpty ? "No pending reminders" : pendingReminders.joined(separator: "\n\n")) } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } .alert("Multiple Entries Removed", isPresented: $showingBulkDeleteAlert) { Button("Allow Deletion", role: .destructive) { store.confirmBlockedSave() } Button("Keep All Entries", role: .cancel) { store.cancelBlockedSave() } } message: { Text("Something attempted to remove \(store.blockedRemovalCount) entries at once. This may be unintended. Do you want to allow the deletion, or keep all entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingBulkLoadAlert) { Button("Load Anyway", role: .destructive) { store.confirmBlockedLoad() } Button("Keep Current Entries", role: .cancel) { store.cancelBlockedLoad() } } message: { Text("The data just loaded from iCloud has \(store.blockedLoadCount) fewer entry(s) than what's currently in memory. This may mean the backup hasn't fully downloaded yet. Load the smaller set anyway, or keep your current entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingIntegrityAlert) { Button("Save Anyway", role: .destructive) { store.confirmIntegritySave() } Button("Keep Current Entries", role: .cancel) { store.cancelIntegritySave() } } message: { Text("Based on this session's adds and deletes, at least \(store.integrityExpectedCount) entries are expected, but only \(store.integrityActualCount) are about to be written. This may indicate data loss. Save anyway, or keep the current entries?") } } } private var iPhoneListContent: some View { List { iPhoneListRows } .listStyle(.plain) .environment(\.defaultMinListRowHeight, 0) .navigationTitle(modeNavigationTitle) .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { sharedToolbar } } // Map-style control panel shown at the bottom of the food/place list. // Search bar mirrors the nav-bar searchable field; buttons mirror toolbar actions. private var foodListMapControls: some View { VStack(spacing: 0) { Divider() // Row 1: MTA | Cal | Search bar | Location toggle | List/Grid toggle HStack(spacing: 0) { // Open MTA app to subway Button { UIApplication.shared.open(URL(string: "info.mta.mymta://subway")!) } label: { Image(systemName: "tram.fill") .font(.system(size: 13)) .foregroundStyle(Color.secondary) .frame(width: screenWidth / 8, height: 44) } Divider().frame(height: 44) // Open MTA app to bus Button { UIApplication.shared.open(URL(string: "info.mta.mymta://bus")!) } label: { Image(systemName: "bus.fill") .font(.system(size: 13)) .foregroundStyle(Color.secondary) .frame(width: screenWidth / 8, height: 44) } Divider().frame(height: 44) HStack(spacing: 6) { Image(systemName: "slider.horizontal.3") .font(.system(size: 13)) .foregroundColor(.primary) TextField("Filter by Name, Nearest, Neighborhood, etc.", text: $searchText) .font(.system(size: 13)) .focused($filterFieldFocused) .onSubmit { filterFieldFocused = false } .submitLabel(.done) .toolbar { ToolbarItemGroup(placement: .keyboard) { Spacer() Button("Done") { filterFieldFocused = false } } } if !searchText.isEmpty { Button { searchText = "" filterFieldFocused = false } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 15)) .foregroundColor(.secondary) } .buttonStyle(.borderless) } } .padding(.horizontal, 10) .padding(.vertical, 7) .background(Color(UIColor.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10)) .frame(maxWidth: .infinity) .padding(.horizontal, 12) .padding(.vertical, 8) Divider().frame(height: 44) // Nearest-first location sort toggle Button { nearestFirst.toggle() if nearestFirst { locationManager.requestLocation() startGeocoding() } } label: { Image(systemName: nearestFirst ? "location.fill" : "location") .font(.system(size: 13)) .foregroundStyle(nearestFirst ? Color.orange : Color.secondary) .frame(width: screenWidth / 8, height: 44) } Divider().frame(height: 44) // List / Grid view toggle Button { iPhoneShowsGrid.toggle() } label: { let isGrid = iPhoneShowsGrid Image(systemName: isGrid ? "list.bullet" : "square.grid.2x2") .font(.system(size: 13)) .foregroundStyle(Color.primary) .frame(width: screenWidth / 8, height: 44) } } .frame(height: 44) Divider() // Row 2: Sort options. // Shows tab: all labels are display-only except Ratings, which is a toggle. // Other tabs: radio-button sort selection as before. if selectedTab == .shows { showsSortRow } else { HStack(spacing: 0) { listSortButton("Default", isSelected: sortOrder == .default) { sortOrder = .default } Divider().frame(height: 34) listSortButton(selectedTab == .food ? "Cuisine" : "Type", isSelected: sortOrder == .cuisine) { sortOrder = .cuisine } Divider().frame(height: 34) listSortButton("Neighborhood", isSelected: sortOrder == .neighborhood) { sortOrder = .neighborhood } Divider().frame(height: 34) listSortButton("Nearest", isSelected: sortOrder == .distance) { sortOrder = .distance locationManager.requestLocation() startGeocoding() } Divider().frame(height: 34) listSortButton("Ratings", isSelected: sortOrder == .ratings) { sortOrder = .ratings } } } Divider() } .background(.bar) } /// A single cell in the bottom sort row — highlighted when selected. private func listSortButton(_ label: String, isSelected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 11)) .fontWeight(isSelected ? .semibold : .regular) .foregroundStyle(isSelected ? Color.primary : Color.secondary) .minimumScaleFactor(0.7) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 8) } } /// Sort row shown for the Shows/Theater tab. /// Default, Type, Neighborhood, Nearest are grey display labels (not tappable). /// Ratings is a toggle: when on, past shows are sorted by rating ↓ then date ↓. private var showsSortRow: some View { HStack(spacing: 0) { ForEach(["Default", "Type", "Neighborhood", "Nearest"], id: \.self) { label in Text(label) .font(.system(size: 11)) .foregroundStyle(Color.secondary) .minimumScaleFactor(0.7) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 8) Divider().frame(height: 34) } Button { showsRatingsSort.toggle() } label: { Text("Ratings") .font(.system(size: 11)) .fontWeight(showsRatingsSort ? .semibold : .regular) .foregroundStyle(showsRatingsSort ? Color.primary : Color.secondary) .minimumScaleFactor(0.7) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 8) } } } private var eventsSortRow: some View { HStack(spacing: 0) { ForEach(["Default", "Type", "Neighborhood", "Nearest"], id: \.self) { label in Text(label) .font(.system(size: 11)) .foregroundStyle(Color.secondary) .minimumScaleFactor(0.7) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 8) Divider().frame(height: 34) } Button { eventsRatingsSort.toggle() } label: { Text("Ratings") .font(.system(size: 11)) .fontWeight(eventsRatingsSort ? .semibold : .regular) .foregroundStyle(eventsRatingsSort ? Color.primary : Color.secondary) .minimumScaleFactor(0.7) .lineLimit(1) .frame(maxWidth: .infinity) .padding(.vertical, 8) } } }`iPhoneLayout` varProperty `iPhoneLayout`. Type: `some`.
▶ SELECTABLE ROW HELPERS (PDF EXPORT MODE)
/// Wraps a food-entry row: NavigationLink in normal mode, checkbox-toggle Button in PDF-select mode. @ViewBuilder private func listRow(entry: SpotEntry, index: Int, showRatingStars: Bool = false, showPriorityDots: Bool = false, distanceText: String? = nil) -> some View { if isSelectingForPDF { Button { if pdfSelectedIDs.contains(entry.id) { pdfSelectedIDs.remove(entry.id) } else { pdfSelectedIDs.insert(entry.id) } } label: { HStack(spacing: 12) { Image(systemName: pdfSelectedIDs.contains(entry.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(pdfSelectedIDs.contains(entry.id) ? Color.indigo : Color.secondary) .font(.system(size: 22)) .frame(width: 26) EntryRowView(entry: entry, linksEnabled: false, showBell: false, showRatingStars: showRatingStars, showPriorityDots: showPriorityDots, distanceText: distanceText) } } .buttonStyle(.plain) } else { NavigationLink(destination: detailDestination(for: index)) { EntryRowView(entry: entry, linksEnabled: false, showBell: false, showRatingStars: showRatingStars, showPriorityDots: showPriorityDots, distanceText: distanceText) } } } /// Same helper for show entries (uses ShowRowView). @ViewBuilder private func showsRow(entry: SpotEntry, index: Int, showBell: Bool = true) -> some View { if isSelectingForPDF { Button { if pdfSelectedIDs.contains(entry.id) { pdfSelectedIDs.remove(entry.id) } else { pdfSelectedIDs.insert(entry.id) } } label: { HStack(spacing: 12) { Image(systemName: pdfSelectedIDs.contains(entry.id) ? "checkmark.circle.fill" : "circle") .foregroundStyle(pdfSelectedIDs.contains(entry.id) ? Color.indigo : Color.secondary) .font(.system(size: 22)) .frame(width: 26) ShowRowView(entry: entry, showBell: showBell) } } .buttonStyle(.plain) } else { NavigationLink(destination: detailDestination(for: index)) { ShowRowView(entry: entry, showBell: showBell) } } } // Extracted from iPhoneListContent so the type-checker handles the List // body independently (avoids "expression too complex" error). @ViewBuilder private var iPhoneListRows: some View { if modeFilteredEntries.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: modeEmptyIcon, description: Text(modeEmptyMessage) ) } else if !searchText.isEmpty && !hasListEntries { ContentUnavailableView.search(text: searchText) } else if sortOrder == .default {Documentation commentDescribes the following declaration.
▶ DEFAULT: TO TRY + BEEN WITH PRIORITY/STAR GROUPING
if !toTryEntries.isEmpty { Section(header: bigSectionHeader("Try")) { } ForEach(toTryByPriority, id: \.priority) { group in Section(header: prioritySectionHeader(for: group.priority, count: group.entries.count)) { ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, distanceText: nearestFirst ? distanceText(for: store.entries[index]) : nil) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(group.entries, at: offsets) } } } } if !beenEntries.isEmpty { Section(header: bigSectionHeader("Been")) { } ForEach(beenByStarRating, id: \.starRating) { group in Section(header: starSectionHeader(for: group.starRating, count: group.entries.count)) { ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, distanceText: nearestFirst ? distanceText(for: store.entries[index]) : nil) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(group.entries, at: offsets) } } } } } else if sortOrder == .distance {Code blockSee source code for full implementation.
▶ DISTANCE: FLAT COMBINED LIST SORTED NEAREST FIRST
ForEach(distanceSortedEntries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, showRatingStars: true, showPriorityDots: true, distanceText: distanceText(for: entry)) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(distanceSortedEntries, at: offsets) } } else if sortOrder == .ratings {Code blockSee source code for full implementation.
▶ RATINGS: ALL ENTRIES GROUPED BY STAR RATING, HIGHEST FIRST
ForEach(ratingGroups, id: \.rating) { group in Section(header: starSectionHeader(for: group.rating, count: group.entries.count)) { ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, showRatingStars: true) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .opacity(!isSelectingForPDF && group.rating == 0 ? 0.4 : 1.0) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(group.entries, at: offsets) } } } } else {Code blockSee source code for full implementation.
▶ CUISINE / NEIGHBORHOOD: COMBINED LIST GROUPED BY SORT FIELD
if sortOrder == .neighborhood { // Two-level: borough header → neighborhood header → entries ForEach(neighborhoodBoroughGroups, id: \.borough) { boroughGroup in boroughSectionHeader(boroughGroup.borough) ForEach(boroughGroup.neighborhoods, id: \.name) { neighborhoodGroup in Text(neighborhoodGroup.name) .font(.system(size: 16, weight: .bold)) .foregroundColor(Color(red: 220/255, green: 20/255, blue: 60/255)) .listRowInsets(EdgeInsets(top: 10, leading: 4, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) ForEach(neighborhoodGroup.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, showRatingStars: true, showPriorityDots: true, distanceText: nearestFirst ? distanceText(for: store.entries[index]) : nil) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(neighborhoodGroup.entries, at: offsets) } } } } else { ForEach(combinedSortGroups, id: \.key) { group in Text(group.key) .font(.system(size: 16, weight: .bold)) .foregroundColor(Color(red: 220/255, green: 20/255, blue: 60/255)) .listRowInsets(EdgeInsets(top: 10, leading: 4, bottom: 0, trailing: 0)) .listRowSeparator(.hidden) ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { listRow(entry: store.entries[index], index: index, showRatingStars: true, showPriorityDots: true, distanceText: nearestFirst ? distanceText(for: store.entries[index]) : nil) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteEntriesInList(group.entries, at: offsets) } } } } } private var iPhoneGridContent: some View { ScrollView { gridBody(minCellWidth: 75) } .navigationTitle(modeNavigationTitle) .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { sharedToolbar } }Code blockSee source code for full implementation.
▶ IPAD LAYOUT
private var iPadLayout: some View { NavigationStack { Group { if iPhoneShowsGrid { iPadGridContent } else { iPhoneListContent } } .alert("Pending Reminders", isPresented: $showingPendingReminders) { Button("OK", role: .cancel) { } } message: { Text(pendingReminders.isEmpty ? "No pending reminders" : pendingReminders.joined(separator: "\n\n")) } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } .alert("Multiple Entries Removed", isPresented: $showingBulkDeleteAlert) { Button("Allow Deletion", role: .destructive) { store.confirmBlockedSave() } Button("Keep All Entries", role: .cancel) { store.cancelBlockedSave() } } message: { Text("Something attempted to remove \(store.blockedRemovalCount) entries at once. This may be unintended. Do you want to allow the deletion, or keep all entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingBulkLoadAlert) { Button("Load Anyway", role: .destructive) { store.confirmBlockedLoad() } Button("Keep Current Entries", role: .cancel) { store.cancelBlockedLoad() } } message: { Text("The data just loaded from iCloud has \(store.blockedLoadCount) fewer entry(s) than what's currently in memory. This may mean the backup hasn't fully downloaded yet. Load the smaller set anyway, or keep your current entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingIntegrityAlert) { Button("Save Anyway", role: .destructive) { store.confirmIntegritySave() } Button("Keep Current Entries", role: .cancel) { store.cancelIntegritySave() } } message: { Text("Based on this session's adds and deletes, at least \(store.integrityExpectedCount) entries are expected, but only \(store.integrityActualCount) are about to be written. This may indicate data loss. Save anyway, or keep the current entries?") } } }`iPadLayout` varProperty `iPadLayout`. Type: `some`.
▶ SHARED SECTION HEADERS
// Used when there are no upcoming entries (edge case) private var eventsListHeader: some View { HStack { Text("TO TRY") .font(.system(size: 16, weight: .bold)) .foregroundColor(.primary) Spacer() Text("\(modeFilteredEntries.count) \(modeFilteredEntries.count == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } // EVENTS + Upcoming/Results stacked in one section header — no inter-section gap private var combinedEventsUpcomingHeader: some View { let isSearching = !searchText.isEmpty let totalFiltered = filteredUpcomingEntries.count + filteredPastEntriesByYear.reduce(0) { $0 + $1.entries.count } return VStack(alignment: .leading, spacing: 4) { if !isSearching { HStack { Text("TO TRY") .font(.system(size: 16, weight: .bold)) .foregroundColor(.primary) Spacer() Text("\(modeFilteredEntries.count) \(modeFilteredEntries.count == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } } else { HStack { Text("RESULTS") .font(.system(size: 16, weight: .bold)) .foregroundColor(.primary) Spacer() Text("\(totalFiltered) \(totalFiltered == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } } } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .padding(.top, -10) } private var upcomingSectionHeader: some View { HStack { Spacer() Text("\(upcomingEntries.count) \(upcomingEntries.count == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) .padding(.top, -10) } private func pastSectionHeader(for group: (year: Int, entries: [SpotEntry])) -> some View { HStack { Text(String(group.year)) .font(.system(size: 16, weight: .semibold)) Spacer() Text("\(group.entries.count) \(group.entries.count == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) }Documentation commentDescribes the following declaration.
▶ SHOWS SECTION HEADERS
private var showsBigHeader: some View { HStack { Text(searchText.isEmpty ? "SHOWS" : "RESULTS") .font(.system(size: 16, weight: .bold)) .foregroundColor(.primary) Spacer() Text("\(modeFilteredEntries.count) \(modeFilteredEntries.count == 1 ? "Show" : "Shows")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func showsSectionHeader(_ title: String, count: Int) -> some View { HStack { Text(title) .font(.system(size: 14, weight: .semibold)) Spacer() Text("\(count) \(count == 1 ? "Show" : "Shows")") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func pastShowSectionHeader(for group: (year: Int, entries: [SpotEntry])) -> some View { HStack { Text(String(group.year)) .font(.system(size: 16, weight: .semibold)) Spacer() Text("\(group.entries.count) \(group.entries.count == 1 ? "Show" : "Shows")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) }`showsBigHeader` varProperty `showsBigHeader`. Type: `some`.
▶ EVENTS HEADERS
private var eventsBigHeader: some View { HStack { Text(searchText.isEmpty ? "EVENTS" : "RESULTS") .font(.system(size: 16, weight: .bold)) .foregroundColor(.primary) Spacer() Text("\(modeFilteredEntries.count) \(modeFilteredEntries.count == 1 ? "Event" : "Events")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func eventsSectionHeader(_ title: String, count: Int) -> some View { HStack { Text(title) .font(.system(size: 14, weight: .semibold)) Spacer() Text("\(count) \(count == 1 ? "Event" : "Events")") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func pastEventSectionHeader(for group: (year: Int, entries: [SpotEntry])) -> some View { HStack { Text(String(group.year)) .font(.system(size: 16, weight: .semibold)) Spacer() Text("\(group.entries.count) \(group.entries.count == 1 ? "Event" : "Events")") .font(.system(size: 14)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) }`eventsBigHeader` varProperty `eventsBigHeader`. Type: `some`.
▶ SHARED TOOLBAR (IPHONE)
@ToolbarContentBuilder private var sharedToolbar: some ToolbarContent { if isSelectingForPDF { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { isSelectingForPDF = false pdfSelectedIDs.removeAll() } } ToolbarItem(placement: .principal) { Text(pdfSelectedIDs.isEmpty ? "Select Entries" : "\(pdfSelectedIDs.count) Selected") .font(.system(size: 13, weight: .semibold)) } ToolbarItem(placement: .topBarTrailing) { Button { Task { await generateAndSharePDF() } } label: { Label("Share PDF", systemImage: "square.and.arrow.up") } .disabled(pdfSelectedIDs.isEmpty) } } else { ToolbarItem(placement: .principal) { Text(modeNavigationTitle) .font(.system(size: 13, weight: .semibold)) } ToolbarItem(placement: .topBarLeading) { Button { UIApplication.shared.open(URL(string: "calshow://")!) } label: { Image(systemName: "calendar") } } ToolbarItem(placement: .topBarLeading) { Button { showingContactPicker = true } label: { Image(systemName: "rectangle.stack.person.crop") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "shoebox://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "creditcard.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "accuweather://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "cloud.sun.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "info.mta.mymta://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "tram.fill") } } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 14) { Button { isSelectingForPDF = true pdfSelectedIDs.removeAll() } label: { Image(systemName: "checkmark.circle") .font(.system(size: 16)) } Button(action: { activeContentSheet = .addEntry }) { Image(systemName: "plus") .font(.system(size: 16)) } } } } }Code blockSee source code for full implementation.
▶ IPAD GRID CONTENT
private var iPadGridContent: some View { ScrollView { gridBody(minCellWidth: 100) } .navigationTitle(modeNavigationTitle) .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { sharedToolbar } }`iPadGridContent` varProperty `iPadGridContent`. Type: `some`.
▶ SHOPS TAB
@ViewBuilder private var shopsTabContent: some View { if horizontalSizeClass == .regular { iPadLayout } else { iPhoneLayout } }`shopsTabContent` varProperty `shopsTabContent`. Type: `some`.
▶ SHOWS LAYOUT
private var iPhoneShowsLayout: some View { NavigationStack { VStack(spacing: 0) { Group { if iPhoneShowsGrid { iPhoneShowsGridContent } else { iPhoneShowsListContent } } foodListMapControls } .alert("Pending Reminders", isPresented: $showingPendingReminders) { Button("OK", role: .cancel) { } } message: { Text(pendingReminders.isEmpty ? "No pending reminders" : pendingReminders.joined(separator: "\n\n")) } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } .alert("Multiple Entries Removed", isPresented: $showingBulkDeleteAlert) { Button("Allow Deletion", role: .destructive) { store.confirmBlockedSave() } Button("Keep All Entries", role: .cancel) { store.cancelBlockedSave() } } message: { Text("Something attempted to remove \(store.blockedRemovalCount) entries at once. This may be unintended. Do you want to allow the deletion, or keep all entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingBulkLoadAlert) { Button("Load Anyway", role: .destructive) { store.confirmBlockedLoad() } Button("Keep Current Entries", role: .cancel) { store.cancelBlockedLoad() } } message: { Text("The data just loaded from iCloud has \(store.blockedLoadCount) fewer entry(s) than what's currently in memory. This may mean the backup hasn't fully downloaded yet. Load the smaller set anyway, or keep your current entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingIntegrityAlert) { Button("Save Anyway", role: .destructive) { store.confirmIntegritySave() } Button("Keep Current Entries", role: .cancel) { store.cancelIntegritySave() } } message: { Text("Based on this session's adds and deletes, at least \(store.integrityExpectedCount) entries are expected, but only \(store.integrityActualCount) are about to be written. This may indicate data loss. Save anyway, or keep the current entries?") } } } private var iPhoneShowsListContent: some View { List { iPhoneShowsListRows } .listStyle(.plain) .environment(\.defaultMinListRowHeight, 0) .navigationTitle("Shows") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { showsToolbar } } @ViewBuilder private var iPhoneShowsListRows: some View { let allShows = modeFilteredEntries let upcoming = upcomingShows let consider = considerShows let pastByYear = pastShowsByYear let hasResults = !upcoming.isEmpty || !consider.isEmpty || !pastByYear.isEmpty if allShows.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: "theatermasks", description: Text("Add your first show to get started") ) } else if !searchText.isEmpty && !hasResults { ContentUnavailableView.search(text: searchText) } else { // Top header: SHOWS + total count Section(header: showsBigHeader) { } // Upcoming section if !upcoming.isEmpty { Section(header: showsSectionHeader("Upcoming", count: upcoming.count)) { ForEach(upcoming) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { showsRow(entry: store.entries[index], index: index) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { if !isSelectingForPDF { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } } .onDelete { offsets in deleteUpcomingShows(at: offsets) } } } // Consider section if !consider.isEmpty { Section(header: showsSectionHeader("Consider", count: consider.count)) { ForEach(consider) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { showsRow(entry: store.entries[index], index: index) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } .onDelete { offsets in deleteConsiderShows(at: offsets) } } } // Past shows — either by year (default) or flat by rating↓ date↓ if showsRatingsSort { let ratedPast = pastShowsRatingsSorted if !ratedPast.isEmpty { Section(header: showsSectionHeader("Past — By Rating", count: ratedPast.count)) { ForEach(ratedPast) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { showsRow(entry: store.entries[index], index: index, showBell: false) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } } } else { ForEach(pastByYear, id: \.year) { group in Section(header: pastShowSectionHeader(for: group)) { ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { showsRow(entry: store.entries[index], index: index, showBell: false) .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } .onDelete { offsets in deletePastShowsInGroup(group.entries, at: offsets) } } } } } } private var iPhoneShowsGridContent: some View { ScrollView { if modeFilteredEntries.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: "theatermasks", description: Text("Add your first show to get started") ) .frame(maxWidth: .infinity) .padding(.top, 60) } else { let now = Date() let filtered = modeFilteredEntries.filter { matchesSearch($0) } let sorted: [SpotEntry] = { if showsRatingsSort { // Upcoming (date asc), then Consider (alpha), then Past by rating↓ date↓ let upcoming = filtered.filter { !$0.consider && $0.dateTime >= now } .sorted { $0.dateTime < $1.dateTime } let consider = filtered.filter { $0.consider } .sorted { $0.playName.lowercased() < $1.playName.lowercased() } let past = filtered.filter { !$0.consider && $0.dateTime < now } .sorted { a, b in if a.starRating != b.starRating { return a.starRating > b.starRating } return a.dateTime > b.dateTime } return upcoming + consider + past } else { // Default: upcoming first (date asc), consider after (alpha) return filtered.sorted { a, b in if a.consider != b.consider { return !a.consider } if !a.consider && !b.consider { return a.dateTime < b.dateTime } return a.playName.lowercased() < b.playName.lowercased() } } }() LazyVGrid( columns: [GridItem(.adaptive(minimum: 75, maximum: 115), spacing: 12)], spacing: 16 ) { ForEach(sorted) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { EntryGridItemView(entry: store.entries[index]) } .buttonStyle(.plain) } } } .padding(.horizontal, 16) .padding(.bottom, 16) } } .navigationTitle("Shows") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { showsToolbar } } @ToolbarContentBuilder private var showsToolbar: some ToolbarContent { if isSelectingForPDF { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { isSelectingForPDF = false pdfSelectedIDs.removeAll() } } ToolbarItem(placement: .principal) { Text(pdfSelectedIDs.isEmpty ? "Select Entries" : "\(pdfSelectedIDs.count) Selected") .font(.system(size: 13, weight: .semibold)) } ToolbarItem(placement: .topBarTrailing) { Button { Task { await generateAndSharePDF() } } label: { Label("Share PDF", systemImage: "square.and.arrow.up") } .disabled(pdfSelectedIDs.isEmpty) } } else { ToolbarItem(placement: .principal) { Text("Shows") .font(.system(size: 13, weight: .semibold)) } ToolbarItem(placement: .topBarLeading) { Button { UIApplication.shared.open(URL(string: "calshow://")!) } label: { Image(systemName: "calendar") } } ToolbarItem(placement: .topBarLeading) { Button { showingContactPicker = true } label: { Image(systemName: "rectangle.stack.person.crop") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "shoebox://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "creditcard.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "accuweather://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "cloud.sun.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "info.mta.mymta://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "tram.fill") } } ToolbarItem(placement: .topBarTrailing) { HStack(spacing: 14) { Button { isSelectingForPDF = true pdfSelectedIDs.removeAll() } label: { Image(systemName: "checkmark.circle") .font(.system(size: 16)) } Button(action: { activeContentSheet = .addEntry }) { Image(systemName: "plus") .font(.system(size: 16)) } } } } } @ToolbarContentBuilder private var eventsToolbar: some ToolbarContent { ToolbarItem(placement: .principal) { Text("Events") .font(.system(size: 13, weight: .semibold)) } ToolbarItem(placement: .topBarLeading) { Button { UIApplication.shared.open(URL(string: "calshow://")!) } label: { Image(systemName: "calendar") } } ToolbarItem(placement: .topBarLeading) { Button { showingContactPicker = true } label: { Image(systemName: "rectangle.stack.person.crop") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "shoebox://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "creditcard.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "accuweather://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "cloud.sun.fill") } } ToolbarItem(placement: .topBarLeading) { Button { if let url = URL(string: "info.mta.mymta://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } } label: { Image(systemName: "tram.fill") } } ToolbarItem(placement: .topBarTrailing) { Button(action: { activeContentSheet = .addEntry }) { Image(systemName: "plus") .font(.system(size: 16)) } } } // Shared grid body — minCellWidth: 75 → 4 cols on iPhone, 100 → 6 cols on iPad @ViewBuilder private func gridBody(minCellWidth: CGFloat) -> some View { if modeFilteredEntries.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: modeEmptyIcon, description: Text(modeEmptyMessage) ) .frame(maxWidth: .infinity) .padding(.top, 60) } else if sortOrder == .default { VStack(alignment: .leading, spacing: 0) { let toTrySorted = sortedEntries(toTryEntries.filter { matchesSearch($0) }) let beenSorted = sortedEntries(beenEntries.filter { matchesSearch($0) }) iPadGridSectionHeader(title: "TO TRY", count: toTrySorted.count) gridItems(toTrySorted, minCellWidth: minCellWidth) if !beenSorted.isEmpty { iPadGridSectionHeader(title: "BEEN THERE", count: beenSorted.count) gridItems(beenSorted, minCellWidth: minCellWidth) } } } else if sortOrder == .distance { // Sorts immediately using stored coordinates; any missing ones geocode silently in background. VStack(alignment: .leading, spacing: 0) { iPadGridSectionHeader(title: "NEAREST FIRST", count: distanceSortedEntries.count) gridItems(distanceSortedEntries, minCellWidth: minCellWidth) } } else if sortOrder == .neighborhood { // Two-level: borough header → neighborhood header → grid items VStack(alignment: .leading, spacing: 0) { ForEach(neighborhoodBoroughGroups, id: \.borough) { boroughGroup in Text(boroughGroup.borough.uppercased()) .font(.system(size: 16, weight: .heavy)) .foregroundColor(.secondary) .tracking(1.5) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 20) .padding(.top, 24) .padding(.bottom, 2) ForEach(boroughGroup.neighborhoods, id: \.name) { neighborhoodGroup in gridGroupHeader(title: neighborhoodGroup.name) gridItems(neighborhoodGroup.entries, minCellWidth: minCellWidth) } } } } else if sortOrder == .ratings { // Ratings grouped by star rating, highest first VStack(alignment: .leading, spacing: 0) { ForEach(ratingGroups, id: \.rating) { group in iPadGridSectionHeader( title: group.rating == 0 ? "UNRATED" : String(repeating: "★", count: group.rating), count: group.entries.count ) gridItems(group.entries, minCellWidth: minCellWidth) } } } else { // Cuisine grouped VStack(alignment: .leading, spacing: 0) { ForEach(combinedSortGroups, id: \.key) { group in gridGroupHeader(title: group.key) gridItems(group.entries, minCellWidth: minCellWidth) } } } } private func gridItems(_ entries: [SpotEntry], minCellWidth: CGFloat) -> some View { LazyVGrid( columns: [GridItem(.adaptive(minimum: minCellWidth, maximum: minCellWidth + 40), spacing: 12)], spacing: 16 ) { ForEach(entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { EntryGridItemView(entry: store.entries[index]) } .buttonStyle(.plain) } } } .padding(.horizontal, 16) .padding(.bottom, 16) } // Used for Default sort section headers in the grid private func iPadGridSectionHeader(title: String, count: Int) -> some View { HStack { Text(title) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.primary) Spacer() Text("\(count) \(count == 1 ? "Spot" : "Spots")") .font(.system(size: 14)) .foregroundColor(.secondary) } .padding(.horizontal, 16) .padding(.vertical, 8) .background(Color(UIColor.systemBackground)) .frame(maxWidth: .infinity) } // Used for Cuisine / Neighborhood group headers in the grid — matches list style private func gridGroupHeader(title: String) -> some View { Text(title) .font(.system(size: 16, weight: .bold)) .foregroundColor(Color(red: 220/255, green: 20/255, blue: 60/255)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) .padding(.top, 10) .padding(.bottom, 4) .padding(.horizontal, 16) }`iPhoneShowsLayout` varProperty `iPhoneShowsLayout`. Type: `some`.
▶ EVENTS LAYOUT
private var iPhoneEventsLayout: some View { NavigationStack { VStack(spacing: 0) { Group { if iPhoneEventsGrid { iPhoneEventsGridContent } else { iPhoneEventsListContent } } foodListMapControls } .alert("Pending Reminders", isPresented: $showingPendingReminders) { Button("OK", role: .cancel) { } } message: { Text(pendingReminders.isEmpty ? "No pending reminders" : pendingReminders.joined(separator: "\n\n")) } .alert("Calendar", isPresented: $showingCalendarAlert) { Button("OK", role: .cancel) { } } message: { Text(calendarAlertMessage) } .alert("Multiple Entries Removed", isPresented: $showingBulkDeleteAlert) { Button("Allow Deletion", role: .destructive) { store.confirmBlockedSave() } Button("Keep All Entries", role: .cancel) { store.cancelBlockedSave() } } message: { Text("Something attempted to remove \(store.blockedRemovalCount) entries at once. This may be unintended. Do you want to allow the deletion, or keep all entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingBulkLoadAlert) { Button("Load Anyway", role: .destructive) { store.confirmBlockedLoad() } Button("Keep Current Entries", role: .cancel) { store.cancelBlockedLoad() } } message: { Text("The data just loaded from iCloud has \(store.blockedLoadCount) fewer entry(s) than what's currently in memory. This may mean the backup hasn't fully downloaded yet. Load the smaller set anyway, or keep your current entries?") } .alert("Fewer Entries Than Expected", isPresented: $showingIntegrityAlert) { Button("Save Anyway", role: .destructive) { store.confirmIntegritySave() } Button("Keep Current Entries", role: .cancel) { store.cancelIntegritySave() } } message: { Text("Based on this session's adds and deletes, at least \(store.integrityExpectedCount) entries are expected, but only \(store.integrityActualCount) are about to be written. This may indicate data loss. Save anyway, or keep the current entries?") } } } private var iPhoneEventsListContent: some View { List { iPhoneEventsListRows } .listStyle(.plain) .environment(\.defaultMinListRowHeight, 0) .navigationTitle("Events") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { eventsToolbar } } @ViewBuilder private var iPhoneEventsListRows: some View { let allEvents = modeFilteredEntries let upcoming = upcomingEvents let consider = considerEvents let pastByYear = pastEventsByYear let hasResults = !upcoming.isEmpty || !consider.isEmpty || !pastByYear.isEmpty if allEvents.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: "ticket", description: Text("Add your first event to get started") ) } else if !searchText.isEmpty && !hasResults { ContentUnavailableView.search(text: searchText) } else { Section(header: eventsBigHeader) { } if !upcoming.isEmpty { Section(header: eventsSectionHeader("Upcoming", count: upcoming.count)) { ForEach(upcoming) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { ShowRowView(entry: store.entries[index]) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) .contextMenu { Button { addEntryToCalendar(entry) } label: { Label("Add to Calendar", systemImage: "calendar.badge.plus") } } } } .onDelete { offsets in deleteUpcomingEvents(at: offsets) } } } if !consider.isEmpty { Section(header: eventsSectionHeader("Consider", count: consider.count)) { ForEach(consider) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { ShowRowView(entry: store.entries[index]) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } .onDelete { offsets in deleteConsiderEvents(at: offsets) } } } if eventsRatingsSort { let ratedPast = pastEventsRatingsSorted if !ratedPast.isEmpty { Section(header: eventsSectionHeader("Past — By Rating", count: ratedPast.count)) { ForEach(ratedPast) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { ShowRowView(entry: store.entries[index], showBell: false) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } } } } else { ForEach(pastByYear, id: \.year) { group in Section(header: pastEventSectionHeader(for: group)) { ForEach(group.entries) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { ShowRowView(entry: store.entries[index], showBell: false) } .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) } } .onDelete { offsets in deletePastEventsInGroup(group.entries, at: offsets) } } } } } } private var iPhoneEventsGridContent: some View { ScrollView { if modeFilteredEntries.isEmpty { ContentUnavailableView( "Nothing Here Yet", systemImage: "ticket", description: Text("Add your first event to get started") ) .frame(maxWidth: .infinity) .padding(.top, 60) } else { let now = Date() let filtered = modeFilteredEntries.filter { matchesSearch($0) } let sorted: [SpotEntry] = { if eventsRatingsSort { let upcoming = filtered.filter { !$0.consider && $0.dateTime >= now } .sorted { $0.dateTime < $1.dateTime } let consider = filtered.filter { $0.consider } .sorted { $0.playName.lowercased() < $1.playName.lowercased() } let past = filtered.filter { !$0.consider && $0.dateTime < now } .sorted { a, b in if a.starRating != b.starRating { return a.starRating > b.starRating } return a.dateTime > b.dateTime } return upcoming + consider + past } else { return filtered.sorted { a, b in if a.consider != b.consider { return !a.consider } if !a.consider && !b.consider { return a.dateTime < b.dateTime } return a.playName.lowercased() < b.playName.lowercased() } } }() LazyVGrid( columns: [GridItem(.adaptive(minimum: 75, maximum: 115), spacing: 12)], spacing: 16 ) { ForEach(sorted) { entry in if let index = store.entries.firstIndex(where: { $0.id == entry.id }) { NavigationLink(destination: detailDestination(for: index)) { EntryGridItemView(entry: store.entries[index]) } .buttonStyle(.plain) } } } .padding(.horizontal, 16) .padding(.bottom, 16) } } .navigationTitle("Events") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $iPhoneShowingRatings) { RatingsView(entries: $store.entries, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }) } .navigationDestination(isPresented: $showingMap) { let userLoc = locationManager.location ?? mapInitialCenter.map { CLLocation(latitude: $0.latitude, longitude: $0.longitude) } if let userLoc { NearbyMapView(entries: $store.entries, userLocation: userLoc, isLoading: geocodingInProgress, initialSearchQuery: mapInitialSearch, initialCenter: mapInitialCenter, initialSpan: mapInitialSpan, initialName: mapInitialName, initialTheaters: mapInitialTheaters, initialShowSubway: mapInitialShowSubway, initialShowBus: mapInitialShowBus) } else { ContentUnavailableView("Location Not Available", systemImage: "location.slash", description: Text("Enable location access to see nearby spots")) } } .toolbar { eventsToolbar } }`iPhoneEventsLayout` varProperty `iPhoneEventsLayout`. Type: `some`.
▶ DATA & ACTIONS
func createEntryFromSharedData() { guard let pending = sharedDataManager.pendingEntry else { return } var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) components.hour = 19 components.minute = 0 let defaultDate = Calendar.current.date(from: components) ?? Date() let newEntry = SpotEntry( playName: pending.name, dateTime: defaultDate, cuisine: "", address: "", confirmationLink: pending.content, remindersEnabled: false, notes: pending.content, entryMode: selectedTab.entryMode ?? .food ) store.entries.append(newEntry) NotificationManager.shared.scheduleReminders(for: newEntry) sharedDataManager.pendingEntry = nil } func importSharedEntry() { let appGroupID = "group.com.spots.shared" guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else { return } // Read any debug log written by the share extension, copy it to the clipboard, then delete it let debugLogURL = containerURL.appendingPathComponent("extension_debug.txt") if let logText = try? String(contentsOf: debugLogURL, encoding: .utf8) { print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") print("📋 SpotsShare extension log:") print(logText) print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") // Also copy to clipboard so it's retrievable even when Xcode logging times out UIPasteboard.general.string = "📋 SpotsShare extension log:\n" + logText try? FileManager.default.removeItem(at: debugLogURL) } let fileURL = containerURL.appendingPathComponent("pending_entry.json") guard FileManager.default.fileExists(atPath: fileURL.path), let jsonData = try? Data(contentsOf: fileURL), let entryData = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return } let playName = (entryData["playName"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let resolvedPlayName = playName.isEmpty ? "New Entry" : playName let entryModeStr = (entryData["entryMode"] as? String ?? "food") let importedEntryMode = EntryMode(rawValue: entryModeStr) ?? .food let cuisine = (entryData["cuisine"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let address = (entryData["address"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let phone = (entryData["phone"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let website = (entryData["website"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let neighborhood = (entryData["neighborhood"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let notes = (entryData["notes"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let hours = (entryData["hours"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let jsonConfirmationLink = (entryData["confirmationLink"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let emlFilePath = entryData["emlFilePath"] as? String ?? "" let pendingImagePath = entryData["pendingImagePath"] as? String ?? "" let hasCustomDate = entryData["hasCustomDate"] as? Bool ?? false // Load image saved by the share extension (URL imports only) var importedImageData: Data? = nil if !pendingImagePath.isEmpty, FileManager.default.fileExists(atPath: pendingImagePath) { importedImageData = try? Data(contentsOf: URL(fileURLWithPath: pendingImagePath)) try? FileManager.default.removeItem(atPath: pendingImagePath) print("ℹ️ Loaded pending image: \(importedImageData?.count ?? 0) bytes") } let dateTimeInterval = entryData["dateTime"] as? TimeInterval // Copy .eml or .pdf file to local Documents, and to iCloud Documents if available var permanentEmlPath = "" if !emlFilePath.isEmpty, FileManager.default.fileExists(atPath: emlFilePath) { do { let documentsURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let sourceExt = (emlFilePath as NSString).pathExtension.lowercased() let fileExt = sourceExt.isEmpty ? "eml" : sourceExt let attachmentFileName = "\(resolvedPlayName.replacingOccurrences(of: " ", with: "_"))_\(UUID().uuidString.prefix(8)).\(fileExt)" // Always save locally (reliable fallback) let localDestURL = documentsURL.appendingPathComponent(attachmentFileName) try FileManager.default.copyItem(atPath: emlFilePath, toPath: localDestURL.path) // Also save to iCloud Documents if iCloud is available let icloudURL = FileManager.default.url(forUbiquityContainerIdentifier: nil) print("ℹ️ iCloud container URL: \(icloudURL?.path ?? "nil — iCloud not available")") if let icloudDocsURL = icloudURL?.appendingPathComponent("Documents") { do { try FileManager.default.createDirectory(at: icloudDocsURL, withIntermediateDirectories: true, attributes: nil) let icloudDestURL = icloudDocsURL.appendingPathComponent(attachmentFileName) if FileManager.default.fileExists(atPath: icloudDestURL.path) { try FileManager.default.removeItem(at: icloudDestURL) } try FileManager.default.copyItem(at: localDestURL, to: icloudDestURL) print("✅ Attachment saved to iCloud: \(icloudDestURL.path)") } catch { print("❌ Failed to save to iCloud: \(error)") } } permanentEmlPath = attachmentFileName try? FileManager.default.removeItem(atPath: emlFilePath) } catch { print("Error copying attachment file: \(error)") } } let entryDate: Date if let interval = dateTimeInterval { entryDate = Date(timeIntervalSince1970: interval) } else { var components = Calendar.current.dateComponents([.year, .month, .day], from: Date()) components.hour = 19 components.minute = 0 entryDate = Calendar.current.date(from: components) ?? Date() } // For URL-based imports there's no file, so fall back to the source URL as the link. let resolvedLink = permanentEmlPath.isEmpty ? jsonConfirmationLink : permanentEmlPath let newEntry = SpotEntry( playName: resolvedPlayName, dateTime: entryDate, cuisine: cuisine, address: address, phone: phone, website: website, neighborhood: neighborhood, hours: hours, confirmationLink: resolvedLink, imageData: importedImageData, remindersEnabled: false, notes: notes, hasCustomDate: hasCustomDate, entryMode: importedEntryMode ) store.entries.append(newEntry) NotificationManager.shared.scheduleReminders(for: newEntry) try? FileManager.default.removeItem(at: fileURL) // Switch to the tab matching the imported entry's mode switch importedEntryMode { case .food: selectedTab = .food case .show: selectedTab = .shows case .place: selectedTab = .sites case .shop: selectedTab = .shops case .event: selectedTab = .sites } activeContentSheet = .editImportedEntry(newEntry.id) }`createEntryFromSharedData()` functionImplements `createEntryFromSharedData`.
▶ VISIT MIGRATION
/// Classifies a Visit `type` string ("Theater", "Museum", etc.) into an EntryMode. private static func classifyVisitType(_ type: String) -> EntryMode { let t = type.lowercased() let showKeywords = ["theatre", "theater", "concert", "music venue", "opera", "cinema", "auditorium", "playhouse", "cabaret", "comedy club", "arena", "hall", "performing arts", "broadway", "off-broadway", "show", "performance", "stage", "venue"] let foodKeywords = ["restaurant", "cafe", "café", "bar", "bakery", "diner", "bistro", "pizzeria", "sushi", "brasserie", "tavern", "pub", "eatery", "food", "grill", "steakhouse"] if showKeywords.contains(where: { t.contains($0) }) { return .show } if foodKeywords.contains(where: { t.contains($0) }) { return .food } return .place } /// Silent one-time migration from Visit app data. /// /// How to use: copy your Visit app's `entries.json` into this app's iCloud Documents /// folder (via Finder / Files app) and rename it `visit_migration.json`. /// On next launch, this function finds the file, imports all non-duplicate entries, /// and then deletes the file — nothing is shown to the user. func performVisitMigrationIfNeeded() { // Check local Documents first, then iCloud Documents let candidates: [URL] = [ (try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false))? .appendingPathComponent("visit_migration.json"), FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") .appendingPathComponent("visit_migration.json") ].compactMap { $0 } guard let fileURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }), let data = try? Data(contentsOf: fileURL), let visitEntries = try? JSONDecoder().decode([VisitMigrationEntry].self, from: data) else { return } // Delete the trigger file before modifying store (avoid re-import on crash/retry) try? FileManager.default.removeItem(at: fileURL) let existingIDs = Set(store.entries.map { $0.id }) var added = 0 for v in visitEntries { guard !existingIDs.contains(v.id) else { continue } let newEntry = SpotEntry( id: v.id, playName: v.playName, dateTime: v.dateTime, cuisine: v.type, // Visit "type" → Spots "cuisine" field address: v.address, neighborhood: v.neighborhood, hours: v.hours, confirmationLink: v.confirmationLink, remindersEnabled: false, notes: v.notes, rating: v.rating, beenThere: v.beenThere, starRating: v.starRating, hasCustomDate: v.hasCustomDate, latitude: v.latitude, longitude: v.longitude, entryMode: Self.classifyVisitType(v.type) ) store.entries.append(newEntry) added += 1 } if added > 0 { print("✅ Visit migration: imported \(added) of \(visitEntries.count) entries.") } } /// Wires up image files that were manually copied from Visit's VisitImages folder /// into Spots' SpotsImages folder. Runs silently every launch; no-op once all /// migrated entries already have an imageFilename. func performVisitImageFixupIfNeeded() { // Mirrors the path logic in EntryStore's private localImagesDirectory / // icloudImagesDirectory helpers. let localImagesDir = (try? FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false ))?.appendingPathComponent("SpotsImages", isDirectory: true) let icloudImagesDir = FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") .appendingPathComponent("SpotsImages", isDirectory: true) var fixedUp = 0 for index in store.entries.indices where store.entries[index].imageFilename == nil { let candidate = "\(store.entries[index].id.uuidString).jpg" let found = [localImagesDir, icloudImagesDir].compactMap { $0 }.contains { FileManager.default.fileExists(atPath: $0.appendingPathComponent(candidate).path) } if found { store.entries[index].imageFilename = candidate fixedUp += 1 } } if fixedUp > 0 { print("✅ Visit image fixup: wired up \(fixedUp) image(s).") } }Documentation commentDescribes the following declaration.
▶ PDF EXPORT
/// Generates a PDF from the current selection, grouped to match the active sort mode. func generateAndSharePDF() async { let mode: EntryMode = selectedTab == .shows ? .show : .food let selected = store.entries.filter { pdfSelectedIDs.contains($0.id) && $0.entryMode == mode } guard !selected.isEmpty else { return } var sections: [PDFSection] = [] var sortLabel = "" switch sortOrder { case .cuisine: sortLabel = "Cuisine" let grouped = Dictionary(grouping: selected) { e in e.cuisine.isEmpty ? "Other" : e.cuisine } sections = grouped.keys.sorted().map { key in let entries = grouped[key]!.sorted { $0.playName.lowercased() < $1.playName.lowercased() } return PDFSection(header: key, subheader: nil, entries: entries) } case .neighborhood: sortLabel = "Neighborhood" let byBorough = Dictionary(grouping: selected) { boroughFor($0) } let sortedBoroughs = byBorough.keys.sorted { a, b in let ai = Self.boroughOrder.firstIndex(of: a) ?? Int.max let bi = Self.boroughOrder.firstIndex(of: b) ?? Int.max return ai != bi ? ai < bi : a < b } for borough in sortedBoroughs { let byNeighborhood = Dictionary(grouping: byBorough[borough]!) { e in e.neighborhood.isEmpty ? "Other" : e.neighborhood } for name in byNeighborhood.keys.sorted() { let entries = byNeighborhood[name]!.sorted { $0.playName.lowercased() < $1.playName.lowercased() } sections.append(PDFSection(header: borough, subheader: name, entries: entries)) } } case .ratings: sortLabel = "Rating" let sorted = selected.sorted { a, b in if a.starRating != b.starRating { return a.starRating > b.starRating } return a.rating > b.rating } sections = [PDFSection(header: nil, subheader: nil, entries: sorted)] case .distance, .default: let sorted = selected.sorted { $0.playName.lowercased() < $1.playName.lowercased() } sections = [PDFSection(header: nil, subheader: nil, entries: sorted)] } guard let url = await SpotsPDFExporter.export(sections: sections, mode: mode, sortLabel: sortLabel) else { return } // Present UIActivityViewController directly via UIKit — avoids the SwiftUI sheet // timing race that caused a blank white sheet on first launch. let avc = UIActivityViewController(activityItems: [url], applicationActivities: nil) // completionWithItemsHandler is called on the main thread by UIKit. // @State setters are reference-backed, so captured copies update the live store. avc.completionWithItemsHandler = { _, _, _, _ in isSelectingForPDF = false pdfSelectedIDs.removeAll() } guard let windowScene = UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { $0.activationState == .foregroundActive }), let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController else { return } // Walk to the topmost presented controller so we don't try to present over a sheet. var topVC = rootVC while let presented = topVC.presentedViewController { topVC = presented } topVC.present(avc, animated: true) } func addEntryToCalendar(_ entry: SpotEntry) { Task { let result = await CalendarManager.shared.addEntryToCalendar(entry) await MainActor.run { switch result { case .success: calendarAlertMessage = String(format: String(localized: "\"%@\" has been added to your calendar."), entry.playName) showingCalendarAlert = true case .failure(let error): calendarAlertMessage = error.localizedDescription showingCalendarAlert = true } } } }Documentation commentDescribes the following declaration.
▶ COMPUTED ENTRY LISTS
var upcomingEntries: [SpotEntry] { let now = Date() return modeFilteredEntries.filter { $0.dateTime >= now }.sorted { $0.dateTime < $1.dateTime } } var pastEntries: [SpotEntry] { let now = Date() return modeFilteredEntries.filter { $0.dateTime < now }.sorted { $0.dateTime > $1.dateTime } } var pastEntriesByYear: [(year: Int, entries: [SpotEntry])] { let grouped = Dictionary(grouping: pastEntries) { Calendar.current.component(.year, from: $0.dateTime) } return grouped.keys.sorted(by: >).map { year in (year: year, entries: grouped[year]!) } }`upcomingEntries` varProperty `upcomingEntries`. Type: `[SpotEntry]`.
▶ SHOWS SECTIONS (UPCOMING / CONSIDER / PAST BY YEAR)
var upcomingShows: [SpotEntry] { let now = Date() return modeFilteredEntries .filter { !$0.consider && $0.dateTime >= now && matchesSearch($0) } .sorted { $0.dateTime < $1.dateTime } } var considerShows: [SpotEntry] { modeFilteredEntries .filter { $0.consider && matchesSearch($0) } .sorted { $0.playName.lowercased() < $1.playName.lowercased() } } var pastShowsByYear: [(year: Int, entries: [SpotEntry])] { let now = Date() let past = modeFilteredEntries.filter { !$0.consider && $0.dateTime < now && matchesSearch($0) } let grouped = Dictionary(grouping: past) { Calendar.current.component(.year, from: $0.dateTime) } return grouped.keys.sorted(by: >).map { year in (year: year, entries: grouped[year]!.sorted { $0.dateTime > $1.dateTime }) } } /// Past shows sorted by rating descending, then date descending. /// Used when the Ratings toggle is on in the Theater view. var pastShowsRatingsSorted: [SpotEntry] { let now = Date() return modeFilteredEntries .filter { !$0.consider && $0.dateTime < now && matchesSearch($0) } .sorted { a, b in if a.starRating != b.starRating { return a.starRating > b.starRating } return a.dateTime > b.dateTime } }`upcomingShows` varProperty `upcomingShows`. Type: `[SpotEntry]`.
▶ EVENTS SECTIONS (UPCOMING / CONSIDER / PAST BY YEAR)
// These delegate to modeFilteredEntries which returns .event entries when on the Events tab. var upcomingEvents: [SpotEntry] { upcomingShows } var considerEvents: [SpotEntry] { considerShows } var pastEventsByYear: [(year: Int, entries: [SpotEntry])] { pastShowsByYear } var pastEventsRatingsSorted: [SpotEntry] { pastShowsRatingsSorted }Documentation commentDescribes the following declaration.
▶ TO TRY / BEEN GROUPING
var toTryEntries: [SpotEntry] { modeFilteredEntries.filter { !$0.beenThere && matchesSearch($0) } } var beenEntries: [SpotEntry] { modeFilteredEntries.filter { $0.beenThere && matchesSearch($0) } } var hasListEntries: Bool { !toTryEntries.isEmpty || !beenEntries.isEmpty } /// All entries grouped by star rating (5 → 0), unrated last. Used for the Ratings sort. var ratingGroups: [(rating: Int, entries: [SpotEntry])] { let filtered = modeFilteredEntries.filter { matchesSearch($0) } return [5, 4, 3, 2, 1, 0].compactMap { rating in let group = filtered .filter { $0.starRating == rating } .sorted { $0.playName.lowercased() < $1.playName.lowercased() } return group.isEmpty ? nil : (rating: rating, entries: group) } } var combinedSortGroups: [(key: String, entries: [SpotEntry])] { let filtered = modeFilteredEntries.filter { matchesSearch($0) } let keyFor: (SpotEntry) -> String = { entry in switch sortOrder { case .cuisine: return entry.cuisine.isEmpty ? "Other" : entry.cuisine case .neighborhood: return entry.neighborhood.isEmpty ? "Other" : entry.neighborhood default: return "" } } let grouped = Dictionary(grouping: filtered, by: keyFor) return grouped.keys.sorted().map { key in (key: key, entries: sortedEntries(grouped[key]!)) } }`toTryEntries` varProperty `toTryEntries`. Type: `[SpotEntry]`.
▶ BOROUGH HELPERS (FOR NEIGHBORHOOD SORT)
private static let boroughOrder = AddressHelper.boroughOrder /// Derives the conventional postal borough/city name from an entry's address. private func boroughFor(_ entry: SpotEntry) -> String { AddressHelper.borough(for: entry.address) } /// Two-level grouping for neighborhood sort: borough → neighborhoods → entries. var neighborhoodBoroughGroups: [(borough: String, neighborhoods: [(name: String, entries: [SpotEntry])])] { let filtered = modeFilteredEntries.filter { matchesSearch($0) } let byBorough = Dictionary(grouping: filtered) { boroughFor($0) } let sortedBoroughs = byBorough.keys.sorted { a, b in let ai = Self.boroughOrder.firstIndex(of: a) ?? Int.max let bi = Self.boroughOrder.firstIndex(of: b) ?? Int.max return ai != bi ? ai < bi : a < b } return sortedBoroughs.map { borough in let byNeighborhood = Dictionary(grouping: byBorough[borough]!) { e in e.neighborhood.isEmpty ? "Other" : e.neighborhood } let neighborhoods = byNeighborhood.keys.sorted().map { name in (name: name, entries: sortedEntries(byNeighborhood[name]!)) } return (borough: borough, neighborhoods: neighborhoods) } } private func boroughSectionHeader(_ title: String) -> some View { Text(title.uppercased()) .font(.system(size: 16, weight: .heavy)) .foregroundColor(.secondary) .tracking(1.5) .listRowInsets(EdgeInsets(top: 20, leading: 4, bottom: 2, trailing: 0)) .listRowSeparator(.hidden) } private func sortedEntries(_ entries: [SpotEntry]) -> [SpotEntry] { if nearestFirst, let userLoc = locationManager.location { return entries.sorted { a, b in let dA = coordinate(for: a).map { CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: userLoc) } ?? .infinity let dB = coordinate(for: b).map { CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: userLoc) } ?? .infinity return dA < dB } } switch sortOrder { case .default: return entries.sorted { $0.playName.lowercased() < $1.playName.lowercased() } case .cuisine: return entries.sorted { $0.cuisine.lowercased() < $1.cuisine.lowercased() } case .neighborhood: return entries.sorted { $0.neighborhood.lowercased() < $1.neighborhood.lowercased() } case .distance: return entries.sorted { $0.playName.lowercased() < $1.playName.lowercased() } case .ratings: return entries.sorted { $0.starRating > $1.starRating } } } var toTryByPriority: [(priority: Int, entries: [SpotEntry])] { [5, 4, 3, 2, 1, 0].compactMap { priority in let group = sortedEntries(toTryEntries.filter { $0.rating == priority }) return group.isEmpty ? nil : (priority: priority, entries: group) } } var beenByStarRating: [(starRating: Int, entries: [SpotEntry])] { [5, 4, 3, 2, 1, 0].compactMap { stars in let group = sortedEntries(beenEntries.filter { $0.starRating == stars }) return group.isEmpty ? nil : (starRating: stars, entries: group) } }`boroughOrder` letProperty `boroughOrder`.
▶ PRIORITY GROUPING
var filteredEntriesByPriority: [(priority: Int, entries: [SpotEntry])] { [5, 4, 3, 2, 1, 0].compactMap { priority in let group = sortedEntries(modeFilteredEntries.filter { $0.rating == priority && matchesSearch($0) }) return group.isEmpty ? nil : (priority: priority, entries: group) } } private func prioritySectionHeader(for priority: Int, count: Int) -> some View { HStack(spacing: 4) { if priority == 0 { Text("No Priority") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.secondary) } else { ForEach(1...5, id: \.self) { dot in Image(systemName: dot <= priority ? "circle.fill" : "circle") .font(.system(size: 10)) .foregroundColor(dot <= priority ? .primary : .gray.opacity(0.3)) } } Spacer() Text("\(count) \(count == 1 ? "Spot" : "Spots")") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func listSectionHeader(_ title: String, count: Int) -> some View { HStack { Text(title) .font(.system(size: 14, weight: .semibold)) Spacer() Text("\(count) \(count == 1 ? "Spot" : "Spots")") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) } private func bigSectionHeader(_ title: String) -> some View { Text(title) .font(.system(size: 20, weight: .bold)) .foregroundColor(Color(red: 220/255, green: 20/255, blue: 60/255)) .textCase(nil) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 4, trailing: 16)) } private func starSectionHeader(for starRating: Int, count: Int) -> some View { HStack(spacing: 4) { if starRating == 0 { Text("No Rating") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.secondary) } else { ForEach(1...5, id: \.self) { star in Image(systemName: star <= starRating ? "star.fill" : "star") .font(.system(size: 10)) .foregroundColor(star <= starRating ? .yellow : .gray.opacity(0.3)) } } Spacer() Text("\(count) \(count == 1 ? "Spot" : "Spots")") .font(.system(size: 13)) .foregroundColor(.secondary) } .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) }`filteredEntriesByPriority` varProperty `filteredEntriesByPriority`. Type: `[(priority:`.
▶ SEARCH FILTERING
▶ NAVIGATION HELPER
/// Single place that builds EntryDetailView with the geocoding callback. /// Uses an ID-based binding so the reference stays valid after load() re-sorts entries. @ViewBuilder private func detailDestination(for index: Int) -> some View { let id = store.entries[index].id EntryDetailView( entry: Binding( get: { store.entries.first(where: { $0.id == id }) ?? store.entries[index] }, set: { newValue in if let idx = store.entries.firstIndex(where: { $0.id == id }) { store.entries[idx] = newValue } } ), onAddressChanged: { id in store.geocodeEntry(id: id) }, onOpenInAppMap: { address, id in openMapFromDetail(address: address, entryID: id) }, onOpenSubwayMap: { address in openMapFromDetail(address: address, showSubway: true, showBus: false) }, onOpenBusMap: { address in openMapFromDetail(address: address, showSubway: false, showBus: true) } ) } private var screenWidth: CGFloat { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } .first?.screen.bounds.width ?? 390 } private func openMapFromDetail(address: String, entryID: UUID? = nil, showSubway: Bool = true, showBus: Bool = false) { mapInitialSearch = address // still used by sub-screen instances (kept for easy revert) mapInitialShowSubway = showSubway mapInitialShowBus = showBus locationManager.requestLocation() startGeocoding() // Tab-switch approach: navigate to the main Map tab and inject the search query. // The Map tab's NearbyMapView picks it up in .task and resets the binding after use. // To revert to the sub-screen approach, comment the two lines below and uncomment showingMap = true. mapTabSearchQuery = address mapTabFocusEntryID = entryID // if set, NearbyMapView auto-opens that entry's bubble selectedTab = .map // showingMap = true }Documentation commentDescribes the following declaration.
▶ DISTANCE / GEOCODING
private func geocodeAddressFor(_ entry: SpotEntry) -> String { let addr = entry.address.isEmpty ? entry.cuisine : entry.address return addr.contains(",") ? addr : "\(addr), New York, NY" } /// Returns the best available coordinate for an entry — /// stored (from JSON) first, falling back to the session cache. private func coordinate(for entry: SpotEntry) -> CLLocationCoordinate2D? { if let lat = entry.latitude, let lon = entry.longitude { return CLLocationCoordinate2D(latitude: lat, longitude: lon) } return geocodedCoordinates[entry.id] } private func distanceText(for entry: SpotEntry) -> String? { guard let userLoc = locationManager.location, let coord = coordinate(for: entry) else { return nil } let meters = CLLocation(latitude: coord.latitude, longitude: coord.longitude).distance(from: userLoc) let miles = meters / 1609.34 return miles < 0.1 ? "< 0.1 mi" : String(format: "%.1f mi", miles) } var distanceSortedEntries: [SpotEntry] { let filtered = modeFilteredEntries.filter { matchesSearch($0) } guard let userLoc = locationManager.location else { return filtered } return filtered.sorted { a, b in let dA = coordinate(for: a).map { CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: userLoc) } ?? .infinity let dB = coordinate(for: b).map { CLLocation(latitude: $0.latitude, longitude: $0.longitude).distance(from: userLoc) } ?? .infinity return dA < dB } } /// Geocodes all entries that are missing coordinates. /// - Parameter showSpinner: Pass `true` only when the user explicitly requested distance sort; /// pass `false` for background pre-warming (map open, toolbar map button) so the map /// isn't blocked by a spinner while entries are quietly geocoded in the background. private func startGeocoding(showSpinner: Bool = false) { guard !geocodingInProgress else { return } // Pre-populate session cache from stored coordinates (instant, no API call) for entry in store.entries { if let lat = entry.latitude, let lon = entry.longitude { geocodedCoordinates[entry.id] = CLLocationCoordinate2D(latitude: lat, longitude: lon) } } // Only call the geocoder for entries still missing coordinates let toGeocode = store.entries.filter { coordinate(for: $0) == nil && (!$0.address.isEmpty || !$0.cuisine.isEmpty) } guard !toGeocode.isEmpty else { return } if showSpinner { geocodingInProgress = true } geocodeSequentially(toGeocode, index: 0, accumulator: geocodedCoordinates, showSpinner: showSpinner) } private func geocodeSequentially(_ entries: [SpotEntry], index: Int, accumulator: [UUID: CLLocationCoordinate2D], showSpinner: Bool = false) { guard index < entries.count else { geocodedCoordinates = accumulator if showSpinner { geocodingInProgress = false } return } let entry = entries[index] var acc = accumulator Task { @MainActor in let searchReq = MKLocalSearch.Request() searchReq.naturalLanguageQuery = geocodeAddressFor(entry) if let response = try? await MKLocalSearch(request: searchReq).start(), let item = response.mapItems.first { let coord = item.location.coordinate acc[entry.id] = coord // Persist back to the entry so we don't have to geocode it again if let i = self.store.entries.firstIndex(where: { $0.id == entry.id }) { self.store.entries[i].latitude = coord.latitude self.store.entries[i].longitude = coord.longitude } } try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s rate-limit self.geocodeSequentially(entries, index: index + 1, accumulator: acc, showSpinner: showSpinner) } } private func matchesSearch(_ entry: SpotEntry) -> Bool { AddressHelper.matches(entry: entry, query: searchText) } var filteredUpcomingEntries: [SpotEntry] { upcomingEntries.filter { matchesSearch($0) } } var filteredPastEntriesByYear: [(year: Int, entries: [SpotEntry])] { pastEntriesByYear.compactMap { group in let filtered = group.entries.filter { matchesSearch($0) } return filtered.isEmpty ? nil : (year: group.year, entries: filtered) } }`geocodeAddressFor()` functionImplements `geocodeAddressFor`. Returns `String`.
▶ DELETE
func deleteUpcomingEntries(at offsets: IndexSet) { for index in offsets { let entry = upcomingEntries[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deletePastEntries(at offsets: IndexSet) { for index in offsets { let entry = pastEntries[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deleteEntriesInList(_ list: [SpotEntry], at offsets: IndexSet) { for index in offsets { let entry = list[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deletePastEntriesInGroup(_ group: [SpotEntry], at offsets: IndexSet) { for index in offsets { let entry = group[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deleteUpcomingShows(at offsets: IndexSet) { for index in offsets { let entry = upcomingShows[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deleteConsiderShows(at offsets: IndexSet) { for index in offsets { let entry = considerShows[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deletePastShowsInGroup(_ group: [SpotEntry], at offsets: IndexSet) { for index in offsets { let entry = group[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deleteUpcomingEvents(at offsets: IndexSet) { for index in offsets { let entry = upcomingEvents[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deleteConsiderEvents(at offsets: IndexSet) { for index in offsets { let entry = considerEvents[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func deletePastEventsInGroup(_ group: [SpotEntry], at offsets: IndexSet) { for index in offsets { let entry = group[index] NotificationManager.shared.cancelReminders(for: entry) if let entryIndex = store.entries.firstIndex(where: { $0.id == entry.id }) { store.entries.remove(at: entryIndex) } } } func loadPendingReminders() { UNUserNotificationCenter.current().getPendingNotificationRequests { requests in let formatter = DateFormatter() formatter.dateStyle = .short formatter.timeStyle = .short let reminders = requests.compactMap { request -> String? in guard let trigger = request.trigger as? UNCalendarNotificationTrigger, let nextDate = trigger.nextTriggerDate() else { return nil } return "\(request.content.title)\n\(formatter.string(from: nextDate))" }.sorted() DispatchQueue.main.async { pendingReminders = reminders showingPendingReminders = true } } }`deleteUpcomingEntries()` functionImplements `deleteUpcomingEntries`.
▶ SHEET CONTENT
@ViewBuilder private func contentSheetView(for sheet: ContentSheet) -> some View { switch sheet { case .addEntry: AddEntryView(entries: $store.entries, onSave: { id in store.geocodeEntry(id: id) // Kick off Resy / OpenTable lookup for new food entries. Task { guard let idx = store.entries.firstIndex(where: { $0.id == id }), store.entries[idx].entryMode == .food, store.entries[idx].reservation.isEmpty else { return } if let resURL = await ReservationService.lookup(for: store.entries[idx]) { store.entries[idx].reservation = resURL } } }, mode: selectedTab.entryMode ?? .food) case .editImportedEntry(let id): if let index = store.entries.firstIndex(where: { $0.id == id }) { EditEntryView(entry: $store.entries[index], onSave: {}, isImported: true) } else { // Defensive fallback: entry not found yet (e.g. a rapid iCloud reload // ran between append and sheet render). Show a spinner; if the entry // still can't be found after a moment, dismiss cleanly rather than // leaving a permanent blank white sheet. ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) .task { try? await Task.sleep(nanoseconds: 800_000_000) // 0.8 s if store.entries.firstIndex(where: { $0.id == id }) == nil { activeContentSheet = nil } } } } } }Code blockSee source code for full implementation.
▶ VISIT MIGRATION
/// Minimal mirror of VisitEntry used only for decoding a Visit app entries.json backup. /// Maps Visit's `type` field onto `SpotEntry.cuisine` and derives `entryMode` via keyword matching. private struct VisitMigrationEntry: Decodable { var id: UUID var playName: String var dateTime: Date var type: String var address: String var neighborhood: String var hours: String var confirmationLink: String var imageFilename: String? var notes: String var rating: Int var beenThere: Bool var starRating: Int var hasCustomDate: Bool var latitude: Double? var longitude: Double? private enum CodingKeys: String, CodingKey { case id, playName, dateTime, type, address, neighborhood, hours, confirmationLink, imageFilename, notes, rating, beenThere, starRating, hasCustomDate, latitude, longitude } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) playName = try c.decode(String.self, forKey: .playName) dateTime = try c.decode(Date.self, forKey: .dateTime) type = try c.decode(String.self, forKey: .type) address = try c.decode(String.self, forKey: .address) neighborhood = (try? c.decodeIfPresent(String.self, forKey: .neighborhood)) ?? "" hours = (try? c.decodeIfPresent(String.self, forKey: .hours)) ?? "" confirmationLink = try c.decode(String.self, forKey: .confirmationLink) imageFilename = try? c.decodeIfPresent(String.self, forKey: .imageFilename) notes = (try? c.decodeIfPresent(String.self, forKey: .notes)) ?? "" rating = (try? c.decodeIfPresent(Int.self, forKey: .rating)) ?? 0 beenThere = (try? c.decodeIfPresent(Bool.self, forKey: .beenThere)) ?? false starRating = (try? c.decodeIfPresent(Int.self, forKey: .starRating)) ?? 0 hasCustomDate = (try? c.decodeIfPresent(Bool.self, forKey: .hasCustomDate)) ?? false latitude = try? c.decodeIfPresent(Double.self, forKey: .latitude) longitude = try? c.decodeIfPresent(Double.self, forKey: .longitude) } }Documentation commentDescribes the following declaration.
▶ MINDTHESHOW MIGRATION
/// Minimal mirror of TheaterTicket used only for decoding a MindTheShow tickets.json backup. /// Maps TheaterTicket fields onto SpotEntry fields; all entries import as entryMode = .show. private struct MindTheShowMigrationTicket: Decodable { var id: UUID var playName: String var dateTime: Date var venue: String var address: String var rowSeat: String var confirmationLink: String var imageFilename: String? var notes: String var rating: Int // post-show star rating in MindTheShow → starRating in Spots var consider: Bool // true = Consider section (no firm date) private enum CodingKeys: String, CodingKey { case id, playName, dateTime, venue, address, rowSeat, confirmationLink, imageFilename, notes, rating, consider } init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) playName = try c.decode(String.self, forKey: .playName) dateTime = try c.decode(Date.self, forKey: .dateTime) venue = (try? c.decodeIfPresent(String.self, forKey: .venue)) ?? "" address = (try? c.decodeIfPresent(String.self, forKey: .address)) ?? "" rowSeat = (try? c.decodeIfPresent(String.self, forKey: .rowSeat)) ?? "" confirmationLink = (try? c.decode(String.self, forKey: .confirmationLink)) ?? "" imageFilename = try? c.decodeIfPresent(String.self, forKey: .imageFilename) notes = (try? c.decodeIfPresent(String.self, forKey: .notes)) ?? "" rating = (try? c.decodeIfPresent(Int.self, forKey: .rating)) ?? 0 consider = (try? c.decodeIfPresent(Bool.self, forKey: .consider)) ?? false } }Documentation commentDescribes the following declaration.
extension ContentView { /// Imports MindTheShow tickets from `mindtheshow_migration.json` (placed in iCloud or local /// Documents by the user). Silent no-op once the file is consumed. Same trigger-file pattern /// as performVisitMigrationIfNeeded(). /// /// Field mapping: /// venue → cuisine (shown as subtitle in entry rows) /// rowSeat → neighborhood (seat info stored in the "location" field) /// rating → starRating (post-show star rating) /// consider → consider (Upcoming vs. Consider map toggle) /// hasCustomDate → true when consider == false (has a committed date) /// entryMode → .show func performMindTheShowMigrationIfNeeded() { let localDocs = try? FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) let icloudDocs = FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") let candidates: [URL] = [ localDocs?.appendingPathComponent("mindtheshow_migration.json"), icloudDocs?.appendingPathComponent("mindtheshow_migration.json") ].compactMap { $0 } guard let fileURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }), let data = try? Data(contentsOf: fileURL), let tickets = try? JSONDecoder().decode([MindTheShowMigrationTicket].self, from: data) else { return } // Delete the trigger file before modifying store (avoid re-import on crash/retry) try? FileManager.default.removeItem(at: fileURL) let existingIDs = Set(store.entries.map { $0.id }) var added = 0 for t in tickets { guard !existingIDs.contains(t.id) else { continue } // Strip MindTheShow app-container .eml paths down to filename only — // they can be placed in Spots' Documents folder to restore the link. let link: String = { let raw = t.confirmationLink if raw.hasPrefix("/var/mobile/") || raw.hasPrefix("/private/var/") { return (raw as NSString).lastPathComponent } return raw }() let newEntry = SpotEntry( id: t.id, playName: t.playName, dateTime: t.dateTime, cuisine: t.venue, // venue name shown as subtitle address: t.address, neighborhood: t.rowSeat, // seat info (row/seat) confirmationLink: link, imageFilename: t.imageFilename, remindersEnabled: false, notes: t.notes, starRating: t.rating, // MindTheShow rating → post-visit star rating hasCustomDate: !t.consider, // committed date only when not in Consider entryMode: .show, consider: t.consider ) store.entries.append(newEntry) added += 1 } if added > 0 { print("✅ MindTheShow migration: imported \(added) of \(tickets.count) tickets.") } } // MARK: - Coordinate Patch /// Applies pre-geocoded lat/lon to entries that are missing coordinates. /// Trigger: place `spots_coordinate_patch.json` in iCloud or local Documents. /// Format: [{id, latitude, longitude}, ...] /// The file is deleted after the first successful application. func performCoordinatePatchIfNeeded() { let icloudDocs = FileManager.default.url(forUbiquityContainerIdentifier: nil)? .appendingPathComponent("Documents") let localDocs = try? FileManager.default.url( for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) let candidates: [URL] = [ localDocs?.appendingPathComponent("spots_coordinate_patch.json"), icloudDocs?.appendingPathComponent("spots_coordinate_patch.json") ].compactMap { $0 } guard let fileURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }), let data = try? Data(contentsOf: fileURL) else { return } struct CoordPatch: Decodable { var id: UUID var latitude: Double var longitude: Double } guard let patches = try? JSONDecoder().decode([CoordPatch].self, from: data) else { return } try? FileManager.default.removeItem(at: fileURL) var applied = 0 for patch in patches { guard let i = store.entries.firstIndex(where: { $0.id == patch.id }), store.entries[i].latitude == nil else { continue } store.entries[i].latitude = patch.latitude store.entries[i].longitude = patch.longitude applied += 1 } if applied > 0 { print("✅ Coordinate patch: applied to \(applied) of \(patches.count) entries.") } } }`ContentView` extensionDefines the `ContentView` extension.
▶ ENTRY ROW VIEW
struct EntryRowView: View { let entry: SpotEntry var linksEnabled: Bool = true // false in iPad sidebar to prevent accidental map launches var showBell: Bool = true // false in RatingsView var showRatingStars: Bool = false // true in RatingsView: shows star icons above image var showPriorityDots: Bool = false // true in combined cuisine/neighborhood/distance sort views var distanceText: String? = nil // shown below name in distance sort @Environment(\.openURL) var openURL /// Decoded once and cached; cleared on memory warning so iOS can reclaim RAM. @State private var cachedImage: UIImage? func isEmlFilePath(_ path: String) -> Bool { (path.hasSuffix(".eml") || path.hasSuffix(".pdf")) && !path.hasPrefix("http") && !path.hasPrefix("message:") } func openInMaps(_ address: String) { let full = address.contains(",") ? address : "\(address), New York, NY" let encoded = full.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? full if let url = URL(string: "https://maps.google.com/maps?q=\(encoded)") { openURL(url) } } var body: some View { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(verbatim: entry.playName) .font(.system(size: 15, weight: .medium)) .foregroundColor(.primary) if showBell && entry.remindersEnabled { Image(systemName: "bell.fill") .font(.system(size: 10)) .foregroundColor(.orange) } } if entry.hasCustomDate { Text(entry.formattedDateTime) .font(.system(size: 13)) .foregroundColor(Color.primary.opacity(0.65)) } if !entry.cuisine.isEmpty { if entry.address.isEmpty && linksEnabled { Button(action: { let encoded = entry.cuisine.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? entry.cuisine if let url = URL(string: "https://maps.google.com/maps?q=\(encoded)") { openURL(url) } }) { Text(entry.cuisine) .font(.system(size: 13)) .foregroundColor(.blue) } .buttonStyle(.plain) } else { Text(entry.cuisine) .font(.system(size: 13)) .foregroundColor(Color.primary.opacity(0.65)) } } if !entry.neighborhood.isEmpty { Text(entry.neighborhood) .font(.system(size: 13)) .foregroundColor(Color.primary.opacity(0.65)) } if !entry.address.isEmpty { if linksEnabled { Button(action: { openInMaps(entry.address) }) { Text(entry.address) .font(.system(size: 13)) .foregroundColor(.blue) } .buttonStyle(.plain) } else { Text(entry.address) .font(.system(size: 13)) .foregroundColor(Color.primary.opacity(0.65)) } } if let dist = distanceText { Text(dist) .font(.system(size: 12)) .foregroundColor(Color.primary.opacity(0.5)) } if !entry.confirmationLink.isEmpty && !isEmlFilePath(entry.confirmationLink) && linksEnabled { Button(action: { if let url = URL(string: entry.confirmationLink) { openURL(url) } }) { Text(entry.confirmationLink) .font(.system(size: 13)) .foregroundColor(.blue) .lineLimit(1) } .buttonStyle(.plain) } } Spacer() VStack(spacing: 3) { if showPriorityDots && entry.rating > 0 { HStack(spacing: 2) { ForEach(0..<entry.rating, id: \.self) { _ in Image(systemName: "circle.fill") .font(.system(size: 6)) .foregroundColor(.primary) } } .frame(width: 50) } if showRatingStars && entry.starRating > 0 { HStack(spacing: 2) { ForEach(0..<entry.starRating, id: \.self) { _ in Image(systemName: "star.fill") .font(.system(size: 6)) .foregroundColor(.yellow) } } .frame(width: 50) } if let uiImage = cachedImage { Image(uiImage: uiImage) .resizable() .scaledToFill() .frame(width: 50, height: 77) .clipped() .cornerRadius(4) } else { Rectangle() .fill(Color.gray.opacity(0.3)) .frame(width: 50, height: 77) .cornerRadius(4) } } } // Load a downsampled thumbnail — 50-pt display × 3× screen = 150 px needed. // loadThumbnail() uses ImageIO so only those pixels are decoded from the JPEG stream. // Keyed on imageFilename (not entry.id) so the task re-runs when a web-loaded image // is saved for the first time and a filename is assigned. .task(id: entry.imageFilename ?? "") { if let filename = entry.imageFilename, !filename.isEmpty { // Try disk first. If the file isn't written yet (background save race), // fall back to the in-memory imageData blob. if let image = await EntryStore.loadThumbnail(filename: filename, maxPixelSize: 150) { await MainActor.run { cachedImage = image } } else if let data = entry.imageData { let image = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 150) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value await MainActor.run { cachedImage = image } } } else if let data = entry.imageData { // No filename yet — decode directly from the in-memory blob. let image = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 150) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value await MainActor.run { cachedImage = image } } } // Drop the thumbnail when the row scrolls off screen to avoid accumulating // UIImages for every entry in a long list. Re-loaded on demand when visible. .onDisappear { cachedImage = nil } // On memory pressure, also drop any image that managed to stay in memory. .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in print("⚠️ EntryRowView: memory warning — releasing cached image for '\(entry.playName)'") cachedImage = nil } } }`EntryRowView` structDefines the `EntryRowView` struct. Conforms to View.
▶ ENTRY GRID ITEM VIEW (IPAD GRID LAYOUT)
struct EntryGridItemView: View { let entry: SpotEntry @State private var cachedImage: UIImage? private static func truncate(_ name: String) -> String { name.count <= 16 ? name : String(name.prefix(16)) + "…" } private static let monthDayFormatter: DateFormatter = { let f = DateFormatter() f.dateFormat = "MMM d" return f }() var body: some View { VStack(spacing: 5) { // Always reserve the same height so every image top aligns. // Priority: stars (starRating) > dots (rating) > invisible placeholder. HStack(spacing: 2) { if entry.starRating > 0 { ForEach(1...entry.starRating, id: \.self) { _ in Image(systemName: "star.fill") .font(.system(size: 6)) .foregroundColor(.yellow) } } else if entry.rating > 0 { ForEach(0..<entry.rating, id: \.self) { _ in Image(systemName: "circle.fill") .font(.system(size: 6)) .foregroundColor(.primary) } } else { Image(systemName: "circle.fill") .font(.system(size: 6)) .foregroundColor(.clear) // invisible — reserves exact row height } } // Rectangle establishes the fixed aspect-ratio frame; overlay is // constrained to that exact frame so scaledToFill never overflows. Rectangle() .fill(Color.gray.opacity(0.3)) .aspectRatio(0.65, contentMode: .fit) .overlay( Group { if let uiImage = cachedImage { Image(uiImage: uiImage) .resizable() .scaledToFill() } } .clipped() ) .cornerRadius(6) .clipped() .opacity(1.0) VStack(spacing: 1) { Text(verbatim: Self.truncate(entry.playName)) .font(.system(size: 11)) .lineLimit(1) .foregroundColor(.primary) .frame(maxWidth: .infinity, alignment: .center) if entry.hasCustomDate { Text(Self.monthDayFormatter.string(from: entry.dateTime)) .font(.system(size: 10)) .lineLimit(1) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .center) } } } // Grid cells are wider than list rows — 200 px covers a ~66-pt cell at 3×. // Keyed on imageFilename so the task re-runs when a web-loaded image is first saved. .task(id: entry.imageFilename ?? "") { if let filename = entry.imageFilename, !filename.isEmpty { if let image = await EntryStore.loadThumbnail(filename: filename, maxPixelSize: 200) { await MainActor.run { cachedImage = image } } else if let data = entry.imageData { let image = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 200) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value await MainActor.run { cachedImage = image } } } else if let data = entry.imageData { let image = await Task.detached(priority: .userInitiated) { let resized = EntryStore.resizedImageData(data, maxDimension: 200) guard let source = CGImageSourceCreateWithData(resized as CFData, nil), let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none } return UIImage(cgImage: cg) }.value await MainActor.run { cachedImage = image } } } .onDisappear { cachedImage = nil } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in cachedImage = nil } } }`EntryGridItemView` structDefines the `EntryGridItemView` struct. Conforms to View.
#Preview { ContentView() .environmentObject(SharedDataManager.shared) }Code blockSee source code for full implementation.