design/flighty

4 min czytania 838 słów
design/flighty screenshot

Flighty: Wizualizacja danych zrobiona jak należy

“Chcemy, żeby Flighty działało tak dobrze, że wydawało się niemal nudnie oczywiste.”

Flighty przekształca złożone dane lotnicze w informacje, które można ogarnąć jednym rzutem oka. Laureat Apple Design Award 2023 w kategorii Interakcja, to mistrzowska lekcja prezentowania gęstych danych poprzez spokojny, przejrzysty design inspirowany dziesięcioleciami oznakowania lotniskowego.


Dlaczego Flighty ma znaczenie

Założone przez byłego pracownika Apple, Ryana Jonesa, po frustrującym opóźnieniu lotu, Flighty stało się złotym standardem wizualizacji danych na iOS.

Kluczowe osiągnięcia: - Apple Design Award 2023 (Interakcja) - Finalista iPhone App of the Year 2023 - Pierwsza aplikacja w pełni wykorzystująca Live Activities - 15 kontekstowych stanów inteligentnych - Wiodące w branży przewidywanie opóźnień


Kluczowe wnioski

  1. Czerp ze sprawdzonych systemów - Lotniskowe tablice odlotów mają 50+ lat optymalizacji; Flighty adaptuje ten język wizualny na urządzenia mobilne
  2. Stany kontekstowe redukują obciążenie poznawcze - 15 inteligentnych stanów zapewnia, że użytkownicy widzą dokładnie to, czego potrzebują na każdym etapie podróży
  3. “Nudnie oczywiste” to cel - Jeśli aplikacja po prostu działa bez tarcia, odniosłeś sukces
  4. Pakuj, owijaj i koloruj dane - Prezentuj wnioski, nie surowe dane; koduj kolorami statusy, żeby użytkownicy nie musieli interpretować
  5. Offline-first dla niestabilnej łączności - Loty tracą połączenie; aplikacja musi działać bez internetu, szczególnie podczas lotu

Podstawowa filozofia designu

Oznakowanie lotniskowe jako język designu

Design Flighty jest zbudowany na analogii ze świata rzeczywistego: lotniskowe tablice odlotów.

LOTNISKOWA TABLICA ODLOTÓW (50+ lat udoskonaleń)
───────────────────────────────────────────────────────────────────
LOT      CEL              BRAMKA  CZAS    STATUS
UA 1234  San Francisco    B22     14:30   PLANOWO
DL 567   New York JFK     C15     15:00   OPÓŹNIONY
AA 890   Chicago O'Hare   A08     15:45   BOARDING

Jeden wiersz na lot. Tylko niezbędne informacje. Dekady optymalizacji.

TŁUMACZENIE FLIGHTY
───────────────────────────────────────────────────────────────────
[Karta lotu z tą samą gęstością informacji]
[Status kodowany kolorami]
[Szczegóły odpowiednie do czasu]

Kluczowy wniosek: “Te lotniskowe tablice mają jeden wiersz na lot i to jest dobry drogowskaz—miały 50 lat na ustalenie, co jest ważne.”

Świadomość kontekstu podróży

Flighty rozumie, że podróżowanie jest stresujące. Design musi redukować obciążenie poznawcze, nie zwiększać go.

Zasady designu: 1. Najważniejsze informacje widoczne bez przewijania 2. Progresywne ujawnianie szczegółów 3. Status kodowany kolorami (zielony = dobrze, żółty = ostrzeżenie, czerwony = problem) 4. Dane są “pakowane, owijane i kolorowane”, żeby użytkownicy nie musieli interpretować


Biblioteka wzorców

1. 15 inteligentnych stanów

Charakterystyczna innowacja Flighty: 15 kontekstowych stanów, które pokazują dokładnie to, czego potrzebujesz, dokładnie wtedy, gdy tego potrzebujesz.

Oś czasu stanów:

24 GODZINY PRZED
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Jutro o 14:30                           │
│ Potwierdzenie: ABC123                   │
│ Kieruj się do Terminala 2               │
└─────────────────────────────────────────┘

3 GODZINY PRZED
┌─────────────────────────────────────────┐
│ UA 1234 → SFO                           │
│ Bramka B22 • Boarding 14:00             │
│ Wyjedź na lotnisko do 12:15             │
│ [Zobacz pełną oś czasu →]               │
└─────────────────────────────────────────┘

PRZY BRAMCE
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [PLANOWO]            │
│ Bramka B22 • Boarding za 12 min         │
│ Miejsce 14A • Okno                      │
│ [Twój samolot jest tutaj ✓]             │
└─────────────────────────────────────────┘

DROGA DO SAMOLOTU
┌─────────────────────────────────────────┐
│ UA 1234 → SFO     [BOARDING]           │
│ Twoje miejsce: 14A (Okno)               │
│ ▓▓▓▓▓▓▓▓░░ 80% wsiadło                 │
└─────────────────────────────────────────┘

W LOCIE (offline)
┌─────────────────────────────────────────┐
│  SFO ──────●───────────── JFK           │
│            │                            │
│     Pozostało 2h 15m                    │
│     Przylot ~17:45 czasu lokalnego      │
└─────────────────────────────────────────┘

WYLĄDOWANO
┌─────────────────────────────────────────┐
│ ✓ Przylot do JFK                        │
│ Twoja bramka: C15 → Bramka przesiadki: D22 │
│ 8 min spaceru • Mapa terminala [→]      │
└─────────────────────────────────────────┘

Koncepcja implementacji:

enum FlightState: CaseIterable {
    case farOut          // > 24 godziny
    case dayBefore       // 24 godziny
    case headToAirport   // ~3 godziny
    case atAirport       // W terminalu
    case atGate          // Strefa bramki
    case boarding        // Rozpoczęto boarding
    case onBoard         // Na miejscu
    case taxiing         // Kołowanie na pas
    case inFlight        // W powietrzu
    case descending      // Podejście
    case landed          // Wylądowano
    case atGateDest      // Przy bramce docelowej
    case connection      // Lot przesiadkowy
    case completed       // Podróż zakończona
    case delayed         // Dowolny stan opóźnienia
}

struct SmartStateEngine {
    func currentState(for flight: Flight, context: TravelContext) -> FlightState {
        let now = Date()
        let departure = flight.scheduledDeparture

        // Czynniki kontekstowe
        let isAtAirport = context.currentLocation.isNear(flight.departureAirport)
        let isBoarding = flight.boardingStatus == .active
        let isAirborne = flight.status == .inFlight

        switch true {
        case isAirborne:
            return flight.altitude > 10000 ? .inFlight : .taxiing
        case isBoarding && isAtAirport:
            return .boarding
        case isAtAirport && departure.timeIntervalSinceNow < 3600:
            return .atGate
        case isAtAirport:
            return .atAirport
        case departure.timeIntervalSinceNow < 3 * 3600:
            return .headToAirport
        case departure.timeIntervalSinceNow < 24 * 3600:
            return .dayBefore
        default:
            return .farOut
        }
    }

    func displayContent(for state: FlightState, flight: Flight) -> StateContent {
        switch state {
        case .headToAirport:
            return StateContent(
                headline: "\(flight.number)\(flight.destination.code)",
                primary: "Bramka \(flight.gate) • Boarding \(flight.boardingTime.formatted())",
                secondary: "Wyjedź na lotnisko do \(flight.recommendedDeparture.formatted())",
                action: "Zobacz pełną oś czasu"
            )
        case .inFlight:
            return StateContent(
                headline: flight.routeVisualization,
                primary: "Pozostało \(flight.remainingTime.formatted())",
                secondary: "Przylot ~\(flight.estimatedArrival.formatted())",
                action: nil
            )
        // ... inne stany
        }
    }
}

2. Live Activities i Dynamic Island

Flighty było pierwszą aplikacją, która w pełni wykorzystała Live Activities z iOS 16, ustanawiając standard dla tej funkcji.

Stany Dynamic Island:

KOMPAKTOWY (Minimalny)
╭──────────────────────────────────╮
│ [^] B22  │  *  │  14:30 -> 1h 45m │
╰──────────────────────────────────╯
  Bramka    Status    Czas/Trwanie

ROZWINIĘTY (Długie naciśnięcie)
╭────────────────────────────────────────╮
│  UA 1234 do San Francisco              │
│  ───────────────────────────────────   │
│  Bramka B22       Boarding 14:00       │
│  Miejsce 14A      Planowo ✓            │
│                                        │
│  [Otwórz Flighty]     [Udostępnij ETA] │
╰────────────────────────────────────────╯

W LOCIE (Działa offline)
╭──────────────────────────────────╮
│  SFO ●════════○──── JFK          │
│         2h 15m                   │
╰──────────────────────────────────╯

Wzorzec implementacji SwiftUI:

import ActivityKit
import WidgetKit

struct FlightActivityAttributes: ActivityAttributes {
    public typealias ContentState = FlightStatus

    let flightNumber: String
    let origin: Airport
    let destination: Airport
    let gate: String
    let seat: String

    struct FlightStatus: Codable, Hashable {
        let departureTime: Date
        let arrivalTime: Date
        let status: Status
        let boardingStatus: BoardingStatus
        let progress: Double  // 0.0 do 1.0 dla lotu
        let phase: FlightPhase

        enum Status: String, Codable { case onTime, delayed, cancelled }
        enum BoardingStatus: String, Codable { case notStarted, boarding, closed }
        enum FlightPhase: String, Codable { case preBoarding, boarding, taxiing, inFlight, landed }
    }
}

struct FlightLiveActivity: Widget {
    var body: some WidgetConfiguration {
        ActivityConfiguration(for: FlightActivityAttributes.self) { context in
            // Widok ekranu blokady
            LockScreenView(context: context)
        } dynamicIsland: { context in
            DynamicIsland {
                // Widok rozwinięty
                DynamicIslandExpandedRegion(.leading) {
                    VStack(alignment: .leading) {
                        Text(context.attributes.flightNumber)
                            .font(.headline)
                        Text("do \(context.attributes.destination.city)")
                            .font(.caption)
                    }
                }
                DynamicIslandExpandedRegion(.trailing) {
                    VStack(alignment: .trailing) {
                        Text("Bramka \(context.attributes.gate)")
                        Text(context.state.status.displayText)
                            .foregroundStyle(context.state.status.color)
                    }
                }
                DynamicIslandExpandedRegion(.bottom) {
                    if context.state.phase == .inFlight {
                        FlightProgressBar(progress: context.state.progress)
                    } else {
                        Text("Boarding \(context.state.boardingTime.formatted())")
                    }
                }
            } compactLeading: {
                // Lewa strona kompaktowa
                Text(context.attributes.gate)
                    .font(.caption)
            } compactTrailing: {
                // Prawa strona kompaktowa
                Text(context.state.departureTime, style: .timer)
            } minimal: {
                // Minimalny (gdy wiele aktywności)
                Image(systemName: "airplane")
            }
        }
    }
}

3. Wizualizacja postępu lotu

Flighty pokazuje postęp lotu poprzez różne metafory wizualne.

Linia trasy ze wskaźnikiem samolotu:

POSTĘP LINIOWY (Główny)
───────────────────────────────────────────────────────────────────
SFO ════════════════●════════════════════════════════════ JFK
    [Odlot 45m temu]      ↑ Jesteś tutaj        [Pozostało 2h 15m]

POSTĘP KOŁOWY (Kompaktowy)
        ╭───────╮
       /    ●    \
      │     |     │    ← Łuk postępu wypełnia się zgodnie z ruchem wskazówek zegara
      │     |     │
       \    ↓    /     Pozostało 2h 15m
        ╰───────╯

MAPA CIEPLNA (Statystyki całkowite)
[Mapa z liniami tras o różnej grubości]
Gruba linia = często podróżowana trasa
Cienka linia = podróżowano raz

Implementacja SwiftUI:

struct FlightProgressView: View {
    let progress: Double  // 0.0 do 1.0
    let origin: Airport
    let destination: Airport
    let remainingTime: TimeInterval

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // Linia trasy
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: endX, y: y))
                }
                .stroke(Color.secondary.opacity(0.3), lineWidth: 3)

                // Linia postępu
                Path { path in
                    let startX: CGFloat = 40
                    let endX = geometry.size.width - 40
                    let progressX = startX + (endX - startX) * progress
                    let y = geometry.size.height / 2

                    path.move(to: CGPoint(x: startX, y: y))
                    path.addLine(to: CGPoint(x: progressX, y: y))
                }
                .stroke(Color.accentColor, lineWidth: 3)

                // Znacznik miejsca startu
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: 40, y: geometry.size.height / 2)

                // Ikona samolotu w aktualnej pozycji
                Image(systemName: "airplane")
                    .foregroundStyle(Color.accentColor)
                    .position(
                        x: 40 + (geometry.size.width - 80) * progress,
                        y: geometry.size.height / 2
                    )

                // Znacznik miejsca docelowego
                Circle()
                    .fill(Color.secondary)
                    .frame(width: 8, height: 8)
                    .position(x: geometry.size.width - 40, y: geometry.size.height / 2)

                // Etykiety
                HStack {
                    Text(origin.code)
                        .font(.caption.bold())
                    Spacer()
                    Text(destination.code)
                        .font(.caption.bold())
                }
                .padding(.horizontal, 20)
            }
        }
        .frame(height: 60)
    }
}

4. Asystent przesiadek

Po wylądowaniu z lotem przesiadkowym, Flighty pokazuje obie bramki wraz z Twoją lokalizacją na żywo.

WIDOK PRZESIADKI
┌─────────────────────────────────────────┐
│ Przesiadka: 45 minut                    │
│                                         │
│ ┌─────────────────────────────────────┐ │
│ │                                     │ │
│ │   [Mapa terminala]                  │ │
│ │                                     │ │
│ │   [*] Ty -> -> -> -> -> [>] D22     │ │
│ │   C15            8 min spaceru      │ │
│ │                                     │ │
│ └─────────────────────────────────────┘ │
│                                         │
│ [!] Napięta przesiadka - ruszaj się     │
└─────────────────────────────────────────┘

Implementacja:

struct ConnectionAssistantView: View {
    let arrival: Flight
    let departure: Flight
    let userLocation: CLLocationCoordinate2D?

    var connectionTime: TimeInterval {
        departure.scheduledDeparture.timeIntervalSince(arrival.estimatedArrival)
    }

    var walkingTime: TimeInterval {
        // Oblicz na podstawie odległości bramek i typowej prędkości chodu
        TerminalMap.walkingTime(from: arrival.arrivalGate, to: departure.gate)
    }

    var isTight: Bool {
        connectionTime < walkingTime + 15 * 60  // Mniej niż 15 min zapasu
    }

    var body: some View {
        VStack(spacing: 16) {
            // Nagłówek
            HStack {
                Text("Przesiadka")
                    .font(.headline)
                Spacer()
                Text(connectionTime.formatted())
                    .font(.title2.bold())
                    .foregroundStyle(isTight ? .orange : .primary)
            }

            // Mapa z trasą
            TerminalMapView(
                from: arrival.arrivalGate,
                to: departure.gate,
                userLocation: userLocation
            )
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))

            // Informacje o bramkach
            HStack {
                GateLabel(gate: arrival.arrivalGate, label: "Przylot")
                Spacer()
                Text("\(Int(walkingTime / 60)) min spaceru")
                    .font(.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                GateLabel(gate: departure.gate, label: "Odlot")
            }

            // Ostrzeżenie jeśli napięta przesiadka
            if isTight {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundStyle(.orange)
                    Text("Napięta przesiadka - ruszaj się")
                        .font(.callout)
                }
                .padding()
                .background(Color.orange.opacity(0.1))
                .clipShape(RoundedRectangle(cornerRadius: 8))
            }
        }
        .padding()
    }
}

5. Paszport i statystyki całkowite

Flighty przekształca historię podróży w piękne, nadające się do udostępniania wizualizacje.

STRONA PASZPORTU
┌─────────────────────────────────────────┐
│           [^] PASZPORT FLIGHTY          │
│                                         │
│  [Niestandardowa grafika z motywami     │
│   podróżniczymi]                        │
│                                         │
│  ───────────────────────────────────    │
│  2024                                   │
│                                         │
│  47 lotów                               │
│  89 234 mil                             │
│  23 lotniska                            │
│  12 krajów                              │
│                                         │
│  Najczęściej odwiedzane: SFO (23 razy)  │
│  Najdłuższy lot: SFO → SIN (8 447 mil)  │
│  ───────────────────────────────────    │
│                                         │
│  [Udostępnij] [Zobacz szczegóły]        │
└─────────────────────────────────────────┘

System designu wizualnego

Paleta kolorów

extension Color {
    // Kolory statusów (inspirowane lotniskiem)
    static let flightOnTime = Color(hex: "#10B981")    // Zielony
    static let flightDelayed = Color(hex: "#F59E0B")   // Bursztynowy
    static let flightCancelled = Color(hex: "#EF4444") // Czerwony
    static let flightBoarding = Color(hex: "#3B82F6")  // Niebieski

    // Kolory UI
    static let flightPrimary = Color(hex: "#1F2937")
    static let flightSecondary = Color(hex: "#6B7280")
    static let flightBackground = Color(hex: "#F9FAFB")
    static let flightCard = Color(hex: "#FFFFFF")

    // Akcent
    static let flightAccent = Color(hex: "#6366F1")    // Indygo
}

Typografia

struct FlightyTypography {
    // Numery lotów i bramki (muszą się wyróżniać)
    static let flightNumber = Font.system(size: 20, weight: .bold, design: .monospaced)
    static let gate = Font.system(size: 24, weight: .bold, design: .rounded)

    // Wyświetlanie czasu
    static let time = Font.system(size: 18, weight: .medium, design: .monospaced)
    static let countdown = Font.system(size: 32, weight: .bold, design: .monospaced)

    // Status i etykiety
    static let status = Font.system(size: 14, weight: .semibold)
    static let label = Font.system(size: 12, weight: .medium)
    static let body = Font.system(size: 16, weight: .regular)
}

Filozofia animacji

Celowy ruch

Animacje Flighty służą funkcji, nie dekoracji.

// Animacja zmiany statusu
struct StatusBadge: View {
    let status: FlightStatus

    var body: some View {
        Text(status.displayText)
            .font(.caption.bold())
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(status.color)
            .foregroundStyle(.white)
            .clipShape(Capsule())
            .contentTransition(.numericText())  // Płynne zmiany liczb
            .animation(.spring(response: 0.3), value: status)
    }
}

// Animacja paska postępu
struct AnimatedProgress: View {
    let progress: Double

    var body: some View {
        GeometryReader { geo in
            Rectangle()
                .fill(Color.accentColor)
                .frame(width: geo.size.width * progress)
                .animation(.linear(duration: 60), value: progress)  // Wolna, ciągła
        }
    }
}

Lekcje dla naszej pracy

1. Analogie ze świata rzeczywistego kierują designem

Oznakowanie lotniskowe ma 50+ lat optymalizacji. Czerp ze sprawdzonych systemów.

2. Stany kontekstowe redukują obciążenie poznawcze

15 inteligentnych stanów oznacza, że użytkownicy nigdy nie widzą nieistotnych informacji.

3. Nudnie oczywiste to cel

Jeśli aplikacja “po prostu działa”, odniosłeś sukces. Tarcie to porażka.

4. Pakuj, owijaj, koloruj dane

Nie pokazuj surowych danych. Prezentuj wnioski. Koduj kolorami statusy.

5. Offline-first dla urządzeń mobilnych

Loty tracą połączenie. Aplikacja musi działać bez internetu.


Często zadawane pytania

Jak działają 15 inteligentnych stanów Flighty?

Flighty monitoruje status Twojego lotu, lokalizację i czas, aby automatycznie wyświetlać najbardziej istotne informacje. Stany obejmują zakres od “daleko” (24+ godziny przed) przez “jedź na lotnisko”, “przy bramce”, “boarding”, “w locie” i “wylądowano”. Każdy stan wyświetla inne szczegóły: numery potwierdzenia wcześnie, informacje o bramce gdy są istotne, pozostały czas lotu podczas podróży i wskazówki dotyczące przesiadki po lądowaniu.

Co sprawia, że implementacja Live Activities w Flighty jest wyjątkowa?

Flighty było pierwszą aplikacją, która w pełni wykorzystała Live Activities i Dynamic Island z iOS 16. Implementacja obejmuje widoki kompaktowe pokazujące bramkę i odliczanie, widoki rozwinięte z pełnymi szczegółami lotu oraz wizualizację postępu lotu działającą offline bez łączności. Design traktuje Live Activities jako główny interfejs, nie jako dodatek.

Jak Flighty radzi sobie ze scenariuszami offline?

Flighty pobiera dane lotu i przechowuje je lokalnie przed odlotem. Postęp w locie kontynuuje się na podstawie zaplanowanego czasu i znanych parametrów samolotu. Aplikacja wstępnie oblicza szacowane czasy przylotu, czasy przesiadek i informacje o bramkach. Gdy łączność powraca, dane są synchronizowane i aktualizowane o wszelkie zmiany. To podejście offline-first jest niezbędne, ponieważ loty spędzają godziny bez internetu.

Dlaczego Flighty używa oznakowania lotniskowego jako inspiracji designu?

Lotniskowe tablice odlotów były udoskonalane przez ponad 50 lat, aby szybko komunikować informacje o lotach zestresowanym podróżnym w zatłoczonych miejscach. Używają jednego wiersza na lot, statusu kodowanego kolorami i tylko niezbędnych informacji. Flighty adaptuje ten sprawdzony język wizualny na urządzenia mobilne, wykorzystując te same zasady gęstości, przejrzystości i wskazywania statusu, które lotniska optymalizowały przez dekady.

Jak asystent przesiadek oblicza napięte przesiadki?

Asystent przesiadek porównuje Twój czas przylotu z następnym odlotem, następnie oblicza czas dojścia pieszo na podstawie odległości bramek w terminalu. Uwzględnia typowe prędkości chodu i układy terminali. Jeśli zapas spada poniżej 15 minut, aplikacja oznacza to jako napiętą przesiadkę i pokazuje mapę na żywo z Twoją aktualną lokalizacją, bramką docelową i trasą pieszą.