feat(trip): add Open in Apple Maps button to trip detail map
- Add floating action button (bottom-right) on trip map - Create AppleMapsLauncher service to handle route opening - Collect all routable stops (trip stops + custom items with coords) - Handle >16 waypoints with alert offering to open in parts - Silently skip stops without coordinates Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
136
SportsTime/Core/Services/AppleMapsLauncher.swift
Normal file
136
SportsTime/Core/Services/AppleMapsLauncher.swift
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
//
|
||||||
|
// AppleMapsLauncher.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Service for opening trip routes in Apple Maps
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
/// Result of preparing waypoints for Apple Maps
|
||||||
|
enum AppleMapsLaunchResult {
|
||||||
|
/// Single route can be opened directly (≤16 waypoints)
|
||||||
|
case ready([MKMapItem])
|
||||||
|
|
||||||
|
/// Multiple routes needed (>16 waypoints)
|
||||||
|
case multipleRoutes([[MKMapItem]])
|
||||||
|
|
||||||
|
/// No routable waypoints available
|
||||||
|
case noWaypoints
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles opening trip routes in Apple Maps with chunking for large trips
|
||||||
|
struct AppleMapsLauncher {
|
||||||
|
|
||||||
|
/// Maximum waypoints Apple Maps supports in a single route
|
||||||
|
static let maxWaypoints = 16
|
||||||
|
|
||||||
|
/// Represents a waypoint for the route
|
||||||
|
struct Waypoint {
|
||||||
|
let name: String
|
||||||
|
let coordinate: CLLocationCoordinate2D
|
||||||
|
let sortKey: (day: Int, order: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Prepares waypoints from trip stops and custom items
|
||||||
|
/// - Parameters:
|
||||||
|
/// - stops: Trip stops with optional coordinates
|
||||||
|
/// - customItems: Custom itinerary items that may have coordinates
|
||||||
|
/// - Returns: Launch result indicating if route is ready or needs chunking
|
||||||
|
static func prepare(
|
||||||
|
stops: [TripStop],
|
||||||
|
customItems: [ItineraryItem]
|
||||||
|
) -> AppleMapsLaunchResult {
|
||||||
|
let waypoints = collectWaypoints(stops: stops, customItems: customItems)
|
||||||
|
|
||||||
|
guard !waypoints.isEmpty else {
|
||||||
|
return .noWaypoints
|
||||||
|
}
|
||||||
|
|
||||||
|
let mapItems = waypoints.map { waypoint -> MKMapItem in
|
||||||
|
let placemark = MKPlacemark(coordinate: waypoint.coordinate)
|
||||||
|
let item = MKMapItem(placemark: placemark)
|
||||||
|
item.name = waypoint.name
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
if mapItems.count <= maxWaypoints {
|
||||||
|
return .ready(mapItems)
|
||||||
|
} else {
|
||||||
|
let chunks = chunk(mapItems, size: maxWaypoints)
|
||||||
|
return .multipleRoutes(chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a single route in Apple Maps
|
||||||
|
/// - Parameter mapItems: Array of map items (≤16)
|
||||||
|
static func open(_ mapItems: [MKMapItem]) {
|
||||||
|
guard !mapItems.isEmpty else { return }
|
||||||
|
|
||||||
|
MKMapItem.openMaps(with: mapItems, launchOptions: [
|
||||||
|
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens a specific chunk of a multi-route trip
|
||||||
|
/// - Parameters:
|
||||||
|
/// - chunks: All route chunks
|
||||||
|
/// - index: Which chunk to open (0-based)
|
||||||
|
static func open(chunk index: Int, of chunks: [[MKMapItem]]) {
|
||||||
|
guard index >= 0 && index < chunks.count else { return }
|
||||||
|
open(chunks[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private static func collectWaypoints(
|
||||||
|
stops: [TripStop],
|
||||||
|
customItems: [ItineraryItem]
|
||||||
|
) -> [Waypoint] {
|
||||||
|
var waypoints: [Waypoint] = []
|
||||||
|
|
||||||
|
// Add trip stops with coordinates
|
||||||
|
for (index, stop) in stops.enumerated() {
|
||||||
|
guard let coordinate = stop.coordinate else { continue }
|
||||||
|
|
||||||
|
waypoints.append(Waypoint(
|
||||||
|
name: stop.city,
|
||||||
|
coordinate: coordinate,
|
||||||
|
// Use stop number for day approximation, index for order
|
||||||
|
sortKey: (day: stop.stopNumber, order: Double(index))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mappable custom items
|
||||||
|
for item in customItems {
|
||||||
|
guard let info = item.customInfo,
|
||||||
|
let coordinate = info.coordinate else { continue }
|
||||||
|
|
||||||
|
waypoints.append(Waypoint(
|
||||||
|
name: info.title,
|
||||||
|
coordinate: coordinate,
|
||||||
|
sortKey: (day: item.day, order: item.sortOrder)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by day, then by sort order within day
|
||||||
|
waypoints.sort { lhs, rhs in
|
||||||
|
if lhs.sortKey.day != rhs.sortKey.day {
|
||||||
|
return lhs.sortKey.day < rhs.sortKey.day
|
||||||
|
}
|
||||||
|
return lhs.sortKey.order < rhs.sortKey.order
|
||||||
|
}
|
||||||
|
|
||||||
|
return waypoints
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func chunk(_ items: [MKMapItem], size: Int) -> [[MKMapItem]] {
|
||||||
|
stride(from: 0, to: items.count, by: size).map { start in
|
||||||
|
Array(items[start..<min(start + size, items.count)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,10 @@ struct TripDetailView: View {
|
|||||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||||
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
|
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
|
||||||
|
|
||||||
|
// Apple Maps state
|
||||||
|
@State private var showMultiRouteAlert = false
|
||||||
|
@State private var multiRouteChunks: [[MKMapItem]] = []
|
||||||
|
|
||||||
private let exportService = ExportService()
|
private let exportService = ExportService()
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
|
||||||
@@ -99,6 +103,16 @@ struct TripDetailView: View {
|
|||||||
tripId: trip.id,
|
tripId: trip.id,
|
||||||
saveItineraryItem: saveItineraryItem
|
saveItineraryItem: saveItineraryItem
|
||||||
))
|
))
|
||||||
|
.alert("Large Trip Route", isPresented: $showMultiRouteAlert) {
|
||||||
|
ForEach(multiRouteChunks.indices, id: \.self) { index in
|
||||||
|
Button("Open Part \(index + 1) of \(multiRouteChunks.count)") {
|
||||||
|
AppleMapsLauncher.open(chunk: index, of: multiRouteChunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
||||||
|
}
|
||||||
.onAppear { checkIfSaved() }
|
.onAppear { checkIfSaved() }
|
||||||
.task {
|
.task {
|
||||||
await loadGamesIfNeeded()
|
await loadGamesIfNeeded()
|
||||||
@@ -337,6 +351,24 @@ struct TripDetailView: View {
|
|||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
.padding(.trailing, 12)
|
.padding(.trailing, 12)
|
||||||
}
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
// Open in Apple Maps button
|
||||||
|
Button {
|
||||||
|
openInAppleMaps()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "map.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(12)
|
||||||
|
.background(Theme.warmOrange)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 90) // Above the gradient
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
.accessibilityLabel("Open in Apple Maps")
|
||||||
|
.accessibilityHint("Opens this trip route in Apple Maps")
|
||||||
|
}
|
||||||
|
|
||||||
// Gradient overlay at bottom
|
// Gradient overlay at bottom
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
@@ -1189,6 +1221,26 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openInAppleMaps() {
|
||||||
|
let result = AppleMapsLauncher.prepare(
|
||||||
|
stops: trip.stops,
|
||||||
|
customItems: mappableCustomItems
|
||||||
|
)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .ready(let mapItems):
|
||||||
|
AppleMapsLauncher.open(mapItems)
|
||||||
|
|
||||||
|
case .multipleRoutes(let chunks):
|
||||||
|
multiRouteChunks = chunks
|
||||||
|
showMultiRouteAlert = true
|
||||||
|
|
||||||
|
case .noWaypoints:
|
||||||
|
// No routable locations - button shouldn't be visible but handle gracefully
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func saveTrip() {
|
private func saveTrip() {
|
||||||
// Check trip limit for free users
|
// Check trip limit for free users
|
||||||
if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit {
|
if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit {
|
||||||
|
|||||||
Reference in New Issue
Block a user