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,221 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/palabra.dart';
import '../servicios/servicio_notas.dart';
/// Estado global del juego gestionado con Provider
class EstadoJuego extends ChangeNotifier {
BancoPalabras? _banco;
Partida? _partida;
final Map<String, String> _votos = {}; // votanteId -> votadoId
bool _cargando = false;
BancoPalabras? get banco => _banco;
Partida? get partida => _partida;
Map<String, String> get votos => Map.unmodifiable(_votos);
bool get cargando => _cargando;
Future<void> cargarBanco() async {
_cargando = true;
notifyListeners();
_banco = await BancoPalabras.cargar();
_cargando = false;
notifyListeners();
}
/// Crea una nueva partida con la configuración dada y lista de jugadores
void crearPartida({
required ConfigPartida config,
required List<String> nombresJugadores,
}) {
if (_banco == null) return;
if (nombresJugadores.length < 3) return;
final rng = Random();
// Seleccionar palabra
final palabra = _banco!.palabraAleatoria(config.categoria);
final categoriaReal =
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
// Crear jugadores
final jugadores = nombresJugadores.asMap().entries.map((e) {
return Jugador(
id: 'j${e.key}',
nombre: e.value,
);
}).toList();
// Asignar impostores aleatoriamente
final indices = List.generate(jugadores.length, (i) => i);
indices.shuffle(rng);
final numImpostores =
config.numImpostores.clamp(1, jugadores.length ~/ 3);
for (int i = 0; i < numImpostores; i++) {
jugadores[indices[i]].esImpostor = true;
}
// Asignar palabras
for (final j in jugadores) {
if (!j.esImpostor) {
j.palabra = palabra;
}
}
_partida = Partida(
config: config,
jugadores: jugadores,
palabraSecreta: palabra,
categoriaReal: categoriaReal,
);
_votos.clear();
ServicioNotas.limpiarNotas();
notifyListeners();
}
/// Avanza a la fase de debate
void iniciarDebate() {
if (_partida == null) return;
_partida!.fase = FaseJuego.debate;
notifyListeners();
}
/// Avanza a la fase de votación
void iniciarVotacion() {
if (_partida == null) return;
_partida!.fase = FaseJuego.votacion;
_votos.clear();
notifyListeners();
}
/// Registra un voto (modo un solo móvil)
void registrarVoto(String votanteId, String votadoId) {
_votos[votanteId] = votadoId;
notifyListeners();
}
/// Elimina un voto
void eliminarVoto(String votanteId) {
_votos.remove(votanteId);
notifyListeners();
}
/// Comprueba si todos los jugadores activos (no eliminados) han votado
bool todosHanVotado() {
if (_partida == null) return false;
final activos = _partida!.jugadoresActivos;
return activos.every((j) => _votos.containsKey(j.id));
}
/// Procesa los votos y determina el eliminado
ResultadoVotacion? procesarVotacion() {
if (_partida == null) return null;
// Contar votos
final conteo = <String, int>{};
for (final votado in _votos.values) {
conteo[votado] = (conteo[votado] ?? 0) + 1;
}
if (conteo.isEmpty) return null;
// Encontrar máximo
final maxVotos = conteo.values.reduce(max);
final masVotados =
conteo.entries.where((e) => e.value == maxVotos).toList();
// En caso de empate, elegir aleatoriamente
final rng = Random();
final eliminadoId =
masVotados[rng.nextInt(masVotados.length)].key;
final eliminado = _partida!.jugadores.firstWhere((j) => j.id == eliminadoId);
eliminado.eliminado = true;
final resultado = ResultadoVotacion(
eliminadoId: eliminadoId,
eliminadoNombre: eliminado.nombre,
eraImpostor: eliminado.esImpostor,
votos: Map.from(_votos),
);
_partida!.historialVotaciones.add(resultado);
_partida!.fase = FaseJuego.resultado;
notifyListeners();
return resultado;
}
/// Comprueba si la partida ha terminado y actualiza el estado
bool comprobarFinPartida() {
if (_partida == null) return false;
final impostoresVivos = _partida!.impostoresActivos.length;
final jugadoresVivos = _partida!.jugadoresNormalesActivos.length;
// Los jugadores ganan si no quedan impostores
if (impostoresVivos == 0) {
_partida!.ganador = 'jugadores';
_partida!.fase = FaseJuego.finPartida;
notifyListeners();
return true;
}
// Los impostores ganan si son >= que los jugadores normales
if (impostoresVivos >= jugadoresVivos) {
_partida!.ganador = 'impostores';
_partida!.fase = FaseJuego.finPartida;
notifyListeners();
return true;
}
return false;
}
/// Avanza a la fase de adivinanza del impostor
void iniciarAdivinanza() {
if (_partida == null) return;
_partida!.fase = FaseJuego.adivinanza;
notifyListeners();
}
/// El impostor intenta adivinar la palabra
bool intentarAdivinar(String intento) {
if (_partida == null) return false;
final acierto =
intento.trim().toLowerCase() ==
_partida!.palabraSecreta.trim().toLowerCase();
if (acierto) {
_partida!.ganador = 'impostores';
_partida!.fase = FaseJuego.finPartida;
notifyListeners();
}
return acierto;
}
/// Inicia la siguiente ronda
void siguienteRonda() {
if (_partida == null) return;
_partida!.rondaActual++;
_partida!.fase = FaseJuego.debate;
_votos.clear();
notifyListeners();
}
/// Revancha: mismos jugadores, nueva palabra
void revancha() {
if (_partida == null || _banco == null) return;
final nombres = _partida!.jugadores.map((j) => j.nombre).toList();
crearPartida(config: _partida!.config, nombresJugadores: nombres);
}
/// Limpia la partida actual
void limpiar() {
_partida = null;
_votos.clear();
notifyListeners();
}
}

72
lib/main.dart Normal file
View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'estado/estado_juego.dart';
import 'tema/tema_app.dart';
import 'pantallas/pantalla_principal.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: TemaApp.colorFondo,
),
);
runApp(const ElImpostorApp());
}
class ElImpostorApp extends StatelessWidget {
const ElImpostorApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => EstadoJuego()..cargarBanco(),
child: MaterialApp(
title: 'El Impostor',
theme: TemaApp.obtenerTema(),
debugShowCheckedModeBanner: false,
home: const PantallaCarga(),
),
);
}
}
class PantallaCarga extends StatelessWidget {
const PantallaCarga({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoJuego>();
if (estado.cargando || estado.banco == null) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🎭', style: TextStyle(fontSize: 72)),
const SizedBox(height: 24),
Text(
'El Impostor',
style: Theme.of(context).textTheme.headlineLarge,
),
const SizedBox(height: 16),
const CircularProgressIndicator(color: TemaApp.colorAcento),
const SizedBox(height: 12),
Text(
'Cargando palabras...',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
);
}
return const PantallaPrincipal();
}
}

51
lib/modelos/jugador.dart Normal file
View File

@@ -0,0 +1,51 @@
/// Representa un jugador en la partida
class Jugador {
final String id;
final String nombre;
bool esImpostor;
bool eliminado;
String? palabra;
String? endpointId; // Para modo multimóvil
Map<String, String> notas; // nombre_jugador -> nota
Jugador({
required this.id,
required this.nombre,
this.esImpostor = false,
this.eliminado = false,
this.palabra,
this.endpointId,
Map<String, String>? notas,
}) : notas = notas ?? {};
Jugador copiar({
bool? esImpostor,
bool? eliminado,
String? palabra,
String? endpointId,
}) {
return Jugador(
id: id,
nombre: nombre,
esImpostor: esImpostor ?? this.esImpostor,
eliminado: eliminado ?? this.eliminado,
palabra: palabra ?? this.palabra,
endpointId: endpointId ?? this.endpointId,
notas: Map.from(notas),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
'esImpostor': esImpostor,
'eliminado': eliminado,
};
factory Jugador.fromJson(Map<String, dynamic> json) => Jugador(
id: json['id'] as String,
nombre: json['nombre'] as String,
esImpostor: json['esImpostor'] as bool? ?? false,
eliminado: json['eliminado'] as bool? ?? false,
);
}

63
lib/modelos/palabra.dart Normal file
View File

@@ -0,0 +1,63 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/services.dart';
/// Categorías disponibles en el banco de palabras
class BancoPalabras {
final Map<String, List<String>> categorias;
BancoPalabras(this.categorias);
static BancoPalabras? _instancia;
static Future<BancoPalabras> cargar() async {
if (_instancia != null) return _instancia!;
final jsonStr = await rootBundle.loadString('assets/palabras.json');
final data = json.decode(jsonStr) as Map<String, dynamic>;
final cats = data['categorias'] as Map<String, dynamic>;
final mapa = <String, List<String>>{};
for (final entrada in cats.entries) {
mapa[entrada.key] = List<String>.from(entrada.value);
}
_instancia = BancoPalabras(mapa);
return _instancia!;
}
List<String> get nombresCategorias => categorias.keys.toList();
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null)
String palabraAleatoria(String? categoria) {
final rng = Random();
if (categoria == null || categoria == 'todas') {
final todasPalabras = categorias.values.expand((l) => l).toList();
return todasPalabras[rng.nextInt(todasPalabras.length)];
}
final lista = categorias[categoria]!;
return lista[rng.nextInt(lista.length)];
}
/// Devuelve la categoría a la que pertenece una palabra
String? categoriaDepalabra(String palabra) {
for (final entrada in categorias.entries) {
if (entrada.value.contains(palabra)) return entrada.key;
}
return null;
}
static String nombreBonitoCategoria(String clave) {
const nombres = {
'todas': 'Todas',
'animales': 'Animales',
'comida': 'Comida',
'paises': 'Países',
'deportes': 'Deportes',
'profesiones': 'Profesiones',
'objetos': 'Objetos',
'lugares': 'Lugares',
'peliculas': 'Películas',
'musica': 'Música',
'tecnologia': 'Tecnología',
};
return nombres[clave] ?? clave;
}
}

79
lib/modelos/partida.dart Normal file
View File

@@ -0,0 +1,79 @@
import 'jugador.dart';
/// Configuración de una partida
class ConfigPartida {
final bool modoMultimovil;
final String categoria; // 'todas' o nombre de categoría
final int numImpostores;
final bool pistaImpostor;
final int? tiempoDebateSegundos; // null = sin límite
const ConfigPartida({
this.modoMultimovil = false,
this.categoria = 'todas',
this.numImpostores = 1,
this.pistaImpostor = false,
this.tiempoDebateSegundos,
});
}
/// Fases del juego
enum FaseJuego {
configuracion,
verPalabra,
debate,
votacion,
resultado,
adivinanza, // El impostor intenta adivinar la palabra
finPartida,
}
/// Resultado de una ronda de votación
class ResultadoVotacion {
final String eliminadoId;
final String eliminadoNombre;
final bool eraImpostor;
final Map<String, String> votos; // votante -> votado
const ResultadoVotacion({
required this.eliminadoId,
required this.eliminadoNombre,
required this.eraImpostor,
required this.votos,
});
}
/// Estado completo de una partida
class Partida {
final ConfigPartida config;
final List<Jugador> jugadores;
final String palabraSecreta;
final String categoriaReal;
FaseJuego fase;
int rondaActual;
final List<ResultadoVotacion> historialVotaciones;
String? ganador; // 'jugadores' | 'impostores' | null
Partida({
required this.config,
required this.jugadores,
required this.palabraSecreta,
required this.categoriaReal,
this.fase = FaseJuego.verPalabra,
this.rondaActual = 1,
List<ResultadoVotacion>? historialVotaciones,
this.ganador,
}) : historialVotaciones = historialVotaciones ?? [];
List<Jugador> get jugadoresActivos =>
jugadores.where((j) => !j.eliminado).toList();
List<Jugador> get impostoresActivos =>
jugadoresActivos.where((j) => j.esImpostor).toList();
List<Jugador> get jugadoresNormalesActivos =>
jugadoresActivos.where((j) => !j.esImpostor).toList();
int get impostoresTotales =>
jugadores.where((j) => j.esImpostor).length;
}

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'),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
/// Tipos de mensajes en el protocolo P2P
enum TipoMensaje {
salaInfo,
partidaInicio,
fase,
votacionResultado,
partidaFin,
unirse,
voto,
listo,
}
/// Mensaje del protocolo P2P entre dispositivos
class MensajeP2P {
final TipoMensaje tipo;
final Map<String, dynamic> datos;
MensajeP2P({required this.tipo, required this.datos});
String toJson() => json.encode({
'tipo': tipo.name,
'datos': datos,
});
factory MensajeP2P.fromJson(String jsonStr) {
final mapa = json.decode(jsonStr) as Map<String, dynamic>;
return MensajeP2P(
tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']),
datos: mapa['datos'] as Map<String, dynamic>,
);
}
}
/// Servicio para conexiones P2P usando Google Nearby Connections API.
///
/// Este servicio encapsula toda la lógica de Nearby Connections.
/// Requiere dispositivos Android físicos para funcionar.
/// En la versión actual, se provee la estructura para integración futura.
class ServicioNearby extends ChangeNotifier {
bool _esHost = false;
bool _conectado = false;
String? _endpointId;
final List<String> _dispositivos = [];
bool get esHost => _esHost;
bool get conectado => _conectado;
String? get endpointId => _endpointId;
List<String> get dispositivos => List.unmodifiable(_dispositivos);
/// Inicia como host (anunciando el endpoint)
Future<bool> iniciarHost(String nombreSala) async {
// Nota: nearby_connections requiere permisos de ubicación y Bluetooth
// que deben solicitarse antes de iniciar.
// Implementación con el paquete nearby_connections:
//
// try {
// await Nearby().startAdvertising(
// nombreSala,
// Strategy.P2P_STAR,
// onConnectionInitiated: _onConexionIniciada,
// onConnectionResult: _onResultadoConexion,
// onDisconnected: _onDesconexion,
// serviceId: 'es.freetimelab.elimpostor',
// );
// _esHost = true;
// _endpointId = nombreSala;
// notifyListeners();
// return true;
// } catch (e) {
// debugPrint('Error iniciando host: $e');
// return false;
// }
_esHost = true;
_endpointId = nombreSala;
notifyListeners();
return true;
}
/// Conecta a un host escaneado via QR
Future<bool> conectarAHost(String endpointId, String nombre) async {
// Implementación con el paquete nearby_connections:
//
// try {
// await Nearby().startDiscovery(
// nombre,
// Strategy.P2P_STAR,
// onEndpointFound: (id, name, serviceId) {
// Nearby().requestConnection(nombre, id,
// onConnectionInitiated: _onConexionIniciada,
// onConnectionResult: _onResultadoConexion,
// onDisconnected: _onDesconexion,
// );
// },
// onEndpointLost: (id) {},
// serviceId: 'es.freetimelab.elimpostor',
// );
// return true;
// } catch (e) {
// debugPrint('Error conectando: $e');
// return false;
// }
_conectado = true;
notifyListeners();
return true;
}
/// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
// Implementación:
// final bytes = Uint8List.fromList(utf8.encode(mensaje.toJson()));
// await Nearby().sendBytesPayload(endpointId, bytes);
debugPrint('Enviar a $endpointId: ${mensaje.toJson()}');
}
/// Envía un mensaje a todos los dispositivos conectados
Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _dispositivos) {
await enviarMensaje(id, mensaje);
}
}
/// Desconecta y limpia
Future<void> desconectar() async {
// await Nearby().stopAllEndpoints();
// await Nearby().stopAdvertising();
// await Nearby().stopDiscovery();
_esHost = false;
_conectado = false;
_endpointId = null;
_dispositivos.clear();
notifyListeners();
}
/// Genera los datos para el código QR de conexión
String generarDatosQR(String nombreSala) {
return json.encode({
'app': 'elimpostor',
'endpoint': _endpointId,
'sala': nombreSala,
});
}
}

View File

@@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Servicio para persistir las notas de los jugadores localmente
class ServicioNotas {
static const _clavePrefix = 'notas_';
/// Guarda las notas de un jugador para una partida
static Future<void> guardarNotas(
String jugadorId,
Map<String, String> notas,
String notaLibre,
) async {
final prefs = await SharedPreferences.getInstance();
final datos = {
'notas': notas,
'notaLibre': notaLibre,
};
await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos));
}
/// Carga las notas de un jugador
static Future<Map<String, dynamic>> cargarNotas(String jugadorId) async {
final prefs = await SharedPreferences.getInstance();
final str = prefs.getString('$_clavePrefix$jugadorId');
if (str == null) {
return {'notas': <String, String>{}, 'notaLibre': ''};
}
final datos = json.decode(str) as Map<String, dynamic>;
return {
'notas': Map<String, String>.from(datos['notas'] ?? {}),
'notaLibre': datos['notaLibre'] ?? '',
};
}
/// Limpia todas las notas (al iniciar nueva partida)
static Future<void> limpiarNotas() async {
final prefs = await SharedPreferences.getInstance();
final claves = prefs.getKeys().where((k) => k.startsWith(_clavePrefix));
for (final clave in claves) {
await prefs.remove(clave);
}
}
}

115
lib/tema/tema_app.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class TemaApp {
static const colorFondo = Color(0xFF121212);
static const colorSuperficie = Color(0xFF1E1E1E);
static const colorTarjeta = Color(0xFF2A2A2A);
static const colorAcento = Color(0xFFE53935); // Rojo impostor
static const colorAcentoClaro = Color(0xFFFF6F61);
static const colorNaranja = Color(0xFFFF9800);
static const colorVerde = Color(0xFF4CAF50);
static const colorTexto = Color(0xFFFFFFFF);
static const colorTextoSecundario = Color(0xFFB0B0B0);
static ThemeData obtenerTema() {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: colorFondo,
colorScheme: const ColorScheme.dark(
primary: colorAcento,
secondary: colorNaranja,
surface: colorSuperficie,
error: colorAcento,
),
textTheme: GoogleFonts.poppinsTextTheme(
const TextTheme(
headlineLarge: TextStyle(
color: colorTexto,
fontWeight: FontWeight.bold,
fontSize: 28,
),
headlineMedium: TextStyle(
color: colorTexto,
fontWeight: FontWeight.bold,
fontSize: 22,
),
titleLarge: TextStyle(
color: colorTexto,
fontWeight: FontWeight.w600,
fontSize: 18,
),
titleMedium: TextStyle(
color: colorTexto,
fontWeight: FontWeight.w500,
fontSize: 16,
),
bodyLarge: TextStyle(color: colorTexto, fontSize: 16),
bodyMedium: TextStyle(color: colorTextoSecundario, fontSize: 14),
),
),
cardTheme: CardThemeData(
color: colorTarjeta,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorAcento,
foregroundColor: colorTexto,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorTexto,
side: const BorderSide(color: colorAcento),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorTarjeta,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: colorAcento),
),
labelStyle: const TextStyle(color: colorTextoSecundario),
hintStyle: const TextStyle(color: colorTextoSecundario),
),
appBarTheme: AppBarTheme(
backgroundColor: colorFondo,
foregroundColor: colorTexto,
elevation: 0,
titleTextStyle: GoogleFonts.poppins(
color: colorTexto,
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colorAcento;
return colorTextoSecundario;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorAcento.withValues(alpha: 0.5);
}
return colorTarjeta;
}),
),
);
}
}