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.
171 lines
5.5 KiB
Dart
171 lines
5.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import 'componentes_farolero.dart';
|
|
import 'tema_app.dart';
|
|
|
|
/// Quién está saliendo, que determina qué se pierde y por tanto qué se avisa.
|
|
enum ContextoSalida {
|
|
/// Un solo móvil: la partida vive en este dispositivo y se pierde entera.
|
|
unDispositivo,
|
|
|
|
/// El host de una partida multidispositivo: al salir la corta para todos.
|
|
host,
|
|
|
|
/// Un cliente: solo se va él y puede volver mientras la sala siga abierta.
|
|
cliente,
|
|
}
|
|
|
|
/// Diálogo de confirmación con el estilo de la app.
|
|
///
|
|
/// Devuelve `true` solo si el jugador confirma. Cerrarlo tocando fuera o con
|
|
/// el gesto de atrás cuenta como cancelar, que es lo que interesa cuando lo
|
|
/// que se está evitando es precisamente una salida accidental.
|
|
Future<bool> confirmarFarolero({
|
|
required BuildContext context,
|
|
required String titulo,
|
|
required String mensaje,
|
|
required String textoConfirmar,
|
|
required String textoCancelar,
|
|
IconData icono = Icons.warning_amber_rounded,
|
|
IconData iconoConfirmar = Icons.logout,
|
|
IconData iconoCancelar = Icons.play_arrow,
|
|
}) async {
|
|
final confirmado = await showDialog<bool>(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
builder: (dialogContext) => Dialog(
|
|
backgroundColor: TemaApp.colorSuperficie,
|
|
insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
side: BorderSide(
|
|
color: TemaApp.colorNaranja.withValues(alpha: 0.45),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 72,
|
|
height: 72,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: TemaApp.colorAcento.withValues(alpha: 0.16),
|
|
border: Border.all(
|
|
color: TemaApp.colorAcento.withValues(alpha: 0.65),
|
|
width: 2,
|
|
),
|
|
),
|
|
child: IconoFarolero(
|
|
icono,
|
|
color: TemaApp.colorNaranja,
|
|
size: 38,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
titulo,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(dialogContext).textTheme.titleLarge?.copyWith(
|
|
color: TemaApp.colorDorado,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
mensaje,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(dialogContext).textTheme.bodyMedium?.copyWith(
|
|
color: TemaApp.colorTextoSecundario,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
const SizedBox(height: 26),
|
|
// La acción segura va primero y destacada: si alguien llega aquí
|
|
// sin querer, lo cómodo tiene que ser quedarse.
|
|
BotonFarolero(
|
|
texto: textoCancelar,
|
|
icono: iconoCancelar,
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
BotonFarolero.oscuro(
|
|
texto: textoConfirmar,
|
|
icono: iconoConfirmar,
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return confirmado ?? false;
|
|
}
|
|
|
|
/// Pregunta antes de abandonar una partida en curso.
|
|
Future<bool> confirmarSalirDePartida(
|
|
BuildContext context,
|
|
ContextoSalida contexto,
|
|
) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return confirmarFarolero(
|
|
context: context,
|
|
titulo: l10n.leaveGameTitle,
|
|
mensaje: switch (contexto) {
|
|
ContextoSalida.unDispositivo => l10n.leaveGameSingleDevice,
|
|
ContextoSalida.host => l10n.leaveGameHost,
|
|
ContextoSalida.cliente => l10n.leaveGameClient,
|
|
},
|
|
textoConfirmar: l10n.leaveGame,
|
|
textoCancelar: l10n.stayInGame,
|
|
);
|
|
}
|
|
|
|
/// Envuelve una pantalla de partida para que el gesto o botón de atrás no la
|
|
/// abandone sin preguntar.
|
|
///
|
|
/// Se encarga solo del atrás del sistema; los botones de cerrar de cada
|
|
/// pantalla llaman a [confirmarSalirDePartida] por su cuenta, porque además de
|
|
/// confirmar tienen que desconectar y navegar.
|
|
class GuardiaSalidaPartida extends StatelessWidget {
|
|
final Widget child;
|
|
final ContextoSalida contexto;
|
|
|
|
/// Qué hacer cuando el jugador confirma que quiere salir. Si es null se deja
|
|
/// que la ruta se cierre con normalidad.
|
|
final VoidCallback? onSalirConfirmado;
|
|
|
|
/// Permite desactivar la guarda sin sacar el widget del árbol, por ejemplo
|
|
/// cuando la partida ya ha terminado.
|
|
final bool activo;
|
|
|
|
const GuardiaSalidaPartida({
|
|
super.key,
|
|
required this.child,
|
|
required this.contexto,
|
|
this.onSalirConfirmado,
|
|
this.activo = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: !activo,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final salir = await confirmarSalirDePartida(context, contexto);
|
|
if (!salir || !context.mounted) return;
|
|
final alSalir = onSalirConfirmado;
|
|
if (alSalir != null) {
|
|
alSalir();
|
|
} else if (Navigator.of(context).canPop()) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|