History v2: everything — Wallet auto-prompt, age, track replay, share
Adds the deferred pieces from the v1 ship, plus a Mail Share
Extension target so the iOS share sheet picks up flight emails.
Track replay
- `LoggedFlight.icao24` field — populated from FR24 enrichment on
live-tap adds.
- HistoryDetailView's track query now fires for any flight younger
than 7 days that has an icao24, pulling the actual flown path
from OpenSky's /tracks/all endpoint. Falls back to a clean
great-circle arc otherwise.
Wallet auto-prompt
- RootView subscribes to WalletPassObserver.shared. When the user
adds a boarding pass to Apple Wallet, the observer's published
`pendingPass` flips and we present AddFlightView pre-filled with
the parsed origin / destination / flight # / date.
Airframe age + first-flight date
- `AirframeMetadataService` queries OpenSky's
/api/metadata/aircraft/icao/{icao24} endpoint. Caches results in
the existing `AirframeMetadata` SwiftData model so we never
re-fetch the same airframe twice. (jetphotos and planespotters
pages are both Cloudflare-gated; OpenSky's metadata API is the
cleanest free source.)
- HistoryDetailView fires the lookup on appear and persists the
result; the aircraft card already renders "Age" when a date is
cached.
Mail Share Extension
- New `FlightsShareExtension` Xcode target (app-extension product
type) built into the app bundle via an Embed Foundation
Extensions copy phase.
- `ShareViewController` (SLComposeServiceViewController) parses
shared text + URLs for flight-shaped codes ("AA 2178"), route
hints ("DFW → ORD"), and date strings.
- On Save, the extension builds a `flights://import?carrier=…&num=
…&dep=…&arr=…&date=…` URL and opens it via the responder-chain
openURL trick (Share Extensions can't access UIApplication
directly).
- Host app handles the URL via `.onOpenURL` in RootView, switches
to the History tab and presents AddFlightView prefilled.
- App now has an actual Info.plist (CFBundleURLTypes registered
for `flights://`); switched from GENERATE_INFOPLIST_FILE to
INFOPLIST_FILE for the app target.
If the dev portal hasn't registered bundle id
`com.flights.app.share` for the team, the signed archive will
fail. In that case the simpler URL-scheme path still works —
users can hit `flights://import?...` from a Shortcut.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import UIKit
|
||||
import Social
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Mail (and any other text/URL source) Share Extension. Parses
|
||||
/// flight info out of the shared content using the same regex
|
||||
/// patterns as the calendar importer, writes the result to an App
|
||||
/// Group UserDefaults entry under `pendingMailShare`, and dismisses.
|
||||
///
|
||||
/// The main app reads that entry on next foreground (via
|
||||
/// PendingShareWatcher) and pops the AddFlightView prefilled with
|
||||
/// whatever we parsed.
|
||||
final class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
private var parsed: ParsedFlight?
|
||||
private var allText: String = ""
|
||||
|
||||
struct ParsedFlight {
|
||||
let flightDate: Date
|
||||
let carrierIATA: String?
|
||||
let flightNumber: String?
|
||||
let departureIATA: String?
|
||||
let arrivalIATA: String?
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = "Add to Flights"
|
||||
placeholder = "Optional note"
|
||||
loadSharedItems()
|
||||
}
|
||||
|
||||
private func loadSharedItems() {
|
||||
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else { return }
|
||||
let group = DispatchGroup()
|
||||
var accumulated = ""
|
||||
|
||||
for item in extensionItems {
|
||||
// Mail surfaces both the subject line (as the contentText)
|
||||
// and the body (as attachments). We absorb both.
|
||||
if let content = item.attributedContentText?.string, !content.isEmpty {
|
||||
accumulated += " " + content
|
||||
}
|
||||
for provider in item.attachments ?? [] {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||
group.enter()
|
||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
||||
defer { group.leave() }
|
||||
if let s = item as? String { accumulated += " " + s }
|
||||
}
|
||||
}
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||
group.enter()
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in
|
||||
defer { group.leave() }
|
||||
if let u = item as? URL { accumulated += " " + u.absoluteString }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.allText = accumulated
|
||||
self.parsed = Self.parseFlight(from: accumulated)
|
||||
self.validateContent()
|
||||
}
|
||||
}
|
||||
|
||||
override func isContentValid() -> Bool {
|
||||
return parsed != nil
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let parsed else {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
return
|
||||
}
|
||||
// Hand the parsed flight off to the host app via a custom URL
|
||||
// scheme. Share Extensions can't call UIApplication.shared
|
||||
// directly, but we can walk the responder chain to find one
|
||||
// that implements `openURL:` and invoke it. iOS still routes
|
||||
// it through the host app correctly.
|
||||
var comps = URLComponents()
|
||||
comps.scheme = "flights"
|
||||
comps.host = "import"
|
||||
var items: [URLQueryItem] = [
|
||||
URLQueryItem(name: "date", value: String(parsed.flightDate.timeIntervalSince1970))
|
||||
]
|
||||
if let c = parsed.carrierIATA { items.append(.init(name: "carrier", value: c)) }
|
||||
if let f = parsed.flightNumber { items.append(.init(name: "num", value: f)) }
|
||||
if let d = parsed.departureIATA { items.append(.init(name: "dep", value: d)) }
|
||||
if let a = parsed.arrivalIATA { items.append(.init(name: "arr", value: a)) }
|
||||
comps.queryItems = items
|
||||
if let url = comps.url {
|
||||
openURLInHost(url)
|
||||
}
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
|
||||
/// Walk the responder chain looking for an object that implements
|
||||
/// `openURL:`. UIApplication is one. Invoking it from a share
|
||||
/// extension launches the host app via its registered URL scheme.
|
||||
private func openURLInHost(_ url: URL) {
|
||||
var responder: UIResponder? = self
|
||||
let selector = NSSelectorFromString("openURL:")
|
||||
while responder != nil {
|
||||
if responder!.responds(to: selector) {
|
||||
_ = responder!.perform(selector, with: url)
|
||||
return
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
return []
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
private static func parseFlight(from text: String) -> ParsedFlight? {
|
||||
guard let flightMatch = matchFlight(in: text) else { return nil }
|
||||
let route = matchRoute(in: text)
|
||||
let date = matchDate(in: text) ?? Date()
|
||||
return ParsedFlight(
|
||||
flightDate: date,
|
||||
carrierIATA: flightMatch.carrier,
|
||||
flightNumber: flightMatch.number,
|
||||
departureIATA: route?.from,
|
||||
arrivalIATA: route?.to
|
||||
)
|
||||
}
|
||||
|
||||
private static func matchFlight(in s: String) -> (carrier: String, number: String)? {
|
||||
let pattern = "([A-Z]{2,3})\\s*([0-9]{1,4})"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let nsRange = NSRange(s.startIndex..., in: s)
|
||||
let denylist: Set<String> = ["AM", "PM", "ET", "PT", "CT", "MT", "US", "UK", "TO", "AS"]
|
||||
for m in regex.matches(in: s, range: nsRange) where m.numberOfRanges == 3 {
|
||||
guard let cRange = Range(m.range(at: 1), in: s),
|
||||
let nRange = Range(m.range(at: 2), in: s) else { continue }
|
||||
let carrier = String(s[cRange])
|
||||
if denylist.contains(carrier) { continue }
|
||||
return (carrier, String(s[nRange]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func matchRoute(in s: String) -> (from: String, to: String)? {
|
||||
let pattern = "([A-Z]{3})\\s*(?:[-→>]|to)\\s*([A-Z]{3})"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
|
||||
let nsRange = NSRange(s.startIndex..., in: s)
|
||||
guard let m = regex.firstMatch(in: s, range: nsRange), m.numberOfRanges == 3,
|
||||
let fRange = Range(m.range(at: 1), in: s),
|
||||
let tRange = Range(m.range(at: 2), in: s) else { return nil }
|
||||
return (String(s[fRange]), String(s[tRange]))
|
||||
}
|
||||
|
||||
private static func matchDate(in s: String) -> Date? {
|
||||
// ISO-ish: "May 27, 2026" / "27 May 2026" / "2026-05-27"
|
||||
let formatters: [String] = [
|
||||
"MMMM d, yyyy",
|
||||
"MMM d, yyyy",
|
||||
"d MMMM yyyy",
|
||||
"d MMM yyyy",
|
||||
"yyyy-MM-dd",
|
||||
"MM/dd/yyyy"
|
||||
]
|
||||
// Try matching against any substring with each formatter.
|
||||
for fmt in formatters {
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = fmt
|
||||
df.locale = Locale(identifier: "en_US_POSIX")
|
||||
// Slide a window through the text; for date formats with
|
||||
// word months we need substrings starting with a month.
|
||||
let words = s.split(whereSeparator: { !$0.isLetter && !$0.isNumber && $0 != "-" && $0 != "/" && $0 != "," }).map(String.init)
|
||||
for i in 0..<words.count {
|
||||
for end in min(i + 4, words.count)...(min(i + 4, words.count)) {
|
||||
let candidate = words[i..<end].joined(separator: " ")
|
||||
if let date = df.date(from: candidate) {
|
||||
return date
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user