A “cinemas near me” app in Flutter

Build a two-screen Flutter app that finds the cinemas around the user and shows today’s programme with booking links, through a small Dart relay that keeps your API key off the phone. France and US.

8 min read

On this page
  1. A relay, not a key in the app
  2. What you need
  3. 1.The relay
  4. 2.Create the app
  5. 3.The relay client
  6. 4.The app
  7. 5.Cinemas near me
  8. 6.Today's programme
  9. 7.Run it
  10. Going to the stores
  11. How many requests does it cost?
  12. Going further

"Cinemas near me" is the first screen of any film app: the phone gives its position, the app lists the nearest cinemas, a tap shows today's programme. This tutorial builds it with Flutter (3.44, Dart 3.12) in two screens, for France and the United States, with a relay written in Dart too: one language from the server to the phone.

Cinemas near me                     ← Max Linder
[ Find cinemas around me ]          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

A relay, not a key in the app

Everything shipped in a mobile app can be read: a key in a --dart-define, in a Dart constant or in an asset is extracted from the APK or the IPA in minutes, and then anyone can spend your quota. So the app never calls API Cinéma directly. It calls your relay, a server of about 120 lines that holds the key, answers only the two questions the app asks, keeps answers in cache and limits each phone.

phone (Flutter) ──GET /cinemas?lat&lng──▶ your relay ──Bearer key──▶ API Cinéma
   (no key)     ──GET /cinemas/:slug/showtimes──▶ (the key, a cache, a limit)

The relay also shapes the answers for the screens: the nearest cinemas with their distance, and the programme grouped by film. The app stays simple and downloads less. It is written here with shelf, the Dart server package; the React Native tutorial has the same relay in Node.js, and either works with either app.

What you need

  • Flutter 3.44 or later (flutter doctor green for at least one platform).
  • A phone, an iOS simulator or an Android emulator. Chrome works too for a first run.
  • An API Cinéma key: create a free account, confirm your e-mail address, then create a key from the dashboard. The free plan gives 100 requests a week, enough to build and test; an app in the stores moves to Starter (€19 a month, 1,000 requests a week) or Pro (€49, 10,000).

The relay: 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 is missing'));
// only the web build of the app needs it (flutter run -d chrome --web-port 8080); iOS and Android ignore CORS
final webOrigin = Platform.environment['WEB_ORIGIN'] ?? 'http://localhost:8080';

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

/// One GET on the API with the key, cached 15 minutes: the app never sees the key.
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 answered ${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 calls a minute per phone at most: a loop in the app or a script cannot drain the 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()
  // cinemas within 15 km, nearest first
  ..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 and lng are required'}, status: 400);
    }
    // rounded to about 1 km: neighbours share the cache, and the exact position never leaves the server
    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'],
        },
    ]);
  })
  // today's programme of one cinema, one entry per film
  ..get('/cinemas/<slug>/showtimes', (Request req, String slug) async {
    if (!RegExp(r'^[\w-]{1,100}$').hasMatch(slug)) {
      return json({'error': 'bad cinema'}, 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 is already the cinema's local time
      (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());
  });

/// Limits each phone, adds the CORS header, and never sends an API error back as it is.
Handler guard(Handler inner) => (req) async {
  // behind a proxy (Fly, Render, Nginx), the first x-forwarded-for address is the phone
  final ip = req.headers['x-forwarded-for']?.split(',').first.trim() ?? 'local';
  if (tooMany(ip)) {
    return json({'error': 'Too many requests, wait a 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': 'Showtimes unavailable, try again later.',
    }, 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('relay listening on http://localhost:$port');
}
Terminal
API_CINEMA_KEY=ac_live_… dart run bin/relay.dart

Try it from another terminal: curl "http://localhost:8787/cinemas?lat=48.8708&lng=2.3317" lists the cinemas around the Opéra in Paris, curl http://localhost:8787/cinemas/max-linder-paris-9e-arrondissement/showtimes the programme of one of them.

Three details matter. The position is rounded to about 1 km before it leaves your server: neighbours share the cache, and API Cinéma never receives the exact location of your users. The cinema slug is checked against a strict pattern, so the app cannot make the relay call any other route with your key. And errors from the API are logged, never sent back as they are.

Create the app

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

Each platform has to know why the app asks for the location. On iOS, add the sentence shown by the permission dialog to ios/Runner/Info.plist, and allow plain HTTP to your computer's IP address while the relay runs there (since iOS 17, App Transport Security blocks IP addresses by default):

ios/Runner/Info.plist
<key>NSLocationWhenInUseUsageDescription</key>
<string>Your location finds the cinemas around you.</string>
<key>NSAppTransportSecurity</key>
<dict>
	<key>NSAllowsLocalNetworking</key>
	<true/>
</dict>

On Android, declare the permissions at the top of android/app/src/main/AndroidManifest.xml (release builds need INTERNET, debug builds have it already), and allow plain HTTP while the relay runs on your computer:

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"
        …

Both settings go away once the relay is on HTTPS.

The address of the relay is passed at build time with --dart-define. It is not a secret, which is why it can go there: those values are written into the app bundle. On a real phone, localhost is the phone itself: use your computer's address on the Wi-Fi network.

The relay client: lib/relay.dart

Three small classes for what the relay returns, and two functions that call it. The relay's own error messages are made for the screen, so they travel in a RelayException.

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

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

// the address of your relay, not a secret: --dart-define values end up in the app bundle
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;
}

/// What the relay answered when it could not help: its message is meant for the screen.
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 : 'Error ${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>),
];

The app: lib/main.dart

A MaterialApp whose first screen is the list; the programme is pushed on top with 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: 'Cinemas near me',
      theme: ThemeData(colorSchemeSeed: Colors.indigo),
      home: const NearbyCinemasPage(),
    );
  }
}

Cinemas near me: lib/nearby_cinemas_page.dart

The location is asked when the user taps the button, not at launch: a permission asked in context is granted far more often, and a refusal gets a clear message instead of an empty screen. geolocator does the three checks in order: is location on, is it allowed, where are we.

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(
          'Location is off: turn it on to see the cinemas around you.',
        );
      }
      var permission = await Geolocator.checkPermission();
      if (permission == LocationPermission.denied) {
        permission = await Geolocator.requestPermission();
      }
      if (permission == LocationPermission.denied ||
          permission == LocationPermission.deniedForever) {
        throw const RelayException(
          'Location is refused: allow it in the settings to see the cinemas around you.',
        );
      }
      // about 100 m is enough for "within 15 km" and saves battery
      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 = 'Something went wrong.');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    final cinemas = _cinemas;
    return Scaffold(
      appBar: AppBar(title: const Text('Cinemas near me')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            FilledButton(
              onPressed: _loading ? null : _locate,
              child: Text(
                cinemas == null ? 'Find cinemas around me' : 'Refresh',
              ),
            ),
            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('No cinema within 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 (about 100 m) is enough to find the cinemas within 15 km: it answers faster than GPS and uses less battery.

Today's programme: lib/cinema_programme_page.dart

The slug and the cinema's name come from the list (the name titles the screen without another request). The request is made once per visit, in a late final field, so rebuilds never call the relay again. Each time opens the cinema's booking page.

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> {
  // one request per visit of the screen, not per rebuild
  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('No more showtimes today.'));
          }
          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(
                        // the cinema's booking page, when it has one
                        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,
                            ),
                          ],
                        ),
                      ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Times are the cinema's local time, as the API returns them: a user in Lyon who looks at a cinema in New York sees New York times, which is what they need to go there.

Run it

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

Pick your phone or a simulator when asked, tap Find cinemas around me, allow the location, then open a cinema. The simulator has no GPS: on iOS, Features › Location › Custom Location sets one; on Android, the emulator's Location panel does. Two things worth knowing:

  • In a browser. flutter run -d chrome --web-port 8080 runs the same app in Chrome, with the relay's default WEB_ORIGIN. Posters whose host does not send CORS headers stay grey in the browser; phones show them.
  • Xcode 27. If the iOS build stops on deployment target 13.0 is not supported, open ios/Runner.xcodeproj and set the iOS deployment target to 15.0, or replace 13.0 by 15.0 in ios/Runner.xcodeproj/project.pbxproj.

Going to the stores

  • Deploy the relay on any host that runs Dart (a container from dart compile exe, Cloud Run, Fly.io, a VPS) with API_CINEMA_KEY in its secrets, over HTTPS: production iOS and Android builds refuse plain HTTP. Then build with --dart-define=RELAY_URL=https://… and remove usesCleartextTraffic from the manifest and NSAppTransportSecurity from the plist.
  • One key per environment. A key for development, another for the relay in production: you can revoke one from the dashboard without cutting the app.
  • Keep the limit. The 30 calls a minute per address is generous for a person and stops a script. If your relay sits behind a proxy, make sure x-forwarded-for comes from it and not from the client.

How many requests does it cost?

One request per screen the cache cannot answer: a user who opens the list and two cinemas costs at most three requests, and much less in a busy area where neighbours share the cache for 15 minutes. With Starter's 1,000 requests a week, that is several hundred sessions a week; Pro goes ten times further.

Going further

  • Another day. Add a date parameter (YYYY-MM-DD) to the programme route of the relay and a row of days above the list; the API returns the programme of any upcoming day.
  • Films near me. A third route on /v1/showtimes with near, radius_km and date lists every showtime around the user, to build a "what's on tonight" screen sorted by start time.
  • Original version only. The API filters with version=VO or VOST: a switch on the programme screen, passed through the relay.

Every parameter and field is in the documentation. The same app exists in React Native with Expo, and the chatbot tutorial has a section on protecting the key that applies to the relay too.