Une appli « cinémas autour de moi » en Flutter

Construire une appli Flutter en deux écrans qui trouve les cinémas autour de l’utilisateur et affiche le programme du jour avec les liens de réservation, via un petit relais Dart qui garde la clé API hors du téléphone.

9 min de lecture

Sur cette page
  1. Un relais, pas de clé dans l’appli
  2. Ce qu’il vous faut
  3. 1.Le relais
  4. 2.Créer l’appli
  5. 3.Le client du relais
  6. 4.L’appli
  7. 5.Cinémas autour de moi
  8. 6.Le programme du jour
  9. 7.Lancer
  10. Vers les stores
  11. Combien de requêtes ça coûte ?
  12. Pour aller plus loin

« Cinémas autour de moi » est le premier écran de toute appli de cinéma : le téléphone donne sa position, l’appli liste les cinémas les plus proches, un appui montre le programme du jour. Ce tutoriel le construit avec Flutter (3.44, Dart 3.12) en deux écrans, pour la France et les États-Unis, avec un relais écrit lui aussi en Dart : un seul langage du serveur au téléphone.

Cinémas autour de moi               ← Max Linder
[ Trouver les cinémas autour de moi ]   Moulin · 2 h 10
Pathe Palace     0,3 km             [11:30 VF] [14:05 VF] [16:40 VF]
Pathe Opera      0,3 km
Max Linder       1,1 km

Un relais, pas de clé dans l’appli

Tout ce qui est livré dans une appli mobile peut être lu : une clé dans un --dart-define, dans une constante Dart ou dans un asset s’extrait de l’APK ou de l’IPA en quelques minutes, et n’importe qui peut alors consommer votre quota. L’appli n’appelle donc jamais API Cinéma directement. Elle appelle votre relais, un serveur d’environ 120 lignes qui garde la clé, ne répond qu’aux deux questions que pose l’appli, garde les réponses en cache et limite chaque téléphone.

téléphone (Flutter) ──GET /cinemas?lat&lng──▶ votre relais ──clé Bearer──▶ API Cinéma
   (aucune clé)     ──GET /cinemas/:slug/showtimes──▶ (la clé, un cache, une limite)

Le relais met aussi les réponses en forme pour les écrans : les cinémas les plus proches avec leur distance, et le programme regroupé par film. L’appli reste simple et télécharge moins. Il est écrit ici avec shelf, le paquet serveur de Dart ; le tutoriel React Native a le même relais en Node.js, et chacun marche avec l’une ou l’autre appli.

Ce qu’il vous faut

  • Flutter 3.44 ou plus récent (flutter doctor au vert pour au moins une plateforme).
  • Un téléphone, un simulateur iOS ou un émulateur Android. Chrome suffit aussi pour un premier essai.
  • Une clé API Cinéma : créez un compte gratuit, confirmez votre adresse e-mail, puis créez une clé depuis le tableau de bord. Le forfait gratuit donne 100 requêtes par semaine, assez pour construire et tester ; une appli sur les stores passe en Starter (19 € par mois, 1 000 requêtes par semaine) ou Pro (49 €, 10 000).

Le relais : relay/bin/relay.dart

Terminal
dart create -t console relay && cd relay
dart pub add shelf shelf_router http
relay/bin/relay.dart
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';

const api = 'https://api.api-cinema.com/v1';
final key =
    Platform.environment['API_CINEMA_KEY'] ??
    (throw StateError('API_CINEMA_KEY manque'));
// seule la version web de l’appli en a besoin (flutter run -d chrome --web-port 8080) ; iOS et Android ignorent CORS
final webOrigin = Platform.environment['WEB_ORIGIN'] ?? 'http://localhost:8080';

final _cache = <String, ({DateTime at, Map<String, dynamic> body})>{};
const _ttl = Duration(minutes: 15);

/// Un GET sur l’API avec la clé, en cache 15 minutes : l’appli ne voit jamais la clé.
Future<Map<String, dynamic>> apiGet(
  String path,
  Map<String, String> params,
) async {
  final uri = Uri.parse('$api$path').replace(queryParameters: params);
  final hit = _cache['$uri'];
  if (hit != null && DateTime.now().difference(hit.at) < _ttl) return hit.body;
  final res = await http
      .get(uri, headers: {'Authorization': 'Bearer $key'})
      .timeout(const Duration(seconds: 10));
  if (res.statusCode != 200) {
    throw HttpException('API Cinéma a répondu ${res.statusCode}');
  }
  final body = jsonDecode(res.body) as Map<String, dynamic>;
  _cache['$uri'] = (at: DateTime.now(), body: body);
  return body;
}

final _hits = <String, List<DateTime>>{};

/// 30 appels par minute et par téléphone au plus : une boucle dans l’appli ou un script ne peut pas vider le quota.
bool tooMany(String ip) {
  final now = DateTime.now();
  final recent =
      (_hits[ip] ?? [])
          .where((t) => now.difference(t) < const Duration(minutes: 1))
          .toList()
        ..add(now);
  _hits[ip] = recent;
  return recent.length > 30;
}

Response json(Object body, {int status = 200}) => Response(
  status,
  body: jsonEncode(body),
  headers: {'content-type': 'application/json; charset=utf-8'},
);

final router = Router()
  // les cinémas à moins de 15 km, du plus proche au plus loin
  ..get('/cinemas', (Request req) async {
    final lat = double.tryParse(req.url.queryParameters['lat'] ?? '');
    final lng = double.tryParse(req.url.queryParameters['lng'] ?? '');
    if (lat == null || lng == null || lat.abs() > 90 || lng.abs() > 180) {
      return json({'error': 'lat et lng sont obligatoires'}, status: 400);
    }
    // arrondie à 1 km environ : les voisins partagent le cache, et la position exacte ne quitte jamais le serveur
    final near = '${lat.toStringAsFixed(2)},${lng.toStringAsFixed(2)}';
    final body = await apiGet('/cinemas', {
      'near': near,
      'radius_km': '15',
      'limit': '30',
    });
    return json([
      for (final c in (body['data'] as List).cast<Map<String, dynamic>>())
        {
          'slug': c['slug'],
          'name': c['name'],
          'address': c['address'],
          'city': c['city'],
          'distanceKm': c['distanceKm'],
        },
    ]);
  })
  // le programme du jour d’un cinéma, une entrée par film
  ..get('/cinemas/<slug>/showtimes', (Request req, String slug) async {
    if (!RegExp(r'^[\w-]{1,100}$').hasMatch(slug)) {
      return json({'error': 'cinéma invalide'}, status: 400);
    }
    final body = await apiGet('/cinemas/$slug/showtimes', {
      'include': 'movie',
      'limit': '300',
    });
    final films = <String, Map<String, dynamic>>{};
    for (final s in (body['data'] as List).cast<Map<String, dynamic>>()) {
      final movie = s['movie'] as Map<String, dynamic>;
      final film = films.putIfAbsent(
        movie['id'] as String,
        () => {
          'title': movie['title'],
          'durationMin': movie['durationMin'],
          'posterUrl': movie['posterUrl'],
          'times': <Map<String, dynamic>>[],
        },
      );
      // startsAt est déjà l’heure locale du cinéma
      (film['times'] as List).add({
        'time': (s['startsAt'] as String).substring(11, 16),
        'version': s['version'],
        'format': s['format'],
        'bookingUrl': s['bookingUrl'],
      });
    }
    return json(films.values.toList());
  });

/// Limite chaque téléphone, ajoute l’en-tête CORS, et ne renvoie jamais une erreur de l’API telle quelle.
Handler guard(Handler inner) => (req) async {
  // derrière un proxy (Fly, Render, Nginx), la première adresse de x-forwarded-for est le téléphone
  final ip = req.headers['x-forwarded-for']?.split(',').first.trim() ?? 'local';
  if (tooMany(ip)) {
    return json({'error': 'Trop de requêtes, patientez une minute.'}, status: 429);
  }
  try {
    final res = await inner(req);
    return res.change(headers: {'access-control-allow-origin': webOrigin});
  } catch (e) {
    stderr.writeln(e);
    return json({
      'error': 'Séances indisponibles, réessayez plus tard.',
    }, status: 502);
  }
};

void main() async {
  final handler = const Pipeline()
      .addMiddleware(logRequests())
      .addMiddleware(guard)
      .addHandler(router.call);
  final port = int.parse(Platform.environment['PORT'] ?? '8787');
  await shelf_io.serve(handler, InternetAddress.anyIPv4, port);
  print('relais à l’écoute sur http://localhost:$port');
}
Terminal
API_CINEMA_KEY=ac_live_… dart run bin/relay.dart

Essayez-le depuis un autre terminal : curl "http://localhost:8787/cinemas?lat=48.8708&lng=2.3317" liste les cinémas autour de l’Opéra à Paris, curl http://localhost:8787/cinemas/max-linder-paris-9e-arrondissement/showtimes le programme de l’un d’eux.

Trois détails comptent. La position est arrondie à 1 km environ avant de quitter votre serveur : les voisins partagent le cache, et API Cinéma ne reçoit jamais la position exacte de vos utilisateurs. Le slug du cinéma est vérifié contre un motif strict, l’appli ne peut donc pas faire appeler une autre route par le relais avec votre clé. Et les erreurs de l’API sont journalisées, jamais renvoyées telles quelles.

Créer l’appli

Terminal
flutter create cinemas --empty --platforms ios,android,web
cd cinemas
flutter pub add geolocator http url_launcher

Chaque plateforme doit savoir pourquoi l’appli demande la position. Sur iOS, ajoutez la phrase affichée par la demande d’autorisation dans ios/Runner/Info.plist, et autorisez le HTTP en clair vers l’adresse IP de votre ordinateur tant que le relais y tourne (depuis iOS 17, App Transport Security bloque les adresses IP par défaut) :

ios/Runner/Info.plist
<key>NSLocationWhenInUseUsageDescription</key>
<string>Votre position sert à trouver les cinémas autour de vous.</string>
<key>NSAppTransportSecurity</key>
<dict>
	<key>NSAllowsLocalNetworking</key>
	<true/>
</dict>

Sur Android, déclarez les permissions en tête de android/app/src/main/AndroidManifest.xml (les builds release ont besoin d’INTERNET, les builds debug l’ont déjà), et autorisez le HTTP en clair tant que le relais tourne sur votre ordinateur :

android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <application
        android:usesCleartextTraffic="true"
        …

Les deux réglages disparaissent une fois le relais en HTTPS.

L’adresse du relais est passée au build avec --dart-define. Ce n’est pas un secret, c’est pourquoi elle peut y aller : ces valeurs sont écrites dans le bundle de l’appli. Sur un vrai téléphone, localhost est le téléphone lui-même : utilisez l’adresse de votre ordinateur sur le réseau Wi-Fi.

Le client du relais : lib/relay.dart

Trois petites classes pour ce que renvoie le relais, et deux fonctions qui l’appellent. Les messages d’erreur du relais sont faits pour l’écran : ils voyagent dans une RelayException.

lib/relay.dart
import 'dart:convert';

import 'package:http/http.dart' as http;

// l’adresse de votre relais, pas un secret : les valeurs --dart-define finissent dans le bundle de l’appli
const relayUrl = String.fromEnvironment(
  'RELAY_URL',
  defaultValue: 'http://localhost:8787',
);

class Cinema {
  const Cinema({
    required this.slug,
    required this.name,
    required this.address,
    required this.city,
    required this.distanceKm,
  });

  factory Cinema.fromJson(Map<String, dynamic> json) => Cinema(
    slug: json['slug'] as String,
    name: json['name'] as String,
    address: json['address'] as String?,
    city: json['city'] as String,
    distanceKm: (json['distanceKm'] as num).toDouble(),
  );

  final String slug;
  final String name;
  final String? address;
  final String city;
  final double distanceKm;
}

class Showtime {
  const Showtime({
    required this.time,
    required this.version,
    required this.format,
    required this.bookingUrl,
  });

  factory Showtime.fromJson(Map<String, dynamic> json) => Showtime(
    time: json['time'] as String,
    version: json['version'] as String,
    format: json['format'] as String,
    bookingUrl: json['bookingUrl'] as String?,
  );

  final String time;
  final String version;
  final String format;
  final String? bookingUrl;
}

class Film {
  const Film({
    required this.title,
    required this.durationMin,
    required this.posterUrl,
    required this.times,
  });

  factory Film.fromJson(Map<String, dynamic> json) => Film(
    title: json['title'] as String,
    durationMin: json['durationMin'] as int?,
    posterUrl: json['posterUrl'] as String?,
    times: [
      for (final t in json['times'] as List)
        Showtime.fromJson(t as Map<String, dynamic>),
    ],
  );

  final String title;
  final int? durationMin;
  final String? posterUrl;
  final List<Showtime> times;
}

/// Ce que le relais a répondu quand il n’a pas pu aider : son message est destiné à l’écran.
class RelayException implements Exception {
  const RelayException(this.message);
  final String message;

  @override
  String toString() => message;
}

Future<List<dynamic>> _get(String path) async {
  final res = await http.get(Uri.parse('$relayUrl$path'));
  final body = jsonDecode(utf8.decode(res.bodyBytes));
  if (res.statusCode != 200) {
    throw RelayException(
      body is Map ? body['error'] as String : 'Erreur ${res.statusCode}',
    );
  }
  return body as List<dynamic>;
}

Future<List<Cinema>> nearbyCinemas(double lat, double lng) async => [
  for (final c in await _get('/cinemas?lat=$lat&lng=$lng'))
    Cinema.fromJson(c as Map<String, dynamic>),
];

Future<List<Film>> cinemaProgramme(String slug) async => [
  for (final f in await _get('/cinemas/${Uri.encodeComponent(slug)}/showtimes'))
    Film.fromJson(f as Map<String, dynamic>),
];

L’appli : lib/main.dart

Une MaterialApp dont le premier écran est la liste ; le programme est empilé par-dessus avec Navigator.

lib/main.dart
import 'package:flutter/material.dart';

import 'nearby_cinemas_page.dart';

void main() {
  runApp(const CinemasApp());
}

class CinemasApp extends StatelessWidget {
  const CinemasApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Cinémas autour de moi',
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const NearbyCinemasPage(),
    );
  }
}

Cinémas autour de moi : lib/nearby_cinemas_page.dart

La position est demandée quand l’utilisateur appuie sur le bouton, pas au lancement : une autorisation demandée en contexte est bien plus souvent accordée, et un refus donne un message clair plutôt qu’un écran vide. geolocator fait les trois vérifications dans l’ordre : la localisation est-elle activée, est-elle autorisée, où sommes-nous.

lib/nearby_cinemas_page.dart
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';

import 'cinema_programme_page.dart';
import 'relay.dart';

class NearbyCinemasPage extends StatefulWidget {
  const NearbyCinemasPage({super.key});

  @override
  State<NearbyCinemasPage> createState() => _NearbyCinemasPageState();
}

class _NearbyCinemasPageState extends State<NearbyCinemasPage> {
  List<Cinema>? _cinemas;
  bool _loading = false;
  String? _error;

  Future<void> _locate() async {
    setState(() {
      _loading = true;
      _error = null;
    });
    try {
      if (!await Geolocator.isLocationServiceEnabled()) {
        throw const RelayException(
          'Localisation désactivée : activez-la pour voir les cinémas autour de vous.',
        );
      }
      var permission = await Geolocator.checkPermission();
      if (permission == LocationPermission.denied) {
        permission = await Geolocator.requestPermission();
      }
      if (permission == LocationPermission.denied ||
          permission == LocationPermission.deniedForever) {
        throw const RelayException(
          'Localisation refusée : autorisez-la dans les réglages pour voir les cinémas autour de vous.',
        );
      }
      // 100 m environ suffisent pour « à moins de 15 km » et économisent la batterie
      final position = await Geolocator.getCurrentPosition(
        locationSettings: const LocationSettings(
          accuracy: LocationAccuracy.medium,
        ),
      );
      final cinemas = await nearbyCinemas(
        position.latitude,
        position.longitude,
      );
      setState(() => _cinemas = cinemas);
    } on RelayException catch (e) {
      setState(() => _error = e.message);
    } catch (_) {
      setState(() => _error = 'Quelque chose a cassé.');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final cinemas = _cinemas;
    return Scaffold(
      appBar: AppBar(title: const Text('Cinémas autour de moi')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            FilledButton(
              onPressed: _loading ? null : _locate,
              child: Text(
                cinemas == null ? 'Trouver les cinémas autour de moi' : 'Actualiser',
              ),
            ),
            if (_loading)
              const Padding(
                padding: EdgeInsets.only(top: 16),
                child: Center(child: CircularProgressIndicator()),
              ),
            if (_error != null)
              Padding(
                padding: const EdgeInsets.only(top: 16),
                child: Text(
                  _error!,
                  style: TextStyle(color: Theme.of(context).colorScheme.error),
                ),
              ),
            if (cinemas != null && cinemas.isEmpty)
              const Padding(
                padding: EdgeInsets.only(top: 16),
                child: Text('Aucun cinéma à moins de 15 km.'),
              ),
            Expanded(
              child: ListView.separated(
                itemCount: cinemas?.length ?? 0,
                separatorBuilder: (_, _) => const Divider(height: 1),
                itemBuilder: (context, i) {
                  final c = cinemas![i];
                  return ListTile(
                    contentPadding: EdgeInsets.zero,
                    title: Text(c.name),
                    subtitle: Text(
                      '${c.distanceKm.toStringAsFixed(1)} km · '
                      '${c.address != null ? '${c.address}, ' : ''}${c.city}',
                    ),
                    onTap: () => Navigator.of(context).push(
                      MaterialPageRoute<void>(
                        builder: (_) =>
                            CinemaProgrammePage(slug: c.slug, name: c.name),
                      ),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

LocationAccuracy.medium (100 m environ) suffit pour trouver les cinémas à moins de 15 km : la réponse arrive plus vite que par GPS et use moins la batterie.

Le programme du jour : lib/cinema_programme_page.dart

Le slug et le nom du cinéma viennent de la liste (le nom titre l’écran sans requête supplémentaire). La requête est faite une fois par visite, dans un champ late final, les reconstructions du widget ne rappellent donc jamais le relais. Chaque horaire ouvre la page de réservation du cinéma.

lib/cinema_programme_page.dart
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

import 'relay.dart';

class CinemaProgrammePage extends StatefulWidget {
  const CinemaProgrammePage({
    super.key,
    required this.slug,
    required this.name,
  });

  final String slug;
  final String name;

  @override
  State<CinemaProgrammePage> createState() => _CinemaProgrammePageState();
}

class _CinemaProgrammePageState extends State<CinemaProgrammePage> {
  // une requête par visite de l’écran, pas par reconstruction
  late final Future<List<Film>> _films = cinemaProgramme(widget.slug);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.name)),
      body: FutureBuilder(
        future: _films,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Padding(
              padding: const EdgeInsets.all(16),
              child: Text(
                '${snapshot.error}',
                style: TextStyle(color: Theme.of(context).colorScheme.error),
              ),
            );
          }
          final films = snapshot.data;
          if (films == null) {
            return const Center(child: CircularProgressIndicator());
          }
          if (films.isEmpty) {
            return const Center(child: Text('Plus de séance aujourd’hui.'));
          }
          return ListView.separated(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            itemCount: films.length,
            separatorBuilder: (_, _) => const Divider(height: 1),
            itemBuilder: (context, i) => _FilmRow(film: films[i]),
          );
        },
      ),
    );
  }
}

class _FilmRow extends StatelessWidget {
  const _FilmRow({required this.film});

  final Film film;

  @override
  Widget build(BuildContext context) {
    final duration = film.durationMin;
    final poster = film.posterUrl;
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 14),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        spacing: 12,
        children: [
          ClipRRect(
            borderRadius: BorderRadius.circular(6),
            child: SizedBox(
              width: 64,
              height: 96,
              child: poster == null
                  ? const ColoredBox(color: Color(0xFFEEEEEE))
                  : Image.network(
                      poster,
                      fit: BoxFit.cover,
                      errorBuilder: (_, _, _) =>
                          const ColoredBox(color: Color(0xFFEEEEEE)),
                    ),
            ),
          ),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  film.title,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                if (duration != null)
                  Text(
                    '${duration ~/ 60} h ${(duration % 60).toString().padLeft(2, '0')}',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                const SizedBox(height: 8),
                Wrap(
                  spacing: 8,
                  runSpacing: 8,
                  children: [
                    for (final t in film.times)
                      OutlinedButton(
                        // la page de réservation du cinéma, quand il en a une
                        onPressed: t.bookingUrl == null
                            ? null
                            : () => launchUrl(
                                Uri.parse(t.bookingUrl!),
                                mode: LaunchMode.externalApplication,
                              ),
                        child: Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [
                            Text(t.time),
                            Text(
                              '${t.version}${t.format == '2D' ? '' : ' ${t.format}'}',
                              style: Theme.of(context).textTheme.labelSmall,
                            ),
                          ],
                        ),
                      ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Les horaires sont l’heure locale du cinéma, telle que l’API la renvoie : un utilisateur à Lyon qui regarde un cinéma de New York voit les horaires de New York, ce dont il a besoin pour y aller.

Lancer

Terminal
flutter run --dart-define=RELAY_URL=http://192.168.1.20:8787

Choisissez votre téléphone ou un simulateur, appuyez sur Trouver les cinémas autour de moi, autorisez la localisation, puis ouvrez un cinéma. Le simulateur n’a pas de GPS : sur iOS, Features › Location › Custom Location en fixe une ; sur Android, le panneau Location de l’émulateur. Deux choses à savoir :

  • Dans un navigateur. flutter run -d chrome --web-port 8080 lance la même appli dans Chrome, avec le WEB_ORIGIN par défaut du relais. Les affiches dont l’hébergeur n’envoie pas d’en-têtes CORS restent grises dans le navigateur ; les téléphones les affichent.
  • Xcode 27. Si le build iOS s’arrête sur deployment target 13.0 is not supported, ouvrez ios/Runner.xcodeproj et passez la cible de déploiement iOS à 15.0, ou remplacez 13.0 par 15.0 dans ios/Runner.xcodeproj/project.pbxproj.

Vers les stores

  • Déployez le relais sur n’importe quel hébergeur qui fait tourner Dart (un conteneur issu de dart compile exe, Cloud Run, Fly.io, un VPS) avec API_CINEMA_KEY dans ses secrets, en HTTPS : les builds iOS et Android de production refusent le HTTP en clair. Buildez ensuite avec --dart-define=RELAY_URL=https://… et retirez usesCleartextTraffic du manifeste et NSAppTransportSecurity du plist.
  • Une clé par environnement. Une clé pour le développement, une autre pour le relais en production : vous pouvez en révoquer une depuis le tableau de bord sans couper l’appli.
  • Gardez la limite. Les 30 appels par minute et par adresse sont larges pour une personne et arrêtent un script. Si votre relais est derrière un proxy, vérifiez que x-forwarded-for vient de lui et pas du client.

Combien de requêtes ça coûte ?

Une requête par écran que le cache ne sait pas servir : un utilisateur qui ouvre la liste et deux cinémas coûte au plus trois requêtes, et bien moins dans un quartier fréquenté où les voisins partagent le cache pendant 15 minutes. Avec les 1 000 requêtes par semaine de Starter, cela fait plusieurs centaines de sessions par semaine ; Pro va dix fois plus loin.

Pour aller plus loin

  • Un autre jour. Ajoutez un paramètre date (AAAA-MM-JJ) à la route du programme du relais et une rangée de jours au-dessus de la liste ; l’API renvoie le programme de n’importe quel jour à venir.
  • Les films autour de moi. Une troisième route sur /v1/showtimes avec near, radius_km et date liste toutes les séances autour de l’utilisateur, pour un écran « ce soir » trié par heure de début.
  • La VO seulement. L’API filtre avec version=VO ou VOST : un interrupteur sur l’écran du programme, transmis par le relais.

Tous les paramètres et champs sont dans la documentation. La même appli existe en React Native avec Expo, et le tutoriel du chatbot a une section sur la protection de la clé qui vaut aussi pour le relais.