Capítulo 05 · Aplicaciones

Móvil: Flutter para meseros y repartidores

La app móvil tiene dos usuarios con vidas muy distintas: el mesero, que toma pedidos dentro del local, y el repartidor, que recorre la ciudad transmitiendo su ubicación y siguiendo la ruta óptima. Una sola app Flutter, dos modos según el rol del login.

5.1 Lo esencial de Flutter para este proyecto

Flutter dibuja la interfaz con su propio motor (no usa los controles nativos): todo es un widget, y la UI se describe como un árbol de widgets que se reconstruye cuando cambia el estado — la misma idea declarativa de React, con otra sintaxis. Lo mínimo que este capítulo asume:

dependencies:
  flutter:
    sdk: flutter
  dio: ^5.4.0                    # HTTP con interceptores (token, refresh)
  provider: ^6.1.0               # estado compartido sencillo
  flutter_secure_storage: ^9.0.0 # guardar tokens (Keystore/Keychain)
  web_socket_channel: ^3.0.0     # WebSocket
  geolocator: ^13.0.0            # GPS: posición y stream de posiciones
  flutter_map: ^7.0.0            # mapa (Leaflet-style, tiles OSM)
  latlong2: ^0.9.0               # tipos LatLng para flutter_map
  phone_state: ^2.0.0            # detectar llamadas entrantes (Android)
  esc_pos_utils_plus: ^2.0.0     # armar bytes ESC/POS (ticket móvil)
  print_bluetooth_thermal: ^1.1.0 # impresoras térmicas Bluetooth

Y el cliente HTTP central con el interceptor que promete el capítulo 2.4: agrega el token a cada petición y, si recibe 401, renueva y reintenta sin que las pantallas se enteren:

import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

final _storage = const FlutterSecureStorage();
final api = Dio(BaseOptions(baseUrl: 'http://192.168.1.50:8080'));

void configurarInterceptores() {
  api.interceptors.add(InterceptorsWrapper(
    onRequest: (opciones, handler) async {
      final token = await _storage.read(key: 'accessToken');
      if (token != null) {
        opciones.headers['Authorization'] = 'Bearer $token';
      }
      handler.next(opciones);
    },
    onError: (error, handler) async {
      // 401 = access token expirado → renovar y reintentar UNA vez
      if (error.response?.statusCode == 401 &&
          error.requestOptions.extra['reintento'] != true) {
        final refresh = await _storage.read(key: 'refreshToken');
        try {
          final r = await Dio().post(
            '${api.options.baseUrl}/api/auth/refresh',
            data: {'refreshToken': refresh},
          );
          await _storage.write(
              key: 'accessToken', value: r.data['accessToken']);
          error.requestOptions.extra['reintento'] = true;
          return handler.resolve(await api.fetch(error.requestOptions));
        } catch (_) {
          // el refresh también falló → sesión muerta → pantalla de login
          irAlLogin();
        }
      }
      handler.next(error);
    },
  ));
}

5.2 La ruta más rápida con múltiples paradas

El repartidor sale con cuatro pedidos: ¿en qué orden entregarlos y por qué calles? Son dos problemas distintos que un motor de rutas resuelve junto:

  1. Orden óptimo de paradas (el "problema del viajante", TSP): con 4 paradas hay 24 órdenes posibles; el motor encuentra el mejor.
  2. Ruta por calles entre cada par de puntos, respetando sentidos y velocidades reales.

Nunca implementes esto tú: usa OSRM (Open Source Routing Machine), el motor libre construido sobre OpenStreetMap. Su endpoint /trip hace exactamente los dos pasos de arriba en una sola llamada HTTP. Alternativas equivalentes: Google Directions API con waypoints=optimize:true (muy preciso, de pago) y OpenRouteService (gratis con registro, hasta cierto volumen).

GET https://router.project-osrm.org/trip/v1/driving/
      -101.1844,19.7008;-101.1720,19.6950;-101.1911,19.7102;-101.1650,19.7040
      ?source=first          ← empieza en el restaurante (primer punto)
      &roundtrip=true        ← y vuelve a él al terminar
      &overview=full         ← geometría completa de la ruta
      &geometries=geojson    ← coordenadas listas, sin decodificar polylines

⚠ OSRM usa longitud,latitud (¡al revés de lo habitual!)

Respuesta (recortada):
{
  "trips": [{
    "geometry": { "coordinates": [[-101.1844,19.7008], …] },  ← para dibujar
    "duration": 1284.6,      ← segundos totales
    "distance": 9871.2       ← metros totales
  }],
  "waypoints": [
    { "waypoint_index": 0 },  ← el punto 0 se visita primero
    { "waypoint_index": 2 },  ← el punto 1 se visita en 3er lugar…
    { "waypoint_index": 1 },
    { "waypoint_index": 3 }
  ]
}
import 'package:dio/dio.dart';
import 'package:latlong2/latlong.dart';

class RutaOptima {
  final List<LatLng> geometria;   // la línea a dibujar en el mapa
  final List<int> ordenParadas;   // ordenParadas[i] = posición de la parada i
  final double duracionMin;
  final double distanciaKm;
  RutaOptima(this.geometria, this.ordenParadas,
      this.duracionMin, this.distanciaKm);
}

Future<RutaOptima> calcularRuta(LatLng origen, List<LatLng> entregas) async {
  // OSRM pide "lng,lat;lng,lat;…" — ojo con el orden invertido
  final puntos = [origen, ...entregas]
      .map((p) => '${p.longitude},${p.latitude}')
      .join(';');

  final r = await Dio().get(
    'https://router.project-osrm.org/trip/v1/driving/$puntos',
    queryParameters: {
      'source': 'first',
      'roundtrip': 'true',
      'overview': 'full',
      'geometries': 'geojson',
    },
  );

  final trip = r.data['trips'][0];
  final coords = (trip['geometry']['coordinates'] as List)
      .map((c) => LatLng(c[1] as double, c[0] as double)) // ← reinvertimos
      .toList();
  final orden = (r.data['waypoints'] as List)
      .map((w) => w['waypoint_index'] as int)
      .toList();

  return RutaOptima(coords, orden,
      trip['duration'] / 60.0, trip['distance'] / 1000.0);
}

Dibujarla es un mapa flutter_map con una capa de línea y los marcadores numerados según el orden que devolvió OSRM:

import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';

class PantallaRuta extends StatelessWidget {
  final RutaOptima ruta;
  final List<LatLng> entregas;
  const PantallaRuta({super.key, required this.ruta, required this.entregas});

  @override
  Widget build(BuildContext context) {
    return FlutterMap(
      options: MapOptions(
        initialCameraFit: CameraFit.coordinates(
          coordinates: ruta.geometria,          // encuadra toda la ruta
          padding: const EdgeInsets.all(40),
        ),
      ),
      children: [
        TileLayer(
          urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
          userAgentPackageName: 'com.turestaurante.movil',
        ),
        PolylineLayer(polylines: [
          Polyline(
            points: ruta.geometria,
            strokeWidth: 5,
            color: const Color(0xFF1E5FA8),
          ),
        ]),
        MarkerLayer(markers: [
          for (var i = 0; i < entregas.length; i++)
            Marker(
              point: entregas[i],
              width: 34, height: 34,
              child: CircleAvatar(
                backgroundColor: const Color(0xFF1E5FA8),
                // +1 porque el índice 0 del trip es el restaurante:
                child: Text('${ruta.ordenParadas[i + 1]}',
                    style: const TextStyle(color: Colors.white)),
              ),
            ),
        ]),
      ],
    );
  }
}

El servidor público router.project-osrm.org es una demo: sirve para desarrollar y para un negocio pequeño, pero no garantiza disponibilidad. Cuando el reparto sea crítico, monta tu propio OSRM — es un contenedor Docker con el mapa de tu región descargado de Geofabrik: docker run -p 5000:5000 osrm/osrm-backend tras preprocesar el .osm.pbf. Tu backend puede hacer de proxy para no exponerlo.

5.3 Transmitir la ubicación por WebSocket

El teléfono del repartidor emite su posición al canal /ws/ubicaciones del backend en la nube (está en 4G, fuera de la red local); la nube la baja al backend local por el túnel del capítulo 3.7 y el mapa del escritorio (capítulo 4.2) la pinta. Tres piezas: permisos, el stream de GPS y el socket.

Permisos (la parte que siempre se olvida)

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!-- solo si transmitirás con la app minimizada: -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Usamos tu ubicación para mostrar el reparto en curso.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Permite seguir el reparto aunque la app esté en segundo plano.</string>
import 'dart:async';
import 'dart:convert';
import 'package:geolocator/geolocator.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class TransmisorUbicacion {
  WebSocketChannel? _canal;
  StreamSubscription<Position>? _gps;
  bool _activo = false;

  Future<void> iniciar(String token, int repartidorId) async {
    _activo = true;

    // 1. permisos en tiempo de ejecución
    var permiso = await Geolocator.checkPermission();
    if (permiso == LocationPermission.denied) {
      permiso = await Geolocator.requestPermission();
    }
    if (permiso == LocationPermission.denied ||
        permiso == LocationPermission.deniedForever) {
      throw Exception('sin permiso de ubicación');
    }

    // 2. conectar el WebSocket (token en la query, cap. 2.7).
    //    OJO: el repartidor anda en 4G, fuera de la red local, así que
    //    conecta al backend EN LA NUBE (cap. 3.7), no a la IP del local:
    _canal = WebSocketChannel.connect(Uri.parse(
        'wss://api.turestaurante.com/ws/ubicaciones?token=$token'));

    // si el socket muere, reintenta en 3 s (backoff simple)
    _canal!.stream.listen((_) {}, onDone: () {
      if (_activo) {
        Future.delayed(const Duration(seconds: 3),
            () => iniciar(token, repartidorId));
      }
    });

    // 3. stream de GPS: emite solo al moverse ≥ 15 metros.
    //    distanceFilter es EL parámetro de batería: sin él, el GPS
    //    dispara varias veces por segundo y funde el teléfono.
    _gps = Geolocator.getPositionStream(
      locationSettings: const LocationSettings(
        accuracy: LocationAccuracy.high,
        distanceFilter: 15,
      ),
    ).listen((pos) {
      _canal?.sink.add(jsonEncode({
        'type': 'UBICACION',
        'datos': {
          'repartidorId': repartidorId,
          'lat': pos.latitude,
          'lng': pos.longitude,
          'ts': DateTime.now().toUtc().toIso8601String(),
        },
      }));
    });
  }

  void detener() {
    _activo = false;
    _gps?.cancel();
    _canal?.sink.close();
  }
}

¿Y si el repartidor bloquea el teléfono? Android congela las apps en segundo plano. Para seguir transmitiendo necesitas un foreground service: un servicio con notificación persistente ("Reparto en curso 🛵") que el sistema no mata. El paquete flutter_background_service lo resuelve: mueves este transmisor a su onStart y listo. Empieza sin él (pantalla encendida con la ruta visible, que es lo normal en reparto) y agrégalo cuando lo necesites de verdad.

5.4 Llamada entrante → aviso en el escritorio

El flujo soñado de todo restaurante con servicio a domicilio: suena el teléfono, y en la pantalla de la caja aparece el cliente con su historial antes de contestar. La cadena completa:

teléfono suena → phone_state detecta RINGING + número
   → POST /api/llamadas  {telefono: "4431234567"}
      → backend busca al cliente por teléfono
         → difunde por /ws/notificaciones:
           {"type":"LLAMADA_ENTRANTE",
            "datos":{"telefono":"4431234567","cliente":"María López"}}
            → el escritorio muestra el popup (cap. 4.4)

Esto solo es posible en Android. iOS no permite que apps de terceros lean el número de una llamada entrante (CallKit solo permite etiquetar llamadas con una base de datos precargada). Además, Google Play restringe el permiso READ_CALL_LOG en apps publicadas — pero tu app es interna y se distribuye por APK directo (capítulo 4.6), así que no pasa por esa revisión. El teléfono que recibe pedidos será un Android dedicado del restaurante: decisión de hardware, no de software.

<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<!-- necesario para que el número llegue en el evento: -->
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
import 'dart:async';
import 'package:phone_state/phone_state.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../api/cliente.dart';

class DetectorLlamadas {
  StreamSubscription<PhoneState>? _sub;
  String? _ultimoNumero;   // para no reportar la misma llamada dos veces

  Future<void> iniciar() async {
    // pedir ambos permisos en tiempo de ejecución
    await [Permission.phone].request();

    _sub = PhoneState.stream.listen((estado) async {
      final esEntrante = estado.status == PhoneStateStatus.CALL_INCOMING;
      final numero = estado.number;

      if (esEntrante && numero != null && numero != _ultimoNumero) {
        _ultimoNumero = numero;
        try {
          // REST y no WS: si la red parpadea, dio puede reintentar,
          // y el backend registra la llamada aunque nadie esté mirando
          await api.post('/api/llamadas', data: {'telefono': numero});
        } catch (_) {
          // sin red: no bloqueamos el teléfono por esto
        }
      }
      if (estado.status == PhoneStateStatus.CALL_ENDED) {
        _ultimoNumero = null;
      }
    });
  }

  void detener() => _sub?.cancel();
}

En el backend, el handler de POST /api/llamadas hace la parte inteligente: busca si el teléfono pertenece a un cliente conocido, junta sus últimos pedidos y difunde el mensaje LLAMADA_ENTRANTE por el canal de notificaciones. El escritorio ya sabe qué hacer con él desde el capítulo 4.4.

5.5 Tomar pedidos como mesero

El módulo de mesero es un CRUD con un modelo mental claro: un carrito local que al confirmarse se convierte en un POST /api/pedidos. A partir de ahí el pedido es del backend, y su vida se rige por esta máquina de estados:

PENDIENTE EN_PREPARACION LISTO ENTREGADO CANCELADO cocina cocina mesero/reparto solo desde PENDIENTE
Cada flecha es un PATCH /api/pedidos/{id}/estado; el backend rechaza transiciones inválidas con 409.

El carrito como ChangeNotifier — nota que guarda el platillo completo para pintar la UI, pero al enviar solo manda IDs y cantidades (regla del capítulo 1.1: los precios los pone el servidor):

import 'package:flutter/foundation.dart';
import '../../api/cliente.dart';
import '../../models/platillo.dart';

class ItemCarrito {
  final Platillo platillo;
  int cantidad;
  String? notas;
  ItemCarrito(this.platillo, this.cantidad, [this.notas]);
}

class Carrito extends ChangeNotifier {
  final List<ItemCarrito> items = [];
  int? mesaId;
  bool enviando = false;

  // total SOLO para mostrar en pantalla; el servidor calcula el real
  double get totalEstimado => items.fold(
      0, (suma, i) => suma + i.platillo.precio * i.cantidad);

  void agregar(Platillo p) {
    final existente = items.where((i) => i.platillo.id == p.id).firstOrNull;
    if (existente != null) {
      existente.cantidad++;
    } else {
      items.add(ItemCarrito(p, 1));
    }
    notifyListeners();   // los widgets suscritos se redibujan
  }

  Future<void> enviar() async {
    if (items.isEmpty || enviando) return;   // anti doble-tap
    enviando = true;
    notifyListeners();
    try {
      await api.post('/api/pedidos', data: {
        'mesaId': mesaId,
        'tipo': 'MESA',
        'items': [
          for (final i in items)
            {
              'platilloId': i.platillo.id,
              'cantidad': i.cantidad,
              'notas': i.notas,
            }
        ],
      });
      items.clear();   // éxito: carrito limpio, la cocina ya lo tiene
    } finally {
      enviando = false;
      notifyListeners();
    }
  }
}

Y la pantalla de toma de pedido, resumida a su estructura (menú arriba, carrito abajo, confirmar al final):

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

  @override
  Widget build(BuildContext context) {
    final carrito = context.watch<Carrito>();   // se redibuja al cambiar

    return Scaffold(
      appBar: AppBar(title: Text('Mesa ${carrito.mesaId ?? "—"}')),
      body: Column(children: [
        // el menú viene de GET /api/platillos, cacheado al abrir la app
        Expanded(child: ListaMenu(alTocar: carrito.agregar)),
        const Divider(height: 1),
        // resumen del carrito con cantidades y notas
        ResumenCarrito(items: carrito.items),
      ]),
      bottomNavigationBar: SafeArea(
        child: FilledButton(
          onPressed: carrito.enviando ? null : () async {
            try {
              await carrito.enviar();
              if (context.mounted) {
                ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
                    content: Text('Pedido enviado a cocina ✓')));
              }
            } on DioException catch (e) {
              // 400/422: el backend explica qué estuvo mal (cap. 1.4)
              final msj = e.response?.data['error'] ?? 'No se pudo enviar';
              if (context.mounted) {
                ScaffoldMessenger.of(context)
                    .showSnackBar(SnackBar(content: Text('$msj')));
              }
            }
          },
          child: Text(carrito.enviando
              ? 'Enviando…'
              : 'Enviar a cocina · \$${carrito.totalEstimado.toStringAsFixed(2)}'),
        ),
      ),
    );
  }
}

¿Y cómo llega a la cocina? Ya llegó: el PedidoService del capítulo 3.1 difunde PEDIDO_NUEVO por el canal /ws/cocina y encola la comanda en la impresora de cocina. La "pantalla de cocina" (KDS) es simplemente la app de escritorio en modo cocina: una vista React que escucha ese canal con useCanal("cocina", …), muestra los pedidos como tarjetas ordenadas por antigüedad, y cada tarjeta tiene botones que hacen PATCH /api/pedidos/{id}/estado para avanzar la máquina de estados. Impresora y pantalla no compiten: la impresora deja constancia física (no se borra con un apagón) y la pantalla organiza el trabajo; muchos restaurantes usan ambas.

5.6 Pedir un ticket impreso desde el móvil

El caso normal es idéntico al del escritorio (capítulo 4.7) porque la impresión es del backend: el mesero pulsa "Imprimir cuenta" y la app hace un POST — la impresora de caja imprime, sin importar desde qué dispositivo se pidió:

Future<void> pedirTicket(int pedidoId) async {
  // 202 Accepted: el backend lo encoló; la confirmación llega por WS
  await api.post('/api/pedidos/$pedidoId/ticket');
}

El caso especial es el repartidor con impresora Bluetooth portátil (esas térmicas de 58 mm de bolsillo): ahí no hay backend que alcance la impresora, y el teléfono imprime directo. Se arma el ticket con esc_pos_utils_plus (la misma lógica ESC/POS del capítulo 4.3, con API cómoda) y se envía por Bluetooth:

import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';

Future<void> imprimirTicketBluetooth(PedidoDetalle pedido) async {
  // 1. conectar (la impresora ya emparejada en ajustes de Android)
  final impresoras = await PrintBluetoothThermal.pairedBluetooths;
  final termica = impresoras.firstWhere((i) => i.name.contains('Printer'));
  await PrintBluetoothThermal.connect(macPrinterAddress: termica.macAdress);

  // 2. armar los bytes ESC/POS (58 mm → PaperSize.mm58, 32 columnas)
  final perfil = await CapabilityProfile.load();
  final gen = Generator(PaperSize.mm58, perfil);
  List<int> bytes = [];

  bytes += gen.text('LA COMANDA',
      styles: const PosStyles(align: PosAlign.center,
          height: PosTextSize.size2, width: PosTextSize.size2));
  bytes += gen.hr();
  for (final item in pedido.items) {
    bytes += gen.row([
      PosColumn(text: '${item.cantidad}x ${item.nombre}', width: 9),
      PosColumn(text: '\$${item.subtotal.toStringAsFixed(2)}', width: 3,
          styles: const PosStyles(align: PosAlign.right)),
    ]);
  }
  bytes += gen.hr();
  bytes += gen.row([
    PosColumn(text: 'TOTAL', width: 6,
        styles: const PosStyles(bold: true)),
    PosColumn(text: '\$${pedido.total.toStringAsFixed(2)}', width: 6,
        styles: const PosStyles(bold: true, align: PosAlign.right)),
  ]);
  bytes += gen.feed(2);
  bytes += gen.cut();

  // 3. enviar
  await PrintBluetoothThermal.writeBytes(bytes);
}

Fíjate cómo gen.row() con columnas de ancho 9+3 y 6+6 (sobre una rejilla de 12) resuelve la alineación izquierda/derecha que en el capítulo 4.3 hicimos a mano con espacios. Es la misma cuadrícula de 32 caracteres por debajo — por eso valió la pena aprender primero los bytes: ahora cualquier librería ESC/POS de cualquier lenguaje te resulta transparente.