Files
Sportstime/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

471 lines
16 KiB
Swift

//
// PlaceSearchSheet.swift
// SportsTime
//
// Focused place search sheet for custom itinerary items
//
import SwiftUI
import MapKit
/// A sheet for searching and selecting a place via MapKit
struct PlaceSearchSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
/// Optional coordinate to bias search results toward (e.g., the trip stop for this day)
var regionCoordinate: CLLocationCoordinate2D?
/// Callback when a location is selected
let onSelect: (MKMapItem) -> Void
@State private var searchQuery = ""
@State private var searchResults: [MKMapItem] = []
@State private var isSearching = false
@State private var searchError: String?
@State private var debounceTask: Task<Void, Never>?
@FocusState private var isSearchFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 0) {
searchBar
.padding(Theme.Spacing.md)
if isSearching {
loadingView
} else if let error = searchError {
errorView(error)
} else if searchResults.isEmpty && !searchQuery.isEmpty {
emptyResultsView
} else if searchResults.isEmpty {
initialStateView
} else {
resultsList
}
}
.navigationTitle("Add Location")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.onAppear {
isSearchFocused = true
}
.onDisappear {
debounceTask?.cancel()
}
}
// MARK: - Search Bar
private var searchBar: some View {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
TextField(searchPlaceholder, text: $searchQuery)
.textFieldStyle(.plain)
.focused($isSearchFocused)
.onSubmit {
debounceTask?.cancel()
performSearch()
}
.onChange(of: searchQuery) {
debounceTask?.cancel()
let query = searchQuery
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
searchResults = []
return
}
debounceTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
performSearch()
}
}
.accessibilityLabel("Search for places")
.accessibilityHint(searchPlaceholder)
if !searchQuery.isEmpty {
Button {
searchQuery = ""
searchResults = []
searchError = nil
debounceTask?.cancel()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.minimumHitTarget()
.accessibilityLabel("Clear search")
}
// Explicit search button
if !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty {
Button {
debounceTask?.cancel()
performSearch()
} label: {
Text("Search")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Theme.warmOrange)
.clipShape(Capsule())
}
.accessibilityLabel("Search for \(searchQuery)")
}
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
private let searchPlaceholder = "Search for a place..."
// MARK: - Results List
private var resultsList: some View {
ScrollView {
LazyVStack(spacing: Theme.Spacing.xs) {
ForEach(searchResults, id: \.self) { place in
PlaceRow(place: place, colorScheme: colorScheme) {
onSelect(place)
dismiss()
}
}
}
.padding(.horizontal, Theme.Spacing.md)
}
}
// MARK: - Loading View
private var loadingView: some View {
VStack(spacing: Theme.Spacing.md) {
Spacer()
ProgressView()
.scaleEffect(1.2)
Text("Searching...")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
}
.frame(maxWidth: .infinity)
}
// MARK: - Empty Results View
private var emptyResultsView: some View {
VStack(spacing: Theme.Spacing.md) {
Spacer()
Image(systemName: "mappin.slash")
.font(.largeTitle)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No places found")
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Try a different search term")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Button("Add without location") {
dismiss()
}
.buttonStyle(.bordered)
.tint(Theme.warmOrange)
.padding(.top, Theme.Spacing.sm)
.accessibilityHint("Returns to the add item form without a location")
Spacer()
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
}
// MARK: - Initial State View
private var initialStateView: some View {
ScrollView {
VStack(spacing: Theme.Spacing.xl) {
// Illustration
VStack(spacing: Theme.Spacing.sm) {
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.1))
.frame(width: 80, height: 80)
Image(systemName: "map.fill")
.font(.system(size: 32))
.foregroundStyle(Theme.warmOrange)
}
.padding(.top, Theme.Spacing.xxl)
Text("Find a Place")
.font(.title3)
.fontWeight(.bold)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Search for restaurants, attractions, hotels, and more")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.lg)
}
// Suggestion chips
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Try searching for")
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(Theme.textMuted(colorScheme))
.textCase(.uppercase)
.padding(.horizontal, Theme.Spacing.xs)
FlowLayout(spacing: Theme.Spacing.xs) {
ForEach(searchSuggestions, id: \.label) { suggestion in
Button {
searchQuery = suggestion.query
debounceTask?.cancel()
performSearch()
} label: {
Label(suggestion.label, systemImage: suggestion.icon)
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Theme.cardBackground(colorScheme))
.clipShape(Capsule())
.overlay(
Capsule()
.strokeBorder(Theme.surfaceGlow(colorScheme), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(.horizontal, Theme.Spacing.md)
}
}
}
private struct SearchSuggestion {
let label: String
let query: String
let icon: String
}
private var searchSuggestions: [SearchSuggestion] {
[
SearchSuggestion(label: "Restaurants", query: "restaurants", icon: "fork.knife"),
SearchSuggestion(label: "Hotels", query: "hotels", icon: "bed.double.fill"),
SearchSuggestion(label: "Coffee", query: "coffee shops", icon: "cup.and.saucer.fill"),
SearchSuggestion(label: "Parking", query: "parking", icon: "car.fill"),
SearchSuggestion(label: "Gas Stations", query: "gas stations", icon: "fuelpump.fill"),
SearchSuggestion(label: "Attractions", query: "tourist attractions", icon: "star.fill"),
SearchSuggestion(label: "Bars", query: "bars", icon: "wineglass.fill"),
SearchSuggestion(label: "Parks", query: "parks", icon: "leaf.fill"),
]
}
// MARK: - Error View
private func errorView(_ error: String) -> some View {
VStack(spacing: Theme.Spacing.md) {
Spacer()
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundStyle(.orange)
.accessibilityHidden(true)
Text("Search unavailable")
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(error)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
HStack(spacing: Theme.Spacing.md) {
Button("Add without location") {
dismiss()
}
.buttonStyle(.bordered)
Button("Retry") {
performSearch()
}
.buttonStyle(.borderedProminent)
.tint(Theme.warmOrange)
}
.padding(.top, Theme.Spacing.sm)
Spacer()
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
}
// MARK: - Search
private func performSearch() {
let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespaces)
guard !trimmedQuery.isEmpty else { return }
isSearching = true
searchError = nil
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = trimmedQuery
// Bias results toward the trip stop's city if available
if let coordinate = regionCoordinate {
request.region = MKCoordinateRegion(
center: coordinate,
latitudinalMeters: 50_000,
longitudinalMeters: 50_000
)
}
let search = MKLocalSearch(request: request)
search.start { response, error in
Task { @MainActor in
isSearching = false
if let error {
searchError = error.localizedDescription
return
}
if let response {
searchResults = response.mapItems
}
}
}
}
}
// MARK: - Flow Layout
private struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
for (index, position) in result.positions.enumerated() {
subviews[index].place(
at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y),
proposal: .unspecified
)
}
}
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
let maxWidth = proposal.width ?? .infinity
var positions: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth && currentX > 0 {
currentX = 0
currentY += lineHeight + spacing
lineHeight = 0
}
positions.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
}
return (positions, CGSize(width: maxWidth, height: currentY + lineHeight))
}
}
// MARK: - Place Row
private struct PlaceRow: View {
let place: MKMapItem
let colorScheme: ColorScheme
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "mappin.circle.fill")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
VStack(alignment: .leading, spacing: Theme.Spacing.xxs) {
Text(place.name ?? "Unknown Place")
.font(.body)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
if let address = formattedAddress {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
.lineLimit(1)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
.buttonStyle(.plain)
.accessibilityLabel(place.name ?? "Unknown Place")
.accessibilityHint(formattedAddress ?? "Tap to select this location")
}
private var formattedAddress: String? {
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
return shortAddress
}
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
return fullAddress
}
return place.addressRepresentations?.cityWithContext
}
}
#Preview {
PlaceSearchSheet { place in
#if DEBUG
print("Selected: \(place.name ?? "unknown")")
#endif
}
}