| struct WatchContentView: View {
@EnvironmentObject var store: WatchEntryStore
@State private var sortByNearest = false
// MARK: - Distance helpers
private func distanceText(for entry: WatchEntry) -> String? {
guard let userLoc = store.userLocation,
let lat = entry.latitude, let lon = entry.longitude else { return nil }
let meters = userLoc.distance(from: CLLocation(latitude: lat, longitude: lon))
let miles = meters / 1609.34
if miles < 0.1 { return "nearby" }
if miles < 10 { return String(format: "%.1f mi", miles) }
return String(format: "%.0f mi", miles)
}
private func distanceMeters(for entry: WatchEntry) -> Double {
guard let userLoc = store.userLocation,
let lat = entry.latitude, let lon = entry.longitude else { return .greatestFiniteMagnitude }
return userLoc.distance(from: CLLocation(latitude: lat, longitude: lon))
}
// MARK: - Default sort (priority / star groups)
var toTryByPriority: [(priority: Int, entries: [WatchEntry])] {
[5, 4, 3, 2, 1, 0].compactMap { priority in
let group = store.entries
.filter { !$0.beenThere && $0.rating == priority }
.sorted { $0.playName.lowercased() < $1.playName.lowercased() }
return group.isEmpty ? nil : (priority: priority, entries: group)
}
}
var beenByStarRating: [(starRating: Int, entries: [WatchEntry])] {
[5, 4, 3, 2, 1, 0].compactMap { stars in
let group = store.entries
.filter { $0.beenThere && $0.starRating == stars }
.sorted { $0.playName.lowercased() < $1.playName.lowercased() }
return group.isEmpty ? nil : (starRating: stars, entries: group)
}
}
// MARK: - Nearest sort (flat lists sorted by distance)
var toTryByDistance: [WatchEntry] {
store.entries
.filter { !$0.beenThere }
.sorted { distanceMeters(for: $0) < distanceMeters(for: $1) }
}
var beenByDistance: [WatchEntry] {
store.entries
.filter { $0.beenThere }
.sorted { distanceMeters(for: $0) < distanceMeters(for: $1) }
}
@State private var syncMessage: String? = nil
var body: some View {
if !store.isLoaded {
// Cache hasn't been read yet — show nothing to avoid a flash of the empty state
Color.clear
} else if store.entries.isEmpty {
VStack(spacing: 6) {
Image(systemName: "fork.knife")
.font(.system(size: 28))
.foregroundColor(.orange)
Text("Nothing Here")
.font(.headline)
if let msg = syncMessage {
Text(msg)
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
} else {
Text("Open Spots on your iPhone")
.font(.caption2)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
Button(action: {
let sent = store.requestSync()
syncMessage = sent ? "Requesting…" : "Open Spots on iPhone first"
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
syncMessage = nil
}
}) {
Label("Refresh", systemImage: "arrow.clockwise")
.font(.caption2)
}
.buttonStyle(.borderless)
.padding(.top, 4)
}
.navigationTitle("Spots")
} else {
VStack(spacing: 0) {
ZStack {
Text("SPOTS")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(lavender)
.frame(maxWidth: .infinity, alignment: .center)
.allowsHitTesting(false)
HStack {
Button(action: {
sortByNearest.toggle()
if sortByNearest { store.startLocationIfNeeded() }
}) {
Image(systemName: sortByNearest ? "location.fill" : "location")
.font(.system(size: 16))
.foregroundColor(sortByNearest ? Color(red: 1, green: 0.388, blue: 0.278) : .white)
}
.buttonStyle(.borderless)
.padding(.leading, 30)
Spacer()
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
List {
if sortByNearest {
let allByDistance = store.entries
.sorted { distanceMeters(for: $0) < distanceMeters(for: $1) }
ForEach(allByDistance) { entry in
NavigationLink(destination: WatchEntryDetailView(entry: entry)) {
WatchEntryRowView(entry: entry, showAsBeen: entry.beenThere, distanceText: distanceText(for: entry))
}
}
} else {
if !toTryByPriority.isEmpty {
Section("Try") {
ForEach(Array(toTryByPriority.enumerated()), id: \.element.priority) { idx, group in
HStack(spacing: 3) {
ForEach(1...5, id: \.self) { dot in
Image(systemName: dot <= group.priority ? "circle.fill" : "circle")
.font(.system(size: 8))
.foregroundColor(dot <= group.priority ? .white : .gray.opacity(0.3))
}
}
.frame(height: 14)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: idx == 0 ? 2 : 4, leading: 8, bottom: 0, trailing: 8))
ForEach(group.entries) { entry in
NavigationLink(destination: WatchEntryDetailView(entry: entry)) {
WatchEntryRowView(entry: entry, showAsBeen: false, showPriority: false)
}
}
}
}
}
if !beenByStarRating.isEmpty {
Section("Been") {
ForEach(Array(beenByStarRating.enumerated()), id: \.element.starRating) { idx, group in
HStack(spacing: 3) {
ForEach(1...5, id: \.self) { star in
Image(systemName: star <= group.starRating ? "star.fill" : "star")
.font(.system(size: 8))
.foregroundColor(star <= group.starRating ? .yellow : .gray.opacity(0.3))
}
}
.frame(height: 14)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: idx == 0 ? 2 : 4, leading: 8, bottom: 0, trailing: 8))
ForEach(group.entries) { entry in
NavigationLink(destination: WatchEntryDetailView(entry: entry)) {
WatchEntryRowView(entry: entry, showAsBeen: true, showPriority: false)
}
}
}
}
}
}
}
.listStyle(.plain)
.environment(\.defaultMinListRowHeight, 0)
}
.navigationTitle("")
.toolbar(.hidden, for: .navigationBar)
.ignoresSafeArea(edges: .top)
}
}
} | `WatchContentView` struct | Defines the `WatchContentView` struct. Conforms to View. |