| struct EntryDetailView: View {
@Binding var entry: SpotEntry
var onAddressChanged: ((UUID) -> Void)? = nil
var onOpenInAppMap: ((String, UUID) -> Void)? = nil // opens address in the app's map; passes entry ID to auto-open bubble
var onOpenSubwayMap: ((String) -> Void)? = nil // opens map with subway layer on
var onOpenBusMap: ((String) -> Void)? = nil // opens map with bus layer on, subway off
@Environment(\.openURL) var openURL
@State private var calendarStatus: CalendarStatus = .notAdded
@State private var showingCalendarAlert = false
@State private var calendarAlertMessage = ""
@State private var showDatePicker: Bool
@State private var rating: Int
@State private var beenThere: Bool
@State private var starRating: Int
@State private var activeSheet: ActiveSheet?
@State private var detailImage: UIImage?
@State private var showFullscreenImage = false
@State private var docInteractionController: UIDocumentInteractionController?
@State private var isFetchingPhone = false
@State private var isFetchingWebsite = false
@State private var isFetchingReservation = false
@State private var isFetchingIBDB = false
@State private var isFetchingSpectra = false
@State private var nearbyBusRoutes: [String] = []
@State private var busRoutesLoaded = false
@State private var nearbySubwayLines: [String] = []
@State private var subwayLinesLoaded = false
private static let detailDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "EEE, MMM d · h:mm a"
return f
}()
enum CalendarStatus {
case notAdded, added, checking
}
enum ActiveSheet: Identifiable {
case edit
case eml(URL)
case pdf(URL)
var id: String {
switch self {
case .edit: return "edit"
case .eml(let url): return "eml:\(url.path)"
case .pdf(let url): return "pdf:\(url.path)"
}
}
}
init(entry: Binding<SpotEntry>,
onAddressChanged: ((UUID) -> Void)? = nil,
onOpenInAppMap: ((String, UUID) -> Void)? = nil,
onOpenSubwayMap: ((String) -> Void)? = nil,
onOpenBusMap: ((String) -> Void)? = nil) {
_entry = entry
self.onAddressChanged = onAddressChanged
self.onOpenInAppMap = onOpenInAppMap
self.onOpenSubwayMap = onOpenSubwayMap
self.onOpenBusMap = onOpenBusMap
_showDatePicker = State(initialValue: entry.wrappedValue.hasCustomDate)
_rating = State(initialValue: entry.wrappedValue.rating)
_beenThere = State(initialValue: entry.wrappedValue.beenThere)
_starRating = State(initialValue: entry.wrappedValue.starRating)
}
@ViewBuilder private var infoSection: some View {
if entry.entryMode == .show || entry.entryMode == .event {
Section(header: Text(entry.entryMode == .show ? "Show Information" : "Event Information").font(.system(size: 12))) {
LabeledContent {
Text(entry.playName).font(.system(size: 15, weight: .bold)).foregroundColor(.primary).multilineTextAlignment(.trailing)
} label: {
Text("Show").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if !entry.consider {
Button(action: {
let interval = Int(entry.dateTime.timeIntervalSince1970) - 978307200
if let url = URL(string: "calshow:\(interval)") { openURL(url) }
}) {
Text(Self.detailDateFormatter.string(from: entry.dateTime))
.font(.system(size: 15))
.foregroundColor(.blue)
.multilineTextAlignment(.trailing)
}
.buttonStyle(.plain)
}
} label: {
Text("Date & Time").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if !entry.cuisine.isEmpty {
mapMenu(label: entry.cuisine,
query: entry.address.isEmpty ? entry.cuisine : entry.address)
}
} label: {
Text("Venue").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if entry.address.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
mapMenu(label: entry.address, query: entry.address)
}
} label: {
Text("Address").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if isFetchingPhone {
ProgressView().scaleEffect(0.7)
} else if entry.phone.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
Button(action: { callPhone(entry.phone) }) {
Text(entry.phone).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing)
}
.buttonStyle(.plain)
}
} label: {
Text("Phone").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if isFetchingWebsite {
ProgressView().scaleEffect(0.7)
} else if entry.website.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
Button(action: { openWebsite(entry.website) }) {
Text(entry.website).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing).lineLimit(1)
}
.buttonStyle(.plain)
}
} label: {
Text("Website").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
if entry.entryMode == .food {
LabeledContent {
reservationButton
} label: {
Text("Reserve").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
LabeledContent {
Text(entry.neighborhood.isEmpty ? "—" : entry.neighborhood)
.font(.system(size: 15))
.foregroundColor(entry.neighborhood.isEmpty ? .secondary : .primary)
.multilineTextAlignment(.trailing)
} label: {
Text("Row/Seat").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
HStack {
Button {
entry.consider.toggle()
entry.hasCustomDate = !entry.consider
} label: {
Text("Consider")
.font(.system(size: 14))
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(entry.consider ? Color.orange : Color(.systemGray5))
.foregroundColor(entry.consider ? .white : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.borderless)
Spacer()
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { star in
Button { starRating = (starRating == star) ? 0 : star } label: {
Image(systemName: star <= starRating ? "star.fill" : "star")
.font(.system(size: 18))
.foregroundColor(star <= starRating ? .yellow : .gray.opacity(0.4))
}
.buttonStyle(.borderless)
}
}
.id(starRating)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
} else {
Section(header: Text(entry.entryMode == .food ? String(localized: "Restaurant Information") : String(localized: "Place Information")).font(.system(size: 12))) {
LabeledContent {
Text(entry.playName).font(.system(size: 15, weight: .bold)).foregroundColor(.primary).multilineTextAlignment(.trailing)
} label: {
Text("Spot").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if entry.address.isEmpty && !entry.cuisine.isEmpty {
mapMenu(label: entry.cuisine, query: entry.cuisine)
} else {
Text(entry.cuisine).font(.system(size: 15)).foregroundColor(.primary)
}
} label: {
Text("Type").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
Text(entry.neighborhood.isEmpty ? "—" : entry.neighborhood)
.font(.system(size: 15))
.foregroundColor(entry.neighborhood.isEmpty ? .secondary : .primary)
.multilineTextAlignment(.trailing)
} label: {
Text("Neighborhood").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if entry.address.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
mapMenu(label: entry.address, query: entry.address)
}
} label: {
Text("Address").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if isFetchingPhone {
ProgressView().scaleEffect(0.7)
} else if entry.phone.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
Button(action: { callPhone(entry.phone) }) {
Text(entry.phone).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing)
}.buttonStyle(.plain)
}
} label: {
Text("Phone").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
LabeledContent {
if isFetchingWebsite {
ProgressView().scaleEffect(0.7)
} else if entry.website.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary).multilineTextAlignment(.trailing)
} else {
Button(action: { openWebsite(entry.website) }) {
Text(entry.website).font(.system(size: 15)).foregroundColor(.blue).multilineTextAlignment(.trailing).lineLimit(1)
}.buttonStyle(.plain)
}
} label: {
Text("Website").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
if entry.entryMode == .food {
LabeledContent {
reservationButton
} label: {
Text("Reserve").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
LabeledContent {
Text(entry.hours.isEmpty ? "—" : entry.hours)
.font(.system(size: 15))
.foregroundColor(entry.hours.isEmpty ? .secondary : .primary)
.multilineTextAlignment(.trailing)
} label: {
Text("Hours").font(.system(size: 12)).foregroundColor(.secondary)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
HStack {
Text("Priority").font(.system(size: 12)).foregroundColor(.secondary)
Spacer()
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { dot in
Button { rating = (rating == dot) ? dot - 1 : dot } label: {
Image(systemName: dot <= rating ? "circle.fill" : "circle")
.font(.system(size: 14))
.foregroundColor(dot <= rating ? .primary : .gray.opacity(0.4))
}.buttonStyle(.borderless)
}
}.id(rating)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
HStack {
Text("Been").font(.system(size: 12)).foregroundColor(.secondary)
Spacer()
Toggle("", isOn: $beenThere).labelsHidden()
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
if beenThere {
HStack {
Text("Rating").font(.system(size: 12)).foregroundColor(.secondary)
Spacer()
HStack(spacing: 4) {
ForEach(1...5, id: \.self) { star in
Button { starRating = (starRating == star) ? star - 1 : star } label: {
Image(systemName: star <= starRating ? "star.fill" : "star")
.font(.system(size: 14))
.foregroundColor(star <= starRating ? .yellow : .gray.opacity(0.4))
}.buttonStyle(.borderless)
}
}.id(starRating)
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
}
@ViewBuilder private var reservationButton: some View {
if isFetchingReservation {
ProgressView().scaleEffect(0.7)
} else if entry.reservation.isEmpty {
Text("—").font(.system(size: 15)).foregroundColor(.secondary)
} else {
let platform = ReservationService.platform(for: entry.reservation)
Button(action: {
if let url = URL(string: entry.reservation) { openURL(url) }
}) {
Text(platform == .resy ? "Resy" : "OpenTable")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(platform == .resy ? Color(red: 0.89, green: 0.17, blue: 0.17)
: Color(red: 0.85, green: 0.22, blue: 0.25))
.clipShape(RoundedRectangle(cornerRadius: 7))
}
.buttonStyle(.plain)
}
}
@ViewBuilder private var imageSection: some View {
Section(header: Text("Image").font(.system(size: 12))) {
if let uiImage = detailImage {
HStack {
Spacer()
Image(uiImage: uiImage).resizable().scaledToFill()
.frame(width: 120, height: 185).clipped().cornerRadius(6)
.onTapGesture { showFullscreenImage = true }
Spacer()
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else {
Text("No image").font(.system(size: 15)).foregroundColor(.secondary)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
@ViewBuilder private var moreInfoSection: some View {
Section(header: Text("More Info").font(.system(size: 12))) {
if !entry.confirmationLink.isEmpty {
if isLocalAttachment(entry.confirmationLink) {
Button(action: { openAttachment(entry.confirmationLink) }) {
HStack {
Image(systemName: attachmentIcon(for: entry.confirmationLink)).font(.system(size: 14))
Text(attachmentLabel(for: entry.confirmationLink)).font(.system(size: 15))
Spacer()
Image(systemName: "arrow.up.right.square").font(.system(size: 12))
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else {
Button(action: { if let url = URL(string: entry.confirmationLink) { openURL(url) } }) {
HStack {
Text("View More Info").font(.system(size: 15))
Spacer()
Image(systemName: "arrow.up.right.square").font(.system(size: 12))
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
} else {
Text("No confirmation link").font(.system(size: 15)).foregroundColor(.secondary)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
if entry.entryMode == .show || entry.entryMode == .event {
HStack(spacing: 16) {
Text("Info").font(.system(size: 12)).foregroundColor(.secondary)
Button {
guard !entry.playName.isEmpty, !isFetchingIBDB else { return }
isFetchingIBDB = true
Task {
defer { isFetchingIBDB = false }
if let url = await IBDBService.shared.findShowURL(named: entry.playName) {
openWebsite(url.absoluteString)
}
}
} label: {
if isFetchingIBDB {
ProgressView().scaleEffect(0.7)
} else {
Text("IBDB")
}
}
.buttonStyle(.plain)
.foregroundColor(entry.playName.isEmpty ? .secondary : .blue)
.disabled(entry.playName.isEmpty || isFetchingIBDB)
Button {
guard !entry.playName.isEmpty, !isFetchingSpectra else { return }
isFetchingSpectra = true
Task {
defer { isFetchingSpectra = false }
if let url = await SpectraService.shared.findShowURL(named: entry.playName) {
openWebsite(url.absoluteString)
}
}
} label: {
if isFetchingSpectra {
ProgressView().scaleEffect(0.7)
} else {
Text("Spectra")
}
}
.buttonStyle(.plain)
.foregroundColor(entry.playName.isEmpty ? .secondary : .blue)
.disabled(entry.playName.isEmpty || isFetchingSpectra)
Spacer()
}
.font(.system(size: 15))
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
if entry.entryMode == .food {
HStack(spacing: 16) {
Text("Reviews").font(.system(size: 12)).foregroundColor(.secondary)
Button("Yelp") { openWebsite(yelpSearchURL) }
.buttonStyle(.plain)
.foregroundColor(entry.playName.isEmpty ? .secondary : .blue)
.disabled(entry.playName.isEmpty)
Button("Eater NY") { openWebsite(eaterNYSearchURL) }
.buttonStyle(.plain)
.foregroundColor(entry.playName.isEmpty ? .secondary : .blue)
.disabled(entry.playName.isEmpty)
Button("The Infatuation") { openWebsite(infatuationSearchURL) }
.buttonStyle(.plain)
.foregroundColor(entry.playName.isEmpty ? .secondary : .blue)
.disabled(entry.playName.isEmpty)
Spacer()
}
.font(.system(size: 15))
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
@ViewBuilder private var remindersCalendarNotesSection: some View {
Section(header: Text("Reminders").font(.system(size: 12))) {
HStack {
Image(systemName: entry.remindersEnabled ? "bell.fill" : "bell.slash")
.font(.system(size: 14)).foregroundColor(entry.remindersEnabled ? .orange : .gray)
Text(entry.remindersEnabled ? "Reminders On" : "Reminders Off").font(.system(size: 15))
Spacer()
if entry.remindersEnabled && entry.dateTime > Date() {
Text("1wk • 1d • 6h • 1h").font(.system(size: 13)).foregroundColor(.secondary)
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
Section(header: Text("Calendar").font(.system(size: 12))) {
if entry.entryMode == .show || entry.entryMode == .event {
if !entry.consider {
Button(action: { addToCalendar() }) {
HStack {
Image(systemName: calendarStatus == .added ? "checkmark.circle.fill" : "calendar.badge.plus")
.font(.system(size: 14)).foregroundColor(calendarStatus == .added ? .green : .blue)
Text(calendarStatus == .added ? "Added to Calendar" : "Add to Calendar").font(.system(size: 15))
Spacer()
if calendarStatus != .added {
Image(systemName: "plus.circle").font(.system(size: 14)).foregroundColor(.blue)
}
}
}
.disabled(calendarStatus == .added)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else {
Text("No date — marked as Consider")
.font(.system(size: 15)).foregroundColor(.secondary)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
} else {
if showDatePicker {
DatePicker("Date & Time", selection: $entry.dateTime, displayedComponents: [.date, .hourAndMinute])
.font(.system(size: 15))
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
Button(action: { addToCalendar() }) {
HStack {
Image(systemName: calendarStatus == .added ? "checkmark.circle.fill" : "calendar.badge.plus")
.font(.system(size: 14)).foregroundColor(calendarStatus == .added ? .green : .blue)
Text(calendarStatus == .added ? "Added to Calendar" : "Add to Calendar").font(.system(size: 15))
Spacer()
if calendarStatus != .added {
Image(systemName: "plus.circle").font(.system(size: 14)).foregroundColor(.blue)
}
}
}
.disabled(calendarStatus == .added)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else {
Button(action: { showDatePicker = true; entry.hasCustomDate = true }) {
HStack {
Image(systemName: "calendar.badge.plus").font(.system(size: 14)).foregroundColor(.blue)
Text("Add to Calendar").font(.system(size: 15)).foregroundColor(.blue)
Spacer()
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
Section(header: Text("Notes").font(.system(size: 12))) {
if !entry.notes.isEmpty {
if isLocalAttachment(entry.notes) {
Button(action: { openAttachment(entry.notes) }) {
HStack {
Image(systemName: "envelope").font(.system(size: 14)).foregroundColor(.blue)
Text("Open Email").font(.system(size: 15)).foregroundColor(.blue)
Spacer()
Image(systemName: "arrow.up.right.square").font(.system(size: 12)).foregroundColor(.blue)
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else if let url = extractURL(from: entry.notes) {
Button(action: { openURL(url) }) {
HStack {
Text(entry.notes).font(.system(size: 15)).foregroundColor(.blue).lineLimit(3)
Spacer()
Image(systemName: "arrow.up.right.square").font(.system(size: 12)).foregroundColor(.blue)
}
}
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
} else {
Text(entry.notes).font(.system(size: 15))
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
} else {
Text("No notes").font(.system(size: 15)).foregroundColor(.secondary)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
}
}
}
@ViewBuilder private var nearbyBusesSection: some View {
if busRoutesLoaded && !nearbyBusRoutes.isEmpty {
Section(header: Text("Nearby Buses").font(.system(size: 12))) {
let columns = [GridItem(.adaptive(minimum: 50), spacing: 6)]
LazyVGrid(columns: columns, alignment: .leading, spacing: 6) {
ForEach(nearbyBusRoutes, id: \.self) { route in
Text(route)
.font(.system(size: 11, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(busRouteColor(for: route),
in: RoundedRectangle(cornerRadius: 4))
}
}
.padding(.vertical, 4)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.contentShape(Rectangle())
.onTapGesture { onOpenBusMap?(entry.address) }
}
}
}
@ViewBuilder private var nearbySubwaysSection: some View {
if subwayLinesLoaded && !nearbySubwayLines.isEmpty {
Section(header: Text("Nearby Subways").font(.system(size: 12))) {
let columns = [GridItem(.adaptive(minimum: 32), spacing: 6)]
LazyVGrid(columns: columns, alignment: .leading, spacing: 6) {
ForEach(nearbySubwayLines, id: \.self) { line in
Text(line)
.font(.system(size: 11, weight: .bold))
.foregroundColor(subwayLineTextColor(for: line))
.frame(minWidth: 24, minHeight: 24)
.padding(.horizontal, 4)
.background(subwayLineColor(for: line),
in: Circle())
}
}
.padding(.vertical, 4)
.listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16))
.contentShape(Rectangle())
.onTapGesture { onOpenSubwayMap?(entry.address) }
}
}
}
private var detailList: some View {
List {
infoSection
imageSection
moreInfoSection
nearbySubwaysSection
nearbyBusesSection
remindersCalendarNotesSection
}
.listStyle(.insetGrouped)
.listSectionSpacing(4)
.environment(\.defaultMinListRowHeight, 0)
.onChange(of: rating) { _, v in entry.rating = v }
.onChange(of: entry.rating){ _, v in if rating != v { rating = v } }
.onChange(of: beenThere) { _, v in entry.beenThere = v }
.onChange(of: entry.beenThere){ _, v in if beenThere != v { beenThere = v } }
.onChange(of: starRating) { _, v in entry.starRating = v }
.onChange(of: entry.starRating){ _, v in if starRating != v { starRating = v } }
.navigationTitle("Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Edit") { activeSheet = .edit }.font(.system(size: 16))
}
}
}
var body: some View {
detailList
.sheet(item: $activeSheet) { sheet in
switch sheet {
case .edit:
EditEntryView(entry: $entry, onAddressChanged: onAddressChanged)
case .eml(let url):
ShareSheet(activityItems: [url])
case .pdf(let url):
QuickLookPreview(url: url)
}
} | `EntryDetailView` struct | Defines the `EntryDetailView` struct. Conforms to View. |
| .fullScreenCover(isPresented: $showFullscreenImage) {
if let uiImage = detailImage {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button {
showFullscreenImage = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 30))
.foregroundStyle(.white, .black.opacity(0.5))
.padding(16)
}
}
}
}
.alert("Calendar", isPresented: $showingCalendarAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(calendarAlertMessage)
}
.task {
copyAttachmentToICloudIfNeeded(entry.confirmationLink)
}
.task {
// Silently back-fill phone and/or website if missing and a More Info link exists
let needsPhone = entry.phone.isEmpty
let needsWebsite = entry.website.isEmpty
guard (needsPhone || needsWebsite), !entry.confirmationLink.isEmpty else { return }
if needsPhone { isFetchingPhone = true }
if needsWebsite { isFetchingWebsite = true }
PhoneExtractor.extract(for: entry) { phone, website in
if needsPhone { isFetchingPhone = false }
if needsWebsite { isFetchingWebsite = false }
if !phone.isEmpty { entry.phone = phone }
if !website.isEmpty { entry.website = website }
}
}
.task {
// Back-fill reservation URL for existing food entries that don't have one yet.
guard entry.entryMode == .food, entry.reservation.isEmpty else { return }
isFetchingReservation = true
if let resURL = await ReservationService.lookup(for: entry) {
entry.reservation = resURL
}
isFetchingReservation = false
}
.task(id: entry.reservation) {
// Once a reservation URL is known, fetch the restaurant description and
// put it in Notes — but only if Notes are currently empty.
guard entry.entryMode == .food,
entry.notes.isEmpty,
!entry.reservation.isEmpty else { return }
if let desc = await ReservationService.fetchDescription(for: entry) {
entry.notes = desc
}
}
.task {
// Load nearby bus routes from the bundled GTFS data.
// Parses address via geocoder if no lat/lon is stored yet.
guard !entry.address.isEmpty || entry.latitude != nil else { return }
let svc = BusStopService.shared
// Ensure the service data is loaded (it's a background load; spin-wait briefly)
var attempts = 0
while !svc.isLoaded && attempts < 20 { // up to 2s
try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1
}
guard svc.isLoaded else { return }
let coordinate: CLLocationCoordinate2D
if let lat = entry.latitude, let lon = entry.longitude {
coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
} else if !entry.address.isEmpty {
// Geocode the address
let searchReq = MKLocalSearch.Request()
searchReq.naturalLanguageQuery = entry.address
guard let response = try? await MKLocalSearch(request: searchReq).start(),
let item = response.mapItems.first else { return }
coordinate = item.location.coordinate
} else {
return
}
let routes = svc.routes(near: coordinate, radiusMeters: 400)
await MainActor.run {
nearbyBusRoutes = routes
busRoutesLoaded = true
}
}
.task {
// Load nearby subway lines.
guard !entry.address.isEmpty || entry.latitude != nil else { return }
let svc = SubwayStationService.shared
var attempts = 0
while !svc.isLoaded && attempts < 40 { // up to 4s (subway data loads slower)
try? await Task.sleep(nanoseconds: 100_000_000)
attempts += 1
}
guard svc.isLoaded else { return }
let coordinate: CLLocationCoordinate2D
if let lat = entry.latitude, let lon = entry.longitude {
coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
} else if !entry.address.isEmpty {
let searchReq = MKLocalSearch.Request()
searchReq.naturalLanguageQuery = entry.address
guard let response = try? await MKLocalSearch(request: searchReq).start(),
let item = response.mapItems.first else { return }
coordinate = item.location.coordinate
} else {
return
}
let lines = svc.lines(near: coordinate, radiusMeters: 400)
await MainActor.run {
nearbySubwayLines = lines
subwayLinesLoaded = true
}
}
.task(id: entry.imageFilename ?? "") {
// Re-runs whenever imageFilename changes (e.g. after editing and saving a new image).
// 130-pt display × 3× screen = 390 px; use 400 px for headroom.
if let filename = entry.imageFilename, !filename.isEmpty {
// Try disk first; fall back to in-memory imageData if the file hasn't been
// written yet (race between Save dismiss and the async store.save() write).
if let img = await EntryStore.loadThumbnail(filename: filename, maxPixelSize: 400) {
detailImage = img
} else if let data = entry.imageData {
detailImage = await Task.detached(priority: .userInitiated) {
let resized = EntryStore.resizedImageData(data, maxDimension: 400)
guard let source = CGImageSourceCreateWithData(resized as CFData, nil),
let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none }
return UIImage(cgImage: cg)
}.value
}
} else if let data = entry.imageData {
detailImage = await Task.detached(priority: .userInitiated) {
let resized = EntryStore.resizedImageData(data, maxDimension: 400)
guard let source = CGImageSourceCreateWithData(resized as CFData, nil),
let cg = CGImageSourceCreateImageAtIndex(source, 0, nil) else { return UIImage?.none }
return UIImage(cgImage: cg)
}.value
}
}
} | Code block | See source code for full implementation. |