# Návod na filmové API ve Swiftu se SwiftUI

> Tento návod vytvoří aplikaci pro iOS 16 ve Swiftu 5.9, která ukazuje filmy, jež jsou tento týden v trendech, v seznamu SwiftUI s plakáty, roky a skóre. Používá URLSession s async/await a AsyncImage, ošetřuje 401 a 429 a vysvětluje, proč má produkční aplikace volat amodb přes váš vlastní server.

## Co vytvoříte

Vytvoříte `Trending`, aplikaci pro iOS s jednou obrazovkou: seznam filmů, které jsou tento týden v trendech, s plakátem, názvem, rokem a skóre. Potřebujete Xcode se Swiftem 5.9 nebo novějším, cílovou verzi iOS 16 a klíč amodb. Aplikace používá `URLSession` s async/await, modely `Decodable` a `AsyncImage`, bez balíčků. Počítejte s asi 20 minutami.

## Klíč uvnitř aplikace lze získat

Každé tajemství, které se dodává v aplikaci, berte jako veřejné. Někdo může číst řetězce z binárního souboru, zkoumat síťový provoz nebo číst úložiště aplikace. V tomto návodu pochází klíč z proměnné schématu Xcode, což je na vašem vlastním zařízení v pořádku. Pro vydávanou aplikaci spusťte malý server, který klíč drží, přepošle volání do amodb a vrátí JSON. Nastavte `baseURL` v klientovi níže na tento server a uživatele ověřujte tam. Limit požadavků platí na klíč, takže server také umožňuje cachovat jednu odpověď pro všechny vaše uživatele.

## Krok 1: Získejte API klíč

Porovnejte tarify na stránce [ceník](https://amodb.dev/cs/pricing/). Každý tarif vrací všechna data, volba se tedy týká požadavků za minutu. Vytvořte klíč v panelu účtu. Než napíšete jakýkoli kód ve Swiftu, ověřte ho z terminálu:

```bash
export AMODB_KEY=YOUR_API_KEY
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://api.amodb.dev/3/trending/movie/week?api_key=$AMODB_KEY"
```

`200` znamená, že klíč funguje. `401` znamená, že je nesprávný. Stránka [autentizace](https://amodb.dev/cs/authentication/) vysvětluje oba způsoby odeslání klíče.

## Krok 2: Vytvořte projekt v Xcode

Vytvořte projekt aplikace pro iOS s názvem `Trending` s rozhraním SwiftUI a nastavte minimální cílovou verzi na iOS 16. Smažte vygenerovaný `ContentView.swift`. Ve svém schématu otevřete Edit Scheme, zvolte Run, pak Arguments a přidejte proměnnou prostředí s názvem `AMODB_KEY` a svým klíčem. Xcode ji předá aplikaci jen tehdy, když ji spouštíte z Xcode.

## Krok 3: Odešlete první požadavek

Přečtěte klíč z prostředí a pošlete ho jako hlavičku Bearer. Nastavte 10sekundový timeout, aby se zaseknuté připojení ukončilo.

```swift
let key = ProcessInfo.processInfo.environment["AMODB_KEY"] ?? ""
var request = URLRequest(url: URL(string: "https://api.amodb.dev/3/trending/movie/week?language=en-US")!)
request.timeoutInterval = 10
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: request)
print((response as? HTTPURLResponse)?.statusCode ?? 0, data.count)
```

## Krok 4: Dekódujte odpověď a ukažte plakáty

Odpověď je stránka s polem `results` s 20 položkami. Každý film má `id`, `title`, `release_date`, `poster_path` a `vote_average`, plus `media_type` nastavené na `movie`. Nastavte `keyDecodingStrategy` na `.convertFromSnakeCase` a deklarujte vlastnosti v camel case. Pole, která nedeklarujete, se ignorují, což se hodí pro pole, jež se objeví později. `poster_path` může být `null` a `release_date` může chybět nebo být prázdný řetězec, proto model deklaruje obě jako volitelná.

```swift
struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}
```

`poster_path` začíná lomítkem, takže řetězec výše správně spojí velikost a název souboru. Plakáty pocházejí z `img.amodb.dev`, který nepotřebuje klíč a cachuje obrázky 30 dní. Velikosti uvádí stránka [obrázky](https://amodb.dev/cs/images-api/) a endpoint popisuje stránka [trendy a seznamy](https://amodb.dev/cs/trending-lists/).

## Krok 5: Ošetřete chyby a limity požadavků

Namapujte každý stav na chybu, kterou může obrazovka zobrazit.

| Stav         | Význam                                    | Co dělá aplikace                                                              |
| ------------ | ----------------------------------------- | ----------------------------------------------------------------------------- |
| 401          | Klíč chybí, je nesprávný nebo pozastavený | Zobrazí zprávu, bez opakování.                                                |
| 402          | Platnost tarifu skončila                  | Zobrazí zprávu. Obnovte ho na stránce [ceník](https://amodb.dev/cs/pricing/). |
| 429          | Překročili jste požadavky za minutu       | Počká `Retry-After` sekund, nejvýše 30, opakuje nejvýše dvakrát.              |
| 500 nebo 504 | Selhání na straně serveru                 | Zobrazí stav a nabídne obnovení tažením dolů.                                 |
| `URLError`   | Offline, timeout, ztracené připojení      | Zobrazí `localizedDescription`.                                               |

```swift
switch status {
case 200..<300:
    return try Self.decoder.decode(T.self, from: data)
case 401:
    throw AmodbError.invalidKey
case 429 where attempt < 2:
    let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
    try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
    attempt += 1
case 429:
    throw AmodbError.rateLimited
default:
    throw AmodbError.server(status)
}
```

Všechny kódy najdete na stránkách [chyby](https://amodb.dev/cs/errors/) a [limity požadavků](https://amodb.dev/cs/rate-limits/).

## Krok 6: Cachujte seznam

Edge cachuje seznamy trendů 30 minut, takže dřívější nový dotaz vrátí stejné filmy. Limit požadavků volání stále počítá. Držte poslední výsledek ve view modelu a načtěte znovu, jen když je starší než 30 minut nebo uživatel obnoví tažením dolů.

## Krok 7: Kompletní aplikace

Tento jediný soubor obsahuje modely, klienta, view model a pohledy. Vložte ho přes vygenerovaný soubor aplikace.

```swift
import SwiftUI

// MARK: Models

struct TrendingPage: Decodable {
    let results: [Movie]
}

struct Movie: Decodable, Identifiable {
    let id: Int
    let title: String
    let releaseDate: String?
    let posterPath: String?
    let voteAverage: Double

    var year: String { String((releaseDate ?? "").prefix(4)) }

    var posterURL: URL? {
        guard let posterPath else { return nil }
        return URL(string: "https://img.amodb.dev/t/p/w185\(posterPath)")
    }
}

// MARK: Client

enum AmodbError: LocalizedError {
    case missingKey
    case invalidKey
    case rateLimited
    case server(Int)

    var errorDescription: String? {
        switch self {
        case .missingKey: return "Set AMODB_KEY in the Xcode scheme."
        case .invalidKey: return "The API key was rejected (401)."
        case .rateLimited: return "Too many requests. Try again in a minute."
        case .server(let status): return "The service answered with status \(status)."
        }
    }
}

struct AmodbClient {
    var baseURL = URL(string: "https://api.amodb.dev/3")!
    let apiKey: String

    private static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return decoder
    }()

    func get<T: Decodable>(_ path: String, query: [URLQueryItem] = []) async throws -> T {
        guard !apiKey.isEmpty else { throw AmodbError.missingKey }
        var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)!
        components.queryItems = query
        var request = URLRequest(url: components.url!, timeoutInterval: 10)
        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")

        var attempt = 0
        while true {
            let (data, response) = try await URLSession.shared.data(for: request)
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            switch status {
            case 200..<300:
                return try Self.decoder.decode(T.self, from: data)
            case 401:
                throw AmodbError.invalidKey
            case 429 where attempt < 2:
                let header = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
                try await Task.sleep(for: .seconds(min(Int(header ?? "") ?? 5, 30)))
                attempt += 1
            case 429:
                throw AmodbError.rateLimited
            default:
                throw AmodbError.server(status)
            }
        }
    }
}

// MARK: View model

@MainActor
final class TrendingViewModel: ObservableObject {
    @Published private(set) var movies: [Movie] = []
    @Published private(set) var errorMessage: String?
    @Published private(set) var isLoading = false

    private let client: AmodbClient
    private var loadedAt: Date?

    init(client: AmodbClient) {
        self.client = client
    }

    func load(force: Bool = false) async {
        if !force, let last = loadedAt, !movies.isEmpty, Date().timeIntervalSince(last) < 30 * 60 {
            return
        }
        isLoading = true
        defer { isLoading = false }
        do {
            let page: TrendingPage = try await client.get(
                "trending/movie/week",
                query: [URLQueryItem(name: "language", value: "en-US")]
            )
            movies = page.results
            loadedAt = Date()
            errorMessage = nil
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: Views

struct MovieRow: View {
    let movie: Movie

    var body: some View {
        HStack(spacing: 12) {
            AsyncImage(url: movie.posterURL) { phase in
                switch phase {
                case .success(let image):
                    image.resizable().scaledToFill()
                case .failure:
                    Color.gray.opacity(0.2)
                default:
                    ProgressView()
                }
            }
            .frame(width: 60, height: 90)
            .clipShape(RoundedRectangle(cornerRadius: 6))

            VStack(alignment: .leading, spacing: 4) {
                Text(movie.title).font(.headline)
                Text(movie.year).font(.subheadline).foregroundStyle(.secondary)
                Label(String(format: "%.1f", movie.voteAverage), systemImage: "star.fill")
                    .font(.caption)
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var model: TrendingViewModel

    init(client: AmodbClient) {
        _model = StateObject(wrappedValue: TrendingViewModel(client: client))
    }

    var body: some View {
        NavigationStack {
            List(model.movies) { movie in
                MovieRow(movie: movie)
            }
            .overlay {
                if model.isLoading && model.movies.isEmpty {
                    ProgressView()
                } else if let message = model.errorMessage, model.movies.isEmpty {
                    Text(message).multilineTextAlignment(.center).padding()
                }
            }
            .navigationTitle("Trending")
            .refreshable { await model.load(force: true) }
            .task { await model.load() }
        }
    }
}

@main
struct TrendingApp: App {
    private let client = AmodbClient(apiKey: ProcessInfo.processInfo.environment["AMODB_KEY"] ?? "")

    var body: some Scene {
        WindowGroup {
            ContentView(client: client)
        }
    }
}
```

Spusťte aplikaci v simulátoru. Seznam se po prvním volání naplní a tažení dolů vynutí opětovné načtení. Skóre je souhrnné `vote_average` ze seznamu a titul, který zatím nemá žádné hlasy, ukazuje 0.0. Pro skóre z IMDb nebo Kinopoisk vyžádejte film s `append_to_response=ratings`. Objekt `ratings` obsahuje `imdb`, `kinopoisk`, `metacritic`, `rotten_tomatoes` a `tmdb`, každý buď `null`, nebo objekt s `value` a `votes`, jak ukazuje stránka [hodnocení](https://amodb.dev/cs/ratings/).

## Další kroky

- Přidejte obrazovku detailu, která načte `/movie/{id}` s `append_to_response=credits,ratings`, a pole najdete na stránce [filmy](https://amodb.dev/cs/movies/).
- Přepněte položku dotazu `language` na jazyk zařízení s využitím 14 základních jazyků na stránce [jazyky](https://amodb.dev/cs/languages/).
- Přesuňte klíč na svůj server a před zveřejněním aplikace na něj nasměrujte `baseURL`.
- Vytvořte stejný seznam pro web pomocí [Next.js](https://amodb.dev/cs/nextjs-movie-app/).

## Otázky a odpovědi

### Je bezpečné vložit klíč API do aplikace pro iOS?

Ne. Cokoli uložené v binárním souboru aplikace nebo v jejím nastavení lze získat. Držte klíč na serveru, který ovládáte, nechte aplikaci volat ten server a klíč uvnitř aplikace používejte jen pro lokální testování.

### Které verze iOS a Swiftu tento návod potřebuje?

Swift 5.9 nebo novější s Xcode a cílovou verzi iOS 16. Tím získáte NavigationStack, Task.sleep s dobou trvání a AsyncImage. View model používá ObservableObject, takže běží i na iOS 16.

### Jak zobrazit plakáty bez dalších knihoven?

Sestavte URL jako https://img.amodb.dev/t/p/w185 plus hodnotu poster_path a předejte ho do AsyncImage. Hostitel obrázků klíč nepotřebuje. Pro větší rozvržení použijte w342 nebo w500 a fázi selhání ošetřete zástupným obrázkem.

### Co má aplikace udělat po odpovědi 429?

Přečíst hlavičku Retry-After, počkat tolik sekund s horní hranicí a opakovat nejvýše dvakrát. Pokud to stále selže, zobrazit zprávu a nechat uživatele obnovit tažením dolů. Nikdy nezačínejte smyčku bez limitu.

### Mohu pro tituly použít jiný jazyk?

Ano. Přidejte položku dotazu language, například ru-RU nebo pt-BR. Poskytuje se čtrnáct základních jazyků a jakýkoli jiný tag se vrátí k angličtině. Tituly pak přijdou lokalizované, pokud překlad existuje.

## Související stránky

- [Návod na filmovou aplikaci v Next.js](https://amodb.dev/cs/nextjs-movie-app/): Vytvořte filmovou aplikaci v Next.js 15 v čistém JavaScriptu: server komponenty načítají trendy, hledání a detaily z amodb s revalidate a drží klíč soukromý.
- [Trendy a seznamy](https://amodb.dev/cs/trending-lists/): API trendů filmů s cestami TMDB v3: denní a týdenní žebříčky a seznamy now playing, popular, top rated a upcoming pro filmy a seriály, cachované 30 minut.
- [API obrázků a hostitel obrázků](https://amodb.dev/cs/images-api/): API obrázků filmů s cestami TMDB v3: vypište plakáty, pozadí, loga a záběry každého titulu a načtěte je z img.amodb.dev v 10 velikostech bez klíče.
- [Autentizace](https://amodb.dev/cs/authentication/): Jak poslat klíč amodb: api_key v dotazu, hlavička Authorization Bearer nebo apikey pro volání OMDb. Formát klíče, uložení, rotace a chyby 401.
- [Limity požadavků](https://amodb.dev/cs/rate-limits/): amodb omezuje klíč počtem požadavků za minutu v klouzavém okně 60 sekund: 20, 40 nebo 120 podle tarifu. Čtěte hlavičky limitu a řešte 429 pomocí Retry-After.
- [Rychlý start](https://amodb.dev/cs/quickstart/): První volání amodb: získejte klíč, vyžádejte detail filmu v curl, JavaScriptu, Pythonu, PHP nebo Go a načtěte plakát z img.amodb.dev. Za méně než 2 minuty.

Aktualizováno: 2026-10-18. Source: https://amodb.dev/cs/swift-movie-api/
