Files
farolero/lib/pantallas/pantalla_votacion_cliente.dart
T
FreeTLab 56f07364c2
Build & Deploy Farolero / Análisis de código (push) Successful in 13s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m22s
feat: confirm before leaving a game, and fix untranslated strings
Exit confirmation
- A dialog in the app's own style now guards every in-game screen. The safe
  action is the prominent one, so a stray tap keeps you in the game.
- Eleven screens covered, against both the system back gesture and the close
  buttons. The message adapts: a single-device game is lost, a host ends it
  for everyone, a client can rejoin.
- The two screens the host also opens from the manager take a flag so that
  back there just closes them instead of offering to quit.

Translations
- 21 keys were still holding the English text in Arabic, Basque, Hindi,
  Japanese, Korean, Dutch, Polish, Russian, Turkish and both Chinese
  variants: 231 strings, covering the whole multi-device flow, permissions
  and the mode picker. Nothing failed, the app just showed English.
- The permissions dialog was hardcoded in Spanish; it is localized now.
- Portuguese used Spanish gerunds where pt-PT takes "a + infinitive".

Checked what looked untranslated but is not: Animals and Notes are correct
Catalan, Configuration and Vibration correct French, Version correct German.
Those are whitelisted in the new test rather than silently rewritten.

l10n_cobertura_test guards all of it: no missing or orphan keys, no empty
values, no English left outside the whitelist, and placeholders preserved.
2026-07-25 23:59:57 +02:00

240 lines
8.1 KiB
Dart

import 'package:flutter/material.dart';
import 'package:farolero/tema/dialogo_confirmacion.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/pantallas/pantalla_notas_online.dart';
import 'package:farolero/pantallas/pantalla_revision_palabra.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart';
/// Pantalla de votación para cliente multidispositivo.
/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
/// por cada jugador controlado activo.
class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores;
final List<JugadorInicioPartida> jugadoresControlados;
final String? partidaId;
final String? pistaImpostor;
final Function(Map<String, String> votos) onVotos;
/// El host abre esta pantalla desde el gestor: allí el atrás solo
/// debe cerrarla, no ofrecer abandonar la partida.
final bool protegerSalida;
const PantallaVotacionCliente({
super.key,
required this.jugadores,
this.jugadoresControlados = const [],
this.partidaId,
this.pistaImpostor,
required this.onVotos,
this.protegerSalida = true,
});
@override
State<PantallaVotacionCliente> createState() => _PantallaVotacionClienteState();
}
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
final Map<String, String> _votosPorVotante = {};
/// Solo los jugadores vivos pueden ser votados.
List<Jugador> get _votables =>
widget.jugadores.where((jugador) => !jugador.eliminado).toList();
/// Y solo los jugadores vivos que controla este dispositivo pueden votar.
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados
.where(
(controlado) =>
_votables.any((jugador) => jugador.id == controlado.jugadorId),
)
.toList();
/// Protocolo antiguo: el cliente no recibió jugadores controlados y emite un
/// único voto sin identificar al votante.
bool get _modoLegacy => widget.jugadoresControlados.isEmpty;
/// Todos los jugadores de este dispositivo están eliminados: no vota nadie.
bool get _sinVotantesVivos => !_modoLegacy && _votantes.isEmpty;
bool get _votacionCompleta {
if (_sinVotantesVivos) return false;
if (_modoLegacy) return _votosPorVotante.containsKey('_legacy');
return _votantes.every(
(votante) => _votosPorVotante[votante.jugadorId] != null,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return GuardiaSalidaPartida(
contexto: ContextoSalida.cliente,
activo: widget.protegerSalida,
child: Scaffold(
backgroundColor: TemaApp.colorFondo,
appBar: AppBar(
title: Text(l10n.voting),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
IconButton(
tooltip: l10n.seeYourWord,
icon: IconoFarolero(Icons.visibility),
onPressed: widget.jugadoresControlados.isEmpty
? null
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
tooltip: l10n.notesTitle,
icon: IconoFarolero(Icons.edit_note),
onPressed: _puedeAbrirNotas
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaNotasOnline(
partidaId: widget.partidaId!,
jugadores: widget.jugadores,
autoresControlados: widget.jugadoresControlados,
),
),
)
: null,
),
],
),
body: FondoFarolero(
intenso: true,
respetarBarras: true,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ArteGameplayFarolero.fase(height: 108),
const SizedBox(height: 10),
EncabezadoFarolero(
icono: Icons.how_to_vote,
titulo: l10n.whoDoYouThinkIsTheImpostor,
subtitulo: l10n.selectOnePlayer,
color: TemaApp.colorAcento,
trailing: Image.asset(
'assets/ui/generated/meta/result_verdict_art.webp',
width: 42,
height: 42,
opacity: const AlwaysStoppedAnimation(0.64),
),
),
const SizedBox(height: 16),
Expanded(
child: _sinVotantesVivos
? Center(
child: EstadoVacioFarolero(
icono: Icons.hourglass_empty,
titulo: l10n.eliminatedCannotVote,
subtitulo: l10n.waitingVoting,
),
)
: _modoLegacy
? _buildSelectorLegacy()
: ListView.builder(
itemCount: _votantes.length,
itemBuilder: (context, index) {
final votante = _votantes[index];
return _buildSelectorParaVotante(context, votante);
},
),
),
const SizedBox(height: 16),
BotonFarolero.secundario(
texto: l10n.votar,
icono: Icons.how_to_vote,
assetIconPath: 'assets/ui/generated/actions/action_vote_mask.webp',
onPressed: _votacionCompleta
? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
: null,
),
],
),
),
),
),
);
}
bool get _puedeAbrirNotas {
return widget.partidaId != null &&
_votables.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty;
}
Widget _buildSelectorLegacy() {
final votables = _votables;
return ListView.builder(
itemCount: votables.length,
itemBuilder: (context, index) {
final jugador = votables[index];
final selected = _votosPorVotante['_legacy'] == jugador.id;
return _buildJugadorVotable(
jugador: jugador,
index: index,
selected: selected,
onTap: () => setState(() => _votosPorVotante['_legacy'] = jugador.id),
);
},
);
}
Widget _buildSelectorParaVotante(
BuildContext context,
JugadorInicioPartida votante,
) {
return PanelFarolero(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(14),
borderColor: TemaApp.colorAcento.withValues(alpha: 0.48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.voteOf(votante.nombre),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
..._votables.asMap().entries.map((entry) {
final jugador = entry.value;
final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
return _buildJugadorVotable(
jugador: jugador,
index: entry.key,
selected: selected,
onTap: () => setState(
() => _votosPorVotante[votante.jugadorId] = jugador.id,
),
);
}),
],
),
);
}
Widget _buildJugadorVotable({
required Jugador jugador,
required int index,
required bool selected,
required VoidCallback onTap,
}) {
return SelectorVotoFarolero(
nombre: '${index + 1}. ${jugador.nombre}',
seleccionado: selected,
onTap: onTap,
);
}
}