El Impostor v0.1 — app Flutter completa

Juego de deducción social para 3-20 jugadores.
Modo un solo móvil completamente funcional.
1000 palabras en 10 categorías.
Notas privadas, votación, adivinanza, revancha.
Material 3 dark theme.
Package: es.freetimelab.elimpostor
This commit is contained in:
ShanaiaBot
2026-04-04 00:50:04 +02:00
parent eb7661cb36
commit de2c8ffa18
45 changed files with 4206 additions and 0 deletions

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/tema_app.dart';
import 'pantalla_debate.dart';
import 'pantalla_fin_partida.dart';
class PantallaAdivinanza extends StatefulWidget {
const PantallaAdivinanza({super.key});
@override
State<PantallaAdivinanza> createState() => _PantallaAdivinanzaState();
}
class _PantallaAdivinanzaState extends State<PantallaAdivinanza> {
final _controlador = TextEditingController();
bool? _acierto;
@override
void dispose() {
_controlador.dispose();
super.dispose();
}
void _intentarAdivinar() {
final estado = context.read<EstadoJuego>();
final resultado = estado.intentarAdivinar(_controlador.text);
setState(() => _acierto = resultado);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
return Scaffold(
appBar: AppBar(
title: const Text('🎯 Adivinanza del impostor'),
automaticallyImplyLeading: false,
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🎭', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
'El impostor eliminado puede\nintentar adivinar la palabra',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Si acierta, ¡los impostores ganan!',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: TemaApp.colorNaranja,
),
),
const SizedBox(height: 32),
if (_acierto == null) ...[
TextField(
controller: _controlador,
decoration: const InputDecoration(
hintText: '¿Cuál crees que es la palabra?',
prefixIcon: Icon(Icons.search),
),
textCapitalization: TextCapitalization.sentences,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 20),
onSubmitted: (_) => _intentarAdivinar(),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
// No intenta adivinar, siguiente ronda
final fin = estado.comprobarFinPartida();
if (fin) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaFinPartida(),
),
);
} else {
estado.siguienteRonda();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaDebate(),
),
);
}
},
child: const Text('No intentar'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: _controlador.text.trim().isNotEmpty
? _intentarAdivinar
: null,
icon: const Icon(Icons.send),
label: const Text('Adivinar'),
),
),
],
),
],
if (_acierto == true) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: TemaApp.colorAcento.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: TemaApp.colorAcento),
),
child: Column(
children: [
const Text('🎭🎉', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
Text(
'¡Ha acertado!',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: TemaApp.colorAcento),
),
const SizedBox(height: 8),
Text(
'La palabra era: ${partida.palabraSecreta}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'¡Los impostores ganan!',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: TemaApp.colorNaranja,
),
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaFinPartida(),
),
);
},
icon: const Icon(Icons.emoji_events),
label: const Text('Ver resultado final'),
),
),
],
if (_acierto == false) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: TemaApp.colorVerde.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: TemaApp.colorVerde),
),
child: Column(
children: [
const Text('', style: TextStyle(fontSize: 48)),
const SizedBox(height: 12),
Text(
'¡No ha acertado!',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: TemaApp.colorVerde),
),
const SizedBox(height: 8),
Text(
'La partida continúa...',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
final fin = estado.comprobarFinPartida();
if (fin) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaFinPartida(),
),
);
} else {
estado.siguienteRonda();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaDebate(),
),
);
}
},
icon: const Icon(Icons.skip_next),
label: const Text('Siguiente ronda'),
),
),
],
],
),
),
),
);
}
}

View File

@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../modelos/partida.dart';
import '../tema/tema_app.dart';
import 'pantalla_ver_palabra.dart';
class PantallaCrearPartida extends StatefulWidget {
const PantallaCrearPartida({super.key});
@override
State<PantallaCrearPartida> createState() => _PantallaCrearPartidaState();
}
class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
bool _modoMultimovil = false;
String _categoria = 'todas';
int _numImpostores = 1;
bool _pistaImpostor = false;
int? _tiempoDebate;
final List<String> _jugadores = [];
final _controladorNombre = TextEditingController();
final _opcionesTiempo = <int?>[null, 60, 120, 180, 300];
final _etiquetasTiempo = ['Sin límite', '1 min', '2 min', '3 min', '5 min'];
int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4);
void _agregarJugador() {
final nombre = _controladorNombre.text.trim();
if (nombre.isEmpty) return;
if (_jugadores.contains(nombre)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Ya existe un jugador con ese nombre')),
);
return;
}
if (_jugadores.length >= 20) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Máximo 20 jugadores')),
);
return;
}
setState(() {
_jugadores.add(nombre);
_controladorNombre.clear();
if (_numImpostores > _maxImpostores) {
_numImpostores = _maxImpostores;
}
});
}
void _eliminarJugador(int index) {
setState(() {
_jugadores.removeAt(index);
if (_numImpostores > _maxImpostores && _maxImpostores > 0) {
_numImpostores = _maxImpostores;
}
});
}
void _iniciarPartida() {
if (_jugadores.length < 3) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Se necesitan al menos 3 jugadores')),
);
return;
}
final estado = context.read<EstadoJuego>();
estado.crearPartida(
config: ConfigPartida(
modoMultimovil: _modoMultimovil,
categoria: _categoria,
numImpostores: _numImpostores,
pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate,
),
nombresJugadores: _jugadores,
);
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const PantallaVerPalabra()),
);
}
@override
void dispose() {
_controladorNombre.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final categorias = ['todas', ...?estado.banco?.nombresCategorias];
return Scaffold(
appBar: AppBar(title: const Text('Crear partida')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Modo de juego
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Modo de juego',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
label: Text('Un solo móvil'),
icon: Icon(Icons.phone_android),
),
ButtonSegment(
value: true,
label: Text('Multimóvil'),
icon: Icon(Icons.devices),
),
],
selected: {_modoMultimovil},
onSelectionChanged: (valor) {
setState(() => _modoMultimovil = valor.first);
},
),
],
),
),
),
const SizedBox(height: 12),
// Categoría
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Categoría',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: DropdownButtonFormField<String>(
initialValue: _categoria,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.category),
),
items: categorias.map((c) {
return DropdownMenuItem(
value: c,
child: Text(BancoPalabras.nombreBonitoCategoria(c)),
);
}).toList(),
onChanged: (v) => setState(() => _categoria = v!),
),
),
],
),
),
),
const SizedBox(height: 12),
// Jugadores
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Jugadores (${_jugadores.length})',
style: Theme.of(context).textTheme.titleLarge),
Text('3-20',
style: Theme.of(context).textTheme.bodyMedium),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _controladorNombre,
decoration: const InputDecoration(
hintText: 'Nombre del jugador',
prefixIcon: Icon(Icons.person_add),
),
textCapitalization: TextCapitalization.words,
onSubmitted: (_) => _agregarJugador(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _agregarJugador,
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 8),
..._jugadores.asMap().entries.map((e) {
return ListTile(
leading: CircleAvatar(
backgroundColor: TemaApp.colorTarjeta,
child: Text('${e.key + 1}',
style:
const TextStyle(color: TemaApp.colorTexto)),
),
title: Text(e.value),
trailing: IconButton(
icon: const Icon(Icons.close, color: TemaApp.colorAcento),
onPressed: () => _eliminarJugador(e.key),
),
dense: true,
);
}),
],
),
),
),
const SizedBox(height: 12),
// Configuración de partida
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Configuración',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
// Número de impostores
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('🎭 Impostores'),
Row(
children: [
IconButton(
onPressed: _numImpostores > 1
? () => setState(() => _numImpostores--)
: null,
icon: const Icon(Icons.remove_circle_outline),
),
Text('$_numImpostores',
style: Theme.of(context)
.textTheme
.titleLarge),
IconButton(
onPressed: _numImpostores < _maxImpostores
? () => setState(() => _numImpostores++)
: null,
icon: const Icon(Icons.add_circle_outline),
),
],
),
],
),
// Pista para impostor
SwitchListTile(
title: const Text('🔍 Pista para impostor'),
subtitle: const Text(
'El impostor conoce la categoría'),
value: _pistaImpostor,
onChanged: (v) =>
setState(() => _pistaImpostor = v),
contentPadding: EdgeInsets.zero,
),
// Temporizador
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('⏱️ Tiempo de debate'),
DropdownButton<int?>(
value: _tiempoDebate,
items: List.generate(
_opcionesTiempo.length,
(i) => DropdownMenuItem(
value: _opcionesTiempo[i],
child: Text(_etiquetasTiempo[i]),
),
),
onChanged: (v) =>
setState(() => _tiempoDebate = v),
),
],
),
],
),
),
),
const SizedBox(height: 24),
// Botón iniciar
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _jugadores.length >= 3 ? _iniciarPartida : null,
icon: const Icon(Icons.play_arrow),
label: const Text('Iniciar partida'),
style: ElevatedButton.styleFrom(
textStyle: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,228 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas.dart';
import 'pantalla_votacion.dart';
class PantallaDebate extends StatefulWidget {
const PantallaDebate({super.key});
@override
State<PantallaDebate> createState() => _PantallaDebateState();
}
class _PantallaDebateState extends State<PantallaDebate> {
Timer? _timer;
int _segundosRestantes = 0;
bool _tiempoAgotado = false;
@override
void initState() {
super.initState();
final estado = context.read<EstadoJuego>();
final tiempo = estado.partida?.config.tiempoDebateSegundos;
if (tiempo != null) {
_segundosRestantes = tiempo;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_segundosRestantes > 0) {
setState(() => _segundosRestantes--);
} else {
timer.cancel();
setState(() => _tiempoAgotado = true);
}
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
}
void _irAVotacion() {
final estado = context.read<EstadoJuego>();
estado.iniciarVotacion();
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const PantallaVotacion()),
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
final tieneTemporizador = partida.config.tiempoDebateSegundos != null;
final progreso = tieneTemporizador
? _segundosRestantes / partida.config.tiempoDebateSegundos!
: 0.0;
return Scaffold(
appBar: AppBar(
title: Text('Debate - Ronda ${partida.rondaActual}'),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Temporizador
if (tieneTemporizador) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: _tiempoAgotado
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(16),
border: _tiempoAgotado
? Border.all(color: TemaApp.colorAcento, width: 2)
: null,
),
child: Column(
children: [
Text(
_tiempoAgotado ? '⏰ ¡Tiempo agotado!' : '⏱️ Tiempo restante',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: _tiempoAgotado
? TemaApp.colorAcento
: TemaApp.colorTextoSecundario,
),
),
const SizedBox(height: 8),
Text(
_formatearTiempo(_segundosRestantes),
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontSize: 48,
fontWeight: FontWeight.bold,
color: _segundosRestantes < 10 && !_tiempoAgotado
? TemaApp.colorAcento
: TemaApp.colorTexto,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progreso,
backgroundColor: TemaApp.colorSuperficie,
valueColor: AlwaysStoppedAnimation(
_segundosRestantes < 10
? TemaApp.colorAcento
: TemaApp.colorVerde,
),
minHeight: 6,
),
),
],
),
),
const SizedBox(height: 16),
],
// Jugadores activos
Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Jugadores en debate',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
'${partida.jugadoresActivos.length} activos • ${partida.impostoresActivos.length} impostor(es) ocultos',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
Expanded(
child: ListView.builder(
itemCount: partida.jugadores.length,
itemBuilder: (context, index) {
final j = partida.jugadores[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: j.eliminado
? Colors.grey
: TemaApp.colorAcento,
child: Text(
j.eliminado ? '💀' : '${index + 1}',
style: const TextStyle(
color: Colors.white, fontSize: 14),
),
),
title: Text(
j.nombre,
style: TextStyle(
decoration: j.eliminado
? TextDecoration.lineThrough
: null,
color: j.eliminado
? TemaApp.colorTextoSecundario
: TemaApp.colorTexto,
),
),
subtitle: j.eliminado
? const Text('Eliminado')
: null,
dense: true,
);
},
),
),
],
),
),
),
),
const SizedBox(height: 16),
// Botones
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaNotas(),
),
);
},
icon: const Text('📝', style: TextStyle(fontSize: 18)),
label: const Text('Notas'),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: ElevatedButton.icon(
onPressed: _irAVotacion,
icon: const Text('🗳️', style: TextStyle(fontSize: 18)),
label: const Text('Ir a votación'),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../tema/tema_app.dart';
import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart';
class PantallaFinPartida extends StatelessWidget {
const PantallaFinPartida({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
final ganaronJugadores = partida.ganador == 'jugadores';
final impostores =
partida.jugadores.where((j) => j.esImpostor).toList();
return Scaffold(
appBar: AppBar(
title: const Text('Fin de partida'),
automaticallyImplyLeading: false,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Ganador
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: ganaronJugadores
? [TemaApp.colorVerde.withValues(alpha: 0.3), TemaApp.colorVerde.withValues(alpha: 0.1)]
: [TemaApp.colorAcento.withValues(alpha: 0.3), TemaApp.colorAcento.withValues(alpha: 0.1)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: ganaronJugadores
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
child: Column(
children: [
Text(
ganaronJugadores ? '🎉' : '🎭',
style: const TextStyle(fontSize: 64),
),
const SizedBox(height: 16),
Text(
ganaronJugadores
? '¡Los jugadores ganan!'
: '¡Los impostores ganan!',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
color: ganaronJugadores
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
// Palabra secreta
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text('🔍 La palabra era:',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
partida.palabraSecreta,
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(
color: TemaApp.colorNaranja,
fontSize: 32,
),
),
const SizedBox(height: 4),
Text(
'Categoría: ${BancoPalabras.nombreBonitoCategoria(partida.categoriaReal)}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
// Impostores
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
'🎭 ${impostores.length == 1 ? 'El impostor era:' : 'Los impostores eran:'}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...impostores.map((j) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('🎭 ',
style: const TextStyle(fontSize: 18)),
Text(
j.nombre,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(color: TemaApp.colorAcento),
),
if (j.eliminado) ...[
const SizedBox(width: 8),
const Text('💀',
style: TextStyle(fontSize: 16)),
],
],
),
)),
],
),
),
),
const SizedBox(height: 16),
// Estadísticas de votaciones
if (partida.historialVotaciones.isNotEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('📊 Historial de votaciones',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
...partida.historialVotaciones
.asMap()
.entries
.map((entrada) {
final ronda = entrada.key + 1;
final resultado = entrada.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ronda $ronda: ${resultado.eliminadoNombre} ${resultado.eraImpostor ? '🎭' : '😇'}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
...resultado.votos.entries.map((v) {
final votante = partida.jugadores
.firstWhere((j) => j.id == v.key);
final votado = partida.jugadores
.firstWhere((j) => j.id == v.value);
return Text(
' ${votante.nombre}${votado.nombre}',
style: Theme.of(context)
.textTheme
.bodyMedium,
);
}),
],
),
);
}),
],
),
),
),
const SizedBox(height: 24),
// Botones
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
estado.revancha();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaVerPalabra(),
),
);
},
icon: const Icon(Icons.replay),
label: const Text('Revancha'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () {
estado.limpiar();
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => const PantallaPrincipal(),
),
(route) => false,
);
},
icon: const Icon(Icons.home),
label: const Text('Menú principal'),
),
),
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,222 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../servicios/servicio_notas.dart';
import '../tema/tema_app.dart';
class PantallaNotas extends StatefulWidget {
const PantallaNotas({super.key});
@override
State<PantallaNotas> createState() => _PantallaNotasState();
}
class _PantallaNotasState extends State<PantallaNotas> {
String? _jugadorSeleccionadoId;
final Map<String, TextEditingController> _controladores = {};
final _controladorNotaLibre = TextEditingController();
bool _cargado = false;
@override
void dispose() {
for (final c in _controladores.values) {
c.dispose();
}
_controladorNotaLibre.dispose();
super.dispose();
}
Future<void> _cargarNotas(String jugadorId) async {
final datos = await ServicioNotas.cargarNotas(jugadorId);
final notas = datos['notas'] as Map<String, String>;
final notaLibre = datos['notaLibre'] as String;
for (final entrada in notas.entries) {
if (_controladores.containsKey(entrada.key)) {
_controladores[entrada.key]!.text = entrada.value;
}
}
_controladorNotaLibre.text = notaLibre;
setState(() => _cargado = true);
}
Future<void> _guardarNotas() async {
if (_jugadorSeleccionadoId == null) return;
final notas = <String, String>{};
for (final entrada in _controladores.entries) {
if (entrada.value.text.isNotEmpty) {
notas[entrada.key] = entrada.value.text;
}
}
await ServicioNotas.guardarNotas(
_jugadorSeleccionadoId!,
notas,
_controladorNotaLibre.text,
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
// Inicializar controladores
for (final j in partida.jugadores) {
_controladores.putIfAbsent(j.id, () => TextEditingController());
}
return Scaffold(
appBar: AppBar(
title: const Text('📝 Notas'),
actions: [
if (_jugadorSeleccionadoId != null)
IconButton(
icon: const Icon(Icons.save),
onPressed: () async {
await _guardarNotas();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Notas guardadas')),
);
}
},
),
],
),
body: _jugadorSeleccionadoId == null
? _construirSelectorJugador(partida)
: _construirNotas(partida),
);
}
Widget _construirSelectorJugador(dynamic partida) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¿Quién eres?',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Selecciona tu nombre para ver tus notas privadas',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: partida.jugadoresActivos.length,
itemBuilder: (context, index) {
final j = partida.jugadoresActivos[index];
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: TemaApp.colorAcento,
child: Text('${index + 1}',
style: const TextStyle(color: Colors.white)),
),
title: Text(j.nombre),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
setState(() {
_jugadorSeleccionadoId = j.id;
_cargado = false;
});
_cargarNotas(j.id);
},
),
);
},
),
),
],
),
);
}
Widget _construirNotas(dynamic partida) {
if (!_cargado) {
return const Center(child: CircularProgressIndicator());
}
final jugadorActual = partida.jugadores
.firstWhere((j) => j.id == _jugadorSeleccionadoId);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
await _guardarNotas();
setState(() {
_jugadorSeleccionadoId = null;
_cargado = false;
for (final c in _controladores.values) {
c.clear();
}
_controladorNotaLibre.clear();
});
},
),
Text(
'Notas de ${jugadorActual.nombre}',
style: Theme.of(context).textTheme.titleLarge,
),
],
),
const SizedBox(height: 16),
// Notas por jugador
Text(
'Apuntes sobre cada jugador',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
const SizedBox(height: 8),
...partida.jugadoresActivos.where((j) => j.id != _jugadorSeleccionadoId).map<Widget>((j) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _controladores[j.id],
decoration: InputDecoration(
labelText: j.nombre,
prefixIcon: const Icon(Icons.person, size: 20),
hintText: '¿Qué ha dicho? ¿Sospechoso?',
),
maxLines: 2,
minLines: 1,
),
);
}),
const SizedBox(height: 16),
Text(
'Nota libre',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
const SizedBox(height: 8),
TextField(
controller: _controladorNotaLibre,
decoration: const InputDecoration(
hintText: 'Apuntes personales...',
prefixIcon: Icon(Icons.note, size: 20),
),
maxLines: 5,
minLines: 3,
),
],
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../tema/tema_app.dart';
import 'pantalla_crear_partida.dart';
import 'pantalla_reglas.dart';
import 'pantalla_unirse.dart';
class PantallaPrincipal extends StatelessWidget {
const PantallaPrincipal({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [TemaApp.colorAcento, TemaApp.colorNaranja],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4),
blurRadius: 30,
spreadRadius: 5,
),
],
),
child: const Center(
child: Text(
'🎭',
style: TextStyle(fontSize: 56),
),
),
),
const SizedBox(height: 24),
// Título
Text(
'El Impostor',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontSize: 36,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
'Juego de deducción social',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
),
),
const SizedBox(height: 48),
// Botones
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(),
),
);
},
icon: const Text('🎮', style: TextStyle(fontSize: 20)),
label: const Text('Crear partida'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaUnirse(),
),
);
},
icon: const Text('📱', style: TextStyle(fontSize: 20)),
label: const Text('Unirse a partida'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaReglas(),
),
);
},
icon: const Text('📖', style: TextStyle(fontSize: 20)),
label: const Text('Cómo jugar'),
),
),
const SizedBox(height: 48),
Text(
'3-20 jugadores • Sin internet',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 12,
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import '../tema/tema_app.dart';
class PantallaReglas extends StatelessWidget {
const PantallaReglas({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('📖 Cómo jugar')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_seccion(
context,
'🎭 ¿Qué es El Impostor?',
'Un juego de deducción social para 3-20 jugadores. '
'Todos reciben una palabra secreta... ¡excepto el impostor! '
'Tu misión: descubrir quién finge.',
),
_seccion(
context,
'🔍 ¿Cómo se juega?',
'1. Se reparten los roles: todos reciben la misma palabra, '
'excepto el/los impostores.\n\n'
'2. Debate: por turnos, cada jugador describe la palabra '
'SIN decirla directamente. El impostor debe fingir que la conoce.\n\n'
'3. Votación: al terminar el debate, todos votan a quién '
'creen que es el impostor.\n\n'
'4. Eliminación: el más votado queda eliminado y se revela '
'si era impostor o no.\n\n'
'5. Si era impostor, puede intentar adivinar la palabra. '
'Si acierta, ¡los impostores ganan!',
),
_seccion(
context,
'🏆 ¿Quién gana?',
'• Jugadores: ganan si eliminan a TODOS los impostores.\n'
'• Impostores: ganan si no son descubiertos hasta que '
'queden igual o menos jugadores normales que impostores, '
'o si adivinan la palabra al ser eliminados.',
),
_seccion(
context,
'💡 Consejos para jugadores',
'• Da pistas sutiles que demuestren que conoces la palabra, '
'pero no tan obvias que el impostor las use.\n'
'• Observa quién da respuestas vagas o genéricas.\n'
'• Usa las notas para apuntar lo que dice cada uno.\n'
'• No digas la palabra directamente, ¡eso ayuda al impostor!',
),
_seccion(
context,
'🎭 Consejos para el impostor',
'• Escucha atentamente las pistas de los demás.\n'
'• Intenta deducir la palabra para dar pistas creíbles.\n'
'• No seas el primero en hablar si no estás seguro.\n'
'• Si te dan la categoría como pista, úsala a tu favor.\n'
'• Acusa a otros para desviar la atención.',
),
_seccion(
context,
'📱 Modos de juego',
'• Un solo móvil: todos comparten el dispositivo. '
'Cada jugador ve su palabra pulsando y manteniendo un botón.\n\n'
'• Multimóvil: cada jugador usa su propio dispositivo. '
'Se conectan por Bluetooth/WiFi Direct sin necesidad de internet.',
),
_ejemplo(
context,
'✏️ Ejemplo de partida',
'Palabra secreta: "Pizza"\n\n'
'• Ana: "Se come caliente" ✓\n'
'• Carlos: "Viene en una caja" ✓\n'
'• Eva (impostor): "Es muy popular" 🤔\n'
'• David: "Tiene queso" ✓\n\n'
'Eva dio una respuesta muy genérica... ¡Sospechosa!',
),
const SizedBox(height: 32),
],
),
),
);
}
Widget _seccion(BuildContext context, String titulo, String contenido) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(contenido,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.5,
)),
],
),
),
),
);
}
Widget _ejemplo(BuildContext context, String titulo, String contenido) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
color: TemaApp.colorNaranja.withValues(alpha: 0.15),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: TemaApp.colorNaranja,
)),
const SizedBox(height: 8),
Text(contenido,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.5,
)),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/partida.dart';
import '../tema/tema_app.dart';
import 'pantalla_adivinanza.dart';
import 'pantalla_debate.dart';
import 'pantalla_fin_partida.dart';
class PantallaResultado extends StatefulWidget {
final ResultadoVotacion resultado;
const PantallaResultado({super.key, required this.resultado});
@override
State<PantallaResultado> createState() => _PantallaResultadoState();
}
class _PantallaResultadoState extends State<PantallaResultado>
with SingleTickerProviderStateMixin {
bool _revelado = false;
late AnimationController _animController;
late Animation<double> _animOpacidad;
@override
void initState() {
super.initState();
_animController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_animOpacidad = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _animController,
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
),
);
// Iniciar animación de suspense
Future.delayed(const Duration(milliseconds: 500), () {
_animController.forward().then((_) {
setState(() => _revelado = true);
});
});
}
@override
void dispose() {
_animController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final estado = context.read<EstadoJuego>();
final partida = estado.partida;
return Scaffold(
appBar: AppBar(
title: const Text('Resultado'),
automaticallyImplyLeading: false,
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animación de suspense
if (!_revelado) ...[
const Text('🥁', style: TextStyle(fontSize: 64)),
const SizedBox(height: 16),
Text(
'Revelando...',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
const CircularProgressIndicator(color: TemaApp.colorAcento),
],
if (_revelado) ...[
// Resultado revelado
FadeTransition(
opacity: _animOpacidad,
child: Column(
children: [
Text(
widget.resultado.eraImpostor ? '🎭' : '😇',
style: const TextStyle(fontSize: 72),
),
const SizedBox(height: 16),
Text(
widget.resultado.eliminadoNombre,
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(fontSize: 32),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: widget.resultado.eraImpostor
? TemaApp.colorVerde.withValues(alpha: 0.3)
: TemaApp.colorAcento.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: widget.resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
child: Text(
widget.resultado.eraImpostor
? '¡Era IMPOSTOR! 🎉'
: 'Era INOCENTE 😱',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
),
const SizedBox(height: 24),
// Detalle de votos
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Votos de esta ronda',
style: Theme.of(context)
.textTheme
.titleMedium),
const SizedBox(height: 8),
...widget.resultado.votos.entries.map((e) {
final votante = partida?.jugadores
.firstWhere((j) => j.id == e.key);
final votado = partida?.jugadores
.firstWhere((j) => j.id == e.value);
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 2),
child: Text(
'${votante?.nombre ?? '?'}${votado?.nombre ?? '?'}',
style: TextStyle(
color: e.value ==
widget.resultado.eliminadoId
? TemaApp.colorAcento
: TemaApp.colorTextoSecundario,
),
),
);
}),
],
),
),
),
const SizedBox(height: 24),
// Acciones
_construirBotones(context, estado),
],
),
),
],
],
),
),
),
);
}
Widget _construirBotones(BuildContext context, EstadoJuego estado) {
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
// Comprobar si la partida terminó
final finPartida = estado.comprobarFinPartida();
if (finPartida) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const PantallaFinPartida()),
);
},
icon: const Icon(Icons.emoji_events),
label: const Text('Ver resultado final'),
),
);
}
// Si era impostor, puede intentar adivinar
if (widget.resultado.eraImpostor) {
return Column(
children: [
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaAdivinanza(),
),
);
},
icon: const Text('🎯', style: TextStyle(fontSize: 18)),
label: const Text('¿El impostor adivina la palabra?'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _siguienteRonda(context, estado),
icon: const Icon(Icons.skip_next),
label: const Text('Siguiente ronda'),
),
),
],
);
}
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _siguienteRonda(context, estado),
icon: const Icon(Icons.skip_next),
label: const Text('Siguiente ronda'),
),
);
}
void _siguienteRonda(BuildContext context, EstadoJuego estado) {
estado.siguienteRonda();
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const PantallaDebate()),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import '../tema/tema_app.dart';
class PantallaUnirse extends StatelessWidget {
const PantallaUnirse({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Unirse a partida')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('📱', style: TextStyle(fontSize: 64)),
const SizedBox(height: 24),
Text(
'Modo multimóvil',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'Escanea el código QR que muestra el host '
'para conectarte a la partida vía Bluetooth/WiFi Direct.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: TemaApp.colorNaranja.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: TemaApp.colorNaranja.withValues(alpha: 0.5)),
),
child: Column(
children: [
const Text('🚧', style: TextStyle(fontSize: 32)),
const SizedBox(height: 8),
Text(
'Próximamente',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: TemaApp.colorNaranja,
),
),
const SizedBox(height: 8),
Text(
'La conexión multimóvil con Nearby Connections '
'requiere dispositivos Android físicos.\n\n'
'Por ahora, usa el modo "Un solo móvil" '
'para jugar en un dispositivo compartido.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back),
label: const Text('Volver'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../tema/tema_app.dart';
import 'pantalla_debate.dart';
class PantallaVerPalabra extends StatefulWidget {
const PantallaVerPalabra({super.key});
@override
State<PantallaVerPalabra> createState() => _PantallaVerPalabraState();
}
class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
final Set<String> _hanVisto = {};
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
final todosHanVisto =
partida.jugadores.every((j) => _hanVisto.contains(j.id));
return Scaffold(
appBar: AppBar(
title: const Text('Ver tu palabra'),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
'Cada jugador debe ver su palabra en secreto',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Ronda ${partida.rondaActual}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorNaranja,
),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: partida.jugadores.length,
itemBuilder: (context, index) {
final jugador = partida.jugadores[index];
final haVisto = _hanVisto.contains(jugador.id);
return Card(
color: haVisto
? TemaApp.colorVerde.withValues(alpha: 0.2)
: TemaApp.colorTarjeta,
child: ListTile(
leading: CircleAvatar(
backgroundColor: haVisto
? TemaApp.colorVerde
: TemaApp.colorAcento,
child: Text(
haVisto ? '' : '${index + 1}',
style:
const TextStyle(color: Colors.white),
),
),
title: Text(jugador.nombre),
subtitle: Text(
haVisto ? 'Ya ha visto su palabra' : 'Pulsa para ver',
),
trailing: haVisto
? const Icon(Icons.check_circle,
color: TemaApp.colorVerde)
: const Icon(Icons.visibility,
color: TemaApp.colorTextoSecundario),
onTap: haVisto
? null
: () => _mostrarPalabra(context, jugador.id),
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: todosHanVisto
? () {
estado.iniciarDebate();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const PantallaDebate(),
),
);
}
: null,
icon: const Icon(Icons.forum),
label: Text(todosHanVisto
? 'Todos han visto → Iniciar debate'
: 'Faltan ${partida.jugadores.length - _hanVisto.length} jugadores'),
),
),
],
),
),
);
}
void _mostrarPalabra(BuildContext context, String jugadorId) {
final estado = context.read<EstadoJuego>();
final partida = estado.partida!;
final jugador = partida.jugadores.firstWhere((j) => j.id == jugadorId);
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _PantallaRevelarPalabra(
nombre: jugador.nombre,
esImpostor: jugador.esImpostor,
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal,
onVisto: () {
setState(() => _hanVisto.add(jugadorId));
},
),
),
);
}
}
class _PantallaRevelarPalabra extends StatefulWidget {
final String nombre;
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String categoria;
final VoidCallback onVisto;
const _PantallaRevelarPalabra({
required this.nombre,
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.categoria,
required this.onVisto,
});
@override
State<_PantallaRevelarPalabra> createState() =>
_PantallaRevelarPalabraState();
}
class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
bool _manteniendo = false;
bool _visto = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.nombre,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
// Zona de revelación
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorVerde.withValues(alpha: 0.3))
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde)
: Colors.transparent,
width: 2,
),
),
child: _manteniendo
? Column(
children: [
Text(
widget.esImpostor ? '🎭' : '🔍',
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
Text(
widget.esImpostor
? '¡Eres el impostor!'
: 'Tu palabra es:',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
color: widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde,
),
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
Text(
widget.palabra,
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(
fontSize: 32,
color: Colors.white,
),
textAlign: TextAlign.center,
),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
'Pista: ${BancoPalabras.nombreBonitoCategoria(widget.categoria)}',
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: TemaApp.colorNaranja,
),
),
],
],
)
: Column(
children: [
const Text('🔒', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
'Mantén pulsado para ver tu palabra',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Asegúrate de que nadie más mira',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 24),
// Botón mantener pulsado
GestureDetector(
onLongPressStart: (_) {
setState(() {
_manteniendo = true;
_visto = true;
});
},
onLongPressEnd: (_) {
setState(() => _manteniendo = false);
},
child: Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _manteniendo
? [TemaApp.colorNaranja, TemaApp.colorAcento]
: [TemaApp.colorAcento, TemaApp.colorAcento],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
_manteniendo
? '👁️ Mostrando...'
: '👆 Mantén pulsado para ver',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 24),
if (_visto)
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
widget.onVisto();
Navigator.pop(context);
},
icon: const Icon(Icons.check),
label: const Text('He visto mi palabra'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/tema_app.dart';
import 'pantalla_resultado.dart';
class PantallaVotacion extends StatefulWidget {
const PantallaVotacion({super.key});
@override
State<PantallaVotacion> createState() => _PantallaVotacionState();
}
class _PantallaVotacionState extends State<PantallaVotacion> {
String? _seleccionado;
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida;
if (partida == null) return const SizedBox.shrink();
final activos = partida.jugadoresActivos;
final todosVotaron = estado.todosHanVotado();
// Modo un solo móvil
if (!partida.config.modoMultimovil) {
return _construirVotacionUnMovil(context, estado, partida, activos, todosVotaron);
}
// Modo multimóvil sería similar pero controlado por Nearby
return _construirVotacionUnMovil(context, estado, partida, activos, todosVotaron);
}
Widget _construirVotacionUnMovil(
BuildContext context,
EstadoJuego estado,
partida,
List activos,
bool todosVotaron,
) {
// Encontrar el siguiente votante que no haya votado
final sinVotar = activos
.where((j) => !estado.votos.containsKey(j.id))
.toList();
if (todosVotaron) {
return _construirTodosVotaron(context, estado);
}
final votanteActual = sinVotar.isNotEmpty ? sinVotar[0] : activos[0];
final puedenRecibir = activos.where((j) => j.id != votanteActual.id).toList();
return Scaffold(
appBar: AppBar(
title: const Text('🗳️ Votación'),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Progreso de votos
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Turno de votar:',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
votanteActual.nombre,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: TemaApp.colorNaranja,
),
),
const SizedBox(height: 8),
Text(
'Votos: ${estado.votos.length}/${activos.length}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 4),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: estado.votos.length / activos.length,
backgroundColor: TemaApp.colorSuperficie,
valueColor: const AlwaysStoppedAnimation(TemaApp.colorAcento),
minHeight: 6,
),
),
],
),
),
const SizedBox(height: 16),
Text(
'¿Quién crees que es el impostor?',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// Lista de candidatos
Expanded(
child: ListView.builder(
itemCount: puedenRecibir.length,
itemBuilder: (context, index) {
final candidato = puedenRecibir[index];
final seleccionado = _seleccionado == candidato.id;
return Card(
color: seleccionado
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
child: ListTile(
leading: CircleAvatar(
backgroundColor: seleccionado
? TemaApp.colorAcento
: TemaApp.colorSuperficie,
child: Text('${index + 1}',
style: const TextStyle(color: Colors.white)),
),
title: Text(candidato.nombre),
trailing: seleccionado
? const Icon(Icons.check_circle,
color: TemaApp.colorAcento)
: const Icon(Icons.radio_button_unchecked),
onTap: () {
setState(() => _seleccionado = candidato.id);
},
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _seleccionado != null
? () {
estado.registrarVoto(
votanteActual.id, _seleccionado!);
setState(() {
_seleccionado = null;
});
}
: null,
icon: const Icon(Icons.how_to_vote),
label: const Text('Confirmar voto'),
),
),
],
),
),
);
}
Widget _construirTodosVotaron(BuildContext context, EstadoJuego estado) {
return Scaffold(
appBar: AppBar(
title: const Text('🗳️ Votación completa'),
automaticallyImplyLeading: false,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🗳️', style: TextStyle(fontSize: 64)),
const SizedBox(height: 24),
Text(
'¡Todos han votado!',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
'Pulsa para revelar el resultado',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
final resultado = estado.procesarVotacion();
if (resultado != null) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) =>
PantallaResultado(resultado: resultado),
),
);
}
},
icon: const Icon(Icons.visibility),
label: const Text('Revelar resultado'),
),
),
],
),
),
),
);
}
}