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.
240 lines
8.1 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|