diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..16b994b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,50 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+build/
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..56546de
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "48c32af0345e9ad5747f78ddce828c7f795f7159"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+ base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+ - platform: android
+ create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+ base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..a502cb2
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,38 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "es.freetimelab.elimpostor"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_17.toString()
+ }
+
+ defaultConfig {
+ applicationId = "es.freetimelab.elimpostor"
+ minSdk = flutter.minSdkVersion
+ targetSdk = 34
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7eea1e4
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt
new file mode 100644
index 0000000..9bf1cae
--- /dev/null
+++ b/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt
@@ -0,0 +1,5 @@
+package es.freetimelab.el_impostor
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..fbee1d8
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,2 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e4ef43f
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..ca7fe06
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.11.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.20" apply false
+}
+
+include(":app")
diff --git a/assets/palabras.json b/assets/palabras.json
new file mode 100644
index 0000000..739599c
--- /dev/null
+++ b/assets/palabras.json
@@ -0,0 +1,124 @@
+{
+ "categorias": {
+ "animales": [
+ "Perro", "Gato", "Caballo", "Vaca", "Cerdo", "Oveja", "Cabra", "Gallina", "Pato", "Conejo",
+ "León", "Tigre", "Elefante", "Jirafa", "Cebra", "Hipopótamo", "Rinoceronte", "Gorila", "Chimpancé", "Orangután",
+ "Águila", "Halcón", "Búho", "Colibrí", "Pelícano", "Flamenco", "Pingüino", "Loro", "Canario", "Cuervo",
+ "Tiburón", "Delfín", "Ballena", "Pulpo", "Medusa", "Tortuga", "Cocodrilo", "Serpiente", "Iguana", "Camaleón",
+ "Abeja", "Mariposa", "Hormiga", "Araña", "Escorpión", "Libélula", "Escarabajo", "Mariquita", "Luciérnaga", "Grillo",
+ "Lobo", "Zorro", "Oso", "Ciervo", "Jabalí", "Ardilla", "Castor", "Nutria", "Mapache", "Tejón",
+ "Camello", "Llama", "Alpaca", "Bisonte", "Búfalo", "Alce", "Reno", "Antílope", "Gacela", "Ñu",
+ "Foca", "Morsa", "Narval", "Orca", "Mantarraya", "Pez espada", "Salmón", "Trucha", "Atún", "Sardina",
+ "Koala", "Canguro", "Ornitorrinco", "Wombat", "Panda", "Lémur", "Suricata", "Hiena", "Leopardo", "Pantera",
+ "Tucán", "Pavo real", "Avestruz", "Cisne", "Gaviota", "Albatros", "Buitre", "Cigüeña", "Garza", "Grulla"
+ ],
+ "comida": [
+ "Pizza", "Hamburguesa", "Paella", "Tortilla", "Croqueta", "Patatas fritas", "Sushi", "Ramen", "Tacos", "Burrito",
+ "Pasta", "Risotto", "Lasaña", "Raviolis", "Ñoquis", "Canelones", "Macarrones", "Espaguetis", "Fideos", "Tallarines",
+ "Ensalada", "Gazpacho", "Sopa", "Crema", "Guiso", "Estofado", "Cocido", "Puchero", "Potaje", "Lentejas",
+ "Pan", "Croissant", "Baguette", "Churros", "Donut", "Magdalena", "Galleta", "Bizcocho", "Tarta", "Flan",
+ "Helado", "Natillas", "Arroz con leche", "Turrón", "Chocolate", "Caramelo", "Chicle", "Piruleta", "Gominola", "Regaliz",
+ "Manzana", "Plátano", "Naranja", "Fresa", "Uva", "Sandía", "Melón", "Piña", "Mango", "Kiwi",
+ "Tomate", "Cebolla", "Ajo", "Pimiento", "Zanahoria", "Calabaza", "Berenjena", "Calabacín", "Pepino", "Lechuga",
+ "Pollo asado", "Filete", "Costillas", "Albóndigas", "Salchicha", "Jamón", "Chorizo", "Bacon", "Pechuga", "Muslo",
+ "Queso", "Yogur", "Mantequilla", "Nata", "Leche", "Huevo", "Aceite", "Vinagre", "Sal", "Pimienta",
+ "Café", "Té", "Zumo", "Batido", "Limonada", "Cerveza", "Vino", "Sangría", "Cóctel", "Refresco"
+ ],
+ "paises": [
+ "España", "Francia", "Italia", "Alemania", "Portugal", "Reino Unido", "Irlanda", "Holanda", "Bélgica", "Suiza",
+ "Austria", "Grecia", "Turquía", "Rusia", "Polonia", "Suecia", "Noruega", "Dinamarca", "Finlandia", "Islandia",
+ "Estados Unidos", "Canadá", "México", "Brasil", "Argentina", "Chile", "Colombia", "Perú", "Venezuela", "Ecuador",
+ "Japón", "China", "India", "Corea del Sur", "Tailandia", "Vietnam", "Indonesia", "Filipinas", "Malasia", "Singapur",
+ "Egipto", "Marruecos", "Sudáfrica", "Nigeria", "Kenia", "Etiopía", "Tanzania", "Ghana", "Senegal", "Túnez",
+ "Australia", "Nueva Zelanda", "Cuba", "Jamaica", "Costa Rica", "Panamá", "Uruguay", "Paraguay", "Bolivia", "Honduras",
+ "Croacia", "República Checa", "Hungría", "Rumanía", "Bulgaria", "Serbia", "Ucrania", "Lituania", "Letonia", "Estonia",
+ "Israel", "Líbano", "Jordania", "Arabia Saudí", "Emiratos Árabes", "Qatar", "Irán", "Irak", "Pakistán", "Bangladesh",
+ "Nepal", "Sri Lanka", "Mongolia", "Camboya", "Myanmar", "Taiwán", "Macao", "Hong Kong", "Laos", "Brunéi",
+ "Islas Maldivas", "Madagascar", "Mozambique", "Angola", "Camerún", "Costa de Marfil", "Mali", "Níger", "Congo", "Uganda"
+ ],
+ "deportes": [
+ "Fútbol", "Baloncesto", "Tenis", "Natación", "Atletismo", "Ciclismo", "Boxeo", "Judo", "Karate", "Taekwondo",
+ "Golf", "Rugby", "Cricket", "Béisbol", "Hockey", "Voleibol", "Balonmano", "Waterpolo", "Esgrima", "Tiro con arco",
+ "Esquí", "Snowboard", "Patinaje", "Surf", "Windsurf", "Vela", "Remo", "Piragüismo", "Buceo", "Escalada",
+ "Gimnasia", "Halterofilia", "Lucha", "Sumo", "MMA", "Kickboxing", "Muay thai", "Capoeira", "Wrestling", "Sambo",
+ "Pádel", "Squash", "Bádminton", "Ping pong", "Billar", "Dardos", "Bolos", "Petanca", "Croquet", "Polo",
+ "Fórmula 1", "Motociclismo", "Rally", "Karting", "NASCAR", "Motocross", "Trial", "Enduro", "Superbike", "Dragster",
+ "Skateboard", "BMX", "Parkour", "Breakdance", "Cheerleading", "Acrobacia", "Trapecio", "Cuerda floja", "Slackline", "Bungee",
+ "Ajedrez", "Damas", "Backgammon", "Go", "Dominó", "Póker", "Bridge", "Mahjong", "Scrabble", "Risk",
+ "Triatlón", "Decatlón", "Maratón", "Ultratrail", "Ironman", "Duatlón", "Cross", "Orientación", "Geocaching", "Senderismo",
+ "Pelota vasca", "Jai alai", "Hurling", "Lacrosse", "Kabaddi", "Sepak takraw", "Bossaball", "Quidditch", "Ultimate frisbee", "Spikeball"
+ ],
+ "profesiones": [
+ "Médico", "Enfermera", "Dentista", "Veterinario", "Farmacéutico", "Cirujano", "Psicólogo", "Fisioterapeuta", "Optometrista", "Nutricionista",
+ "Abogado", "Juez", "Notario", "Fiscal", "Detective", "Policía", "Bombero", "Soldado", "Espía", "Guardaespaldas",
+ "Profesor", "Maestro", "Catedrático", "Tutor", "Pedagogo", "Logopeda", "Director de colegio", "Investigador", "Científico", "Arqueólogo",
+ "Arquitecto", "Ingeniero", "Electricista", "Fontanero", "Carpintero", "Albañil", "Pintor", "Soldador", "Mecánico", "Cerrajero",
+ "Chef", "Camarero", "Barista", "Sommelier", "Pastelero", "Panadero", "Carnicero", "Pescadero", "Frutero", "Heladero",
+ "Actor", "Director de cine", "Guionista", "Cámara", "Productor", "Cantante", "Músico", "DJ", "Bailarín", "Mago",
+ "Periodista", "Escritor", "Editor", "Fotógrafo", "Diseñador", "Ilustrador", "Animador", "Traductor", "Locutor", "Presentador",
+ "Piloto", "Astronauta", "Marinero", "Capitán", "Camionero", "Taxista", "Conductor de bus", "Maquinista", "Cartero", "Mensajero",
+ "Programador", "Hacker", "Administrador de sistemas", "Community manager", "Data scientist", "Youtuber", "Streamer", "Gamer", "Tester", "Scrum master",
+ "Agricultor", "Ganadero", "Jardinero", "Apicultor", "Leñador", "Minero", "Geólogo", "Biólogo", "Botánico", "Zoólogo"
+ ],
+ "objetos": [
+ "Teléfono", "Ordenador", "Televisión", "Radio", "Reloj", "Cámara", "Auriculares", "Altavoz", "Micrófono", "Mando",
+ "Silla", "Mesa", "Sofá", "Cama", "Armario", "Estantería", "Lámpara", "Espejo", "Alfombra", "Cortina",
+ "Cuchillo", "Tenedor", "Cuchara", "Plato", "Vaso", "Taza", "Sartén", "Olla", "Batidora", "Microondas",
+ "Llave", "Candado", "Puerta", "Ventana", "Escalera", "Ascensor", "Columpio", "Tobogán", "Trampolín", "Hamaca",
+ "Libro", "Cuaderno", "Bolígrafo", "Lápiz", "Goma", "Regla", "Tijeras", "Pegamento", "Pincel", "Rotulador",
+ "Paraguas", "Abanico", "Gafas", "Guantes", "Bufanda", "Gorro", "Cinturón", "Corbata", "Anillo", "Collar",
+ "Balón", "Raqueta", "Bicicleta", "Patinete", "Monopatín", "Comba", "Peonza", "Dado", "Puzzle", "Muñeca",
+ "Martillo", "Destornillador", "Alicate", "Sierra", "Taladro", "Nivel", "Metro", "Llave inglesa", "Tornillo", "Clavo",
+ "Maleta", "Mochila", "Bolso", "Cartera", "Monedero", "Sobre", "Paquete", "Caja", "Cubo", "Botella",
+ "Vela", "Cerilla", "Mechero", "Linterna", "Brújula", "Mapa", "Globo terráqueo", "Telescopio", "Microscopio", "Lupa"
+ ],
+ "lugares": [
+ "Playa", "Montaña", "Bosque", "Desierto", "Selva", "Pradera", "Volcán", "Glaciar", "Cascada", "Cueva",
+ "Hospital", "Colegio", "Universidad", "Biblioteca", "Museo", "Teatro", "Cine", "Discoteca", "Restaurante", "Cafetería",
+ "Supermercado", "Farmacia", "Peluquería", "Banco", "Correos", "Gasolinera", "Taller", "Lavandería", "Gimnasio", "Piscina",
+ "Aeropuerto", "Estación de tren", "Puerto", "Autopista", "Puente", "Túnel", "Rotonda", "Parking", "Peaje", "Parada de bus",
+ "Iglesia", "Mezquita", "Sinagoga", "Templo", "Catedral", "Monasterio", "Cementerio", "Ermita", "Santuario", "Capilla",
+ "Parque", "Jardín", "Zoo", "Acuario", "Circo", "Feria", "Parque acuático", "Parque de atracciones", "Estadio", "Campo de fútbol",
+ "Castillo", "Palacio", "Torre", "Muralla", "Fortaleza", "Pirámide", "Coliseo", "Acueducto", "Faro", "Molino",
+ "Oficina", "Fábrica", "Almacén", "Laboratorio", "Estudio", "Despacho", "Sala de reuniones", "Coworking", "Taller", "Nave industrial",
+ "Cárcel", "Comisaría", "Cuartel", "Juzgado", "Ayuntamiento", "Parlamento", "Embajada", "Consulado", "Aduana", "Frontera",
+ "Isla", "Oasis", "Arrecife", "Marisma", "Pantano", "Acantilado", "Valle", "Cañón", "Meseta", "Delta"
+ ],
+ "peliculas": [
+ "Titanic", "Avatar", "Star Wars", "Harry Potter", "El Señor de los Anillos", "Matrix", "Jurassic Park", "Indiana Jones", "Rocky", "Terminator",
+ "El Padrino", "Gladiator", "Braveheart", "Forrest Gump", "El Rey León", "Toy Story", "Frozen", "Coco", "Up", "Wall-E",
+ "Inception", "Interstellar", "Gravity", "The Martian", "Alien", "Predator", "Robocop", "Blade Runner", "Mad Max", "Tron",
+ "Batman", "Superman", "Spider-Man", "Iron Man", "Los Vengadores", "X-Men", "Aquaman", "Thor", "Hulk", "Deadpool",
+ "Piratas del Caribe", "Misión Imposible", "James Bond", "Jason Bourne", "Fast & Furious", "John Wick", "Kill Bill", "Kingsman", "Jack Reacher", "Equalizer",
+ "Shrek", "Madagascar", "Buscando a Nemo", "Monstruos S.A.", "Ratatouille", "Los Increíbles", "Zootopia", "Inside Out", "Soul", "Luca",
+ "Titanic", "Ghost", "Dirty Dancing", "Grease", "Pretty Woman", "Notting Hill", "Love Actually", "La La Land", "Moulin Rouge", "Casablanca",
+ "El Exorcista", "It", "Saw", "Scream", "Halloween", "Pesadilla en Elm Street", "El Resplandor", "Poltergeist", "Drácula", "Frankenstein",
+ "Regreso al Futuro", "E.T.", "Los Goonies", "Karate Kid", "Cazafantasmas", "Gremlins", "Beetlejuice", "Labyrinth", "Willow", "La Historia Interminable",
+ "El Silencio de los Corderos", "Seven", "El Club de la Lucha", "Memento", "Shutter Island", "Gone Girl", "Zodiac", "Prisioneros", "Mystic River", "El Juego de Ender"
+ ],
+ "musica": [
+ "Guitarra", "Piano", "Batería", "Bajo", "Violín", "Saxofón", "Trompeta", "Flauta", "Arpa", "Acordeón",
+ "Rock", "Pop", "Jazz", "Blues", "Reggaetón", "Salsa", "Bachata", "Cumbia", "Tango", "Flamenco",
+ "Rap", "Hip hop", "Trap", "R&B", "Soul", "Funk", "Disco", "House", "Techno", "Dubstep",
+ "Metal", "Punk", "Grunge", "Indie", "Ska", "Reggae", "Country", "Folk", "Góspel", "Coral",
+ "Ópera", "Sinfonía", "Concierto", "Sonata", "Vals", "Bolero", "Serenata", "Himno", "Marcha", "Polca",
+ "Micrófono", "Amplificador", "Altavoz", "Cascos", "Vinilo", "Cassette", "CD", "MP3", "Tocadiscos", "Metrónomo",
+ "Ukelele", "Banjo", "Mandolina", "Cítara", "Laúd", "Oboe", "Clarinete", "Fagot", "Tuba", "Trombón",
+ "Pandereta", "Maracas", "Bongó", "Djembé", "Cajón flamenco", "Castañuelas", "Triángulo", "Xilófono", "Gong", "Platillos",
+ "Karaoke", "Playback", "Coro", "Dúo", "Solo", "Cuarteto", "Orquesta", "Banda", "DJ", "Cantautor",
+ "Festival", "Concierto", "Gira", "Discoteca", "Verbena", "Jam session", "Open mic", "Recital", "Serenata", "Ensayo"
+ ],
+ "tecnologia": [
+ "Smartphone", "Tablet", "Portátil", "Smartwatch", "Auriculares Bluetooth", "Drone", "Robot", "Impresora 3D", "Realidad virtual", "Realidad aumentada",
+ "WiFi", "Bluetooth", "GPS", "NFC", "USB", "HDMI", "Ethernet", "Fibra óptica", "Satélite", "Antena",
+ "Google", "Amazon", "Apple", "Microsoft", "Tesla", "Netflix", "Spotify", "TikTok", "Instagram", "WhatsApp",
+ "Inteligencia artificial", "Blockchain", "Criptomoneda", "Bitcoin", "NFT", "Metaverso", "Cloud computing", "Big data", "IoT", "5G",
+ "Videojuego", "Consola", "Mando", "Joystick", "Arcade", "VR", "Streaming", "Podcast", "Blog", "Meme",
+ "Código QR", "Contraseña", "Huella digital", "Reconocimiento facial", "Cifrado", "Firewall", "Antivirus", "Backup", "Servidor", "Base de datos",
+ "Python", "JavaScript", "HTML", "CSS", "Java", "Swift", "Kotlin", "Rust", "Go", "Ruby",
+ "Linux", "Windows", "macOS", "Android", "iOS", "Ubuntu", "Chrome OS", "Arduino", "Raspberry Pi", "BIOS",
+ "Píxel", "Megabyte", "Gigabyte", "Terabyte", "RAM", "SSD", "Procesador", "Tarjeta gráfica", "Placa base", "Fuente de alimentación",
+ "Email", "Spam", "Phishing", "Hacker", "Bug", "Parche", "Actualización", "App", "Widget", "Plugin"
+ ]
+ }
+}
diff --git a/lib/estado/estado_juego.dart b/lib/estado/estado_juego.dart
new file mode 100644
index 0000000..8caef26
--- /dev/null
+++ b/lib/estado/estado_juego.dart
@@ -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 _votos = {}; // votanteId -> votadoId
+ bool _cargando = false;
+
+ BancoPalabras? get banco => _banco;
+ Partida? get partida => _partida;
+ Map get votos => Map.unmodifiable(_votos);
+ bool get cargando => _cargando;
+
+ Future 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 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 = {};
+ 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();
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
new file mode 100644
index 0000000..cb0d94f
--- /dev/null
+++ b/lib/main.dart
@@ -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();
+
+ 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();
+ }
+}
diff --git a/lib/modelos/jugador.dart b/lib/modelos/jugador.dart
new file mode 100644
index 0000000..ca18067
--- /dev/null
+++ b/lib/modelos/jugador.dart
@@ -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 notas; // nombre_jugador -> nota
+
+ Jugador({
+ required this.id,
+ required this.nombre,
+ this.esImpostor = false,
+ this.eliminado = false,
+ this.palabra,
+ this.endpointId,
+ Map? 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 toJson() => {
+ 'id': id,
+ 'nombre': nombre,
+ 'esImpostor': esImpostor,
+ 'eliminado': eliminado,
+ };
+
+ factory Jugador.fromJson(Map json) => Jugador(
+ id: json['id'] as String,
+ nombre: json['nombre'] as String,
+ esImpostor: json['esImpostor'] as bool? ?? false,
+ eliminado: json['eliminado'] as bool? ?? false,
+ );
+}
diff --git a/lib/modelos/palabra.dart b/lib/modelos/palabra.dart
new file mode 100644
index 0000000..394b61d
--- /dev/null
+++ b/lib/modelos/palabra.dart
@@ -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> categorias;
+
+ BancoPalabras(this.categorias);
+
+ static BancoPalabras? _instancia;
+
+ static Future cargar() async {
+ if (_instancia != null) return _instancia!;
+ final jsonStr = await rootBundle.loadString('assets/palabras.json');
+ final data = json.decode(jsonStr) as Map;
+ final cats = data['categorias'] as Map;
+ final mapa = >{};
+ for (final entrada in cats.entries) {
+ mapa[entrada.key] = List.from(entrada.value);
+ }
+ _instancia = BancoPalabras(mapa);
+ return _instancia!;
+ }
+
+ List 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;
+ }
+}
diff --git a/lib/modelos/partida.dart b/lib/modelos/partida.dart
new file mode 100644
index 0000000..33946bd
--- /dev/null
+++ b/lib/modelos/partida.dart
@@ -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 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 jugadores;
+ final String palabraSecreta;
+ final String categoriaReal;
+ FaseJuego fase;
+ int rondaActual;
+ final List 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? historialVotaciones,
+ this.ganador,
+ }) : historialVotaciones = historialVotaciones ?? [];
+
+ List get jugadoresActivos =>
+ jugadores.where((j) => !j.eliminado).toList();
+
+ List get impostoresActivos =>
+ jugadoresActivos.where((j) => j.esImpostor).toList();
+
+ List get jugadoresNormalesActivos =>
+ jugadoresActivos.where((j) => !j.esImpostor).toList();
+
+ int get impostoresTotales =>
+ jugadores.where((j) => j.esImpostor).length;
+}
diff --git a/lib/pantallas/pantalla_adivinanza.dart b/lib/pantallas/pantalla_adivinanza.dart
new file mode 100644
index 0000000..c894fcc
--- /dev/null
+++ b/lib/pantallas/pantalla_adivinanza.dart
@@ -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 createState() => _PantallaAdivinanzaState();
+}
+
+class _PantallaAdivinanzaState extends State {
+ final _controlador = TextEditingController();
+ bool? _acierto;
+
+ @override
+ void dispose() {
+ _controlador.dispose();
+ super.dispose();
+ }
+
+ void _intentarAdivinar() {
+ final estado = context.read();
+ final resultado = estado.intentarAdivinar(_controlador.text);
+ setState(() => _acierto = resultado);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.watch();
+ 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'),
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart
new file mode 100644
index 0000000..9beebd0
--- /dev/null
+++ b/lib/pantallas/pantalla_crear_partida.dart
@@ -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 createState() => _PantallaCrearPartidaState();
+}
+
+class _PantallaCrearPartidaState extends State {
+ bool _modoMultimovil = false;
+ String _categoria = 'todas';
+ int _numImpostores = 1;
+ bool _pistaImpostor = false;
+ int? _tiempoDebate;
+ final List _jugadores = [];
+ final _controladorNombre = TextEditingController();
+
+ final _opcionesTiempo = [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();
+ 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();
+ 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(
+ 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(
+ 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(
+ 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),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_debate.dart b/lib/pantallas/pantalla_debate.dart
new file mode 100644
index 0000000..7517bd8
--- /dev/null
+++ b/lib/pantallas/pantalla_debate.dart
@@ -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 createState() => _PantallaDebateState();
+}
+
+class _PantallaDebateState extends State {
+ Timer? _timer;
+ int _segundosRestantes = 0;
+ bool _tiempoAgotado = false;
+
+ @override
+ void initState() {
+ super.initState();
+ final estado = context.read();
+ 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();
+ estado.iniciarVotacion();
+ Navigator.pushReplacement(
+ context,
+ MaterialPageRoute(builder: (_) => const PantallaVotacion()),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.watch();
+ 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'),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_fin_partida.dart b/lib/pantallas/pantalla_fin_partida.dart
new file mode 100644
index 0000000..03d9a5a
--- /dev/null
+++ b/lib/pantallas/pantalla_fin_partida.dart
@@ -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();
+ 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),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_notas.dart b/lib/pantallas/pantalla_notas.dart
new file mode 100644
index 0000000..802322a
--- /dev/null
+++ b/lib/pantallas/pantalla_notas.dart
@@ -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 createState() => _PantallaNotasState();
+}
+
+class _PantallaNotasState extends State {
+ String? _jugadorSeleccionadoId;
+ final Map _controladores = {};
+ final _controladorNotaLibre = TextEditingController();
+ bool _cargado = false;
+
+ @override
+ void dispose() {
+ for (final c in _controladores.values) {
+ c.dispose();
+ }
+ _controladorNotaLibre.dispose();
+ super.dispose();
+ }
+
+ Future _cargarNotas(String jugadorId) async {
+ final datos = await ServicioNotas.cargarNotas(jugadorId);
+ final notas = datos['notas'] as Map;
+ 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 _guardarNotas() async {
+ if (_jugadorSeleccionadoId == null) return;
+ final notas = {};
+ 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();
+ 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((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,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_principal.dart b/lib/pantallas/pantalla_principal.dart
new file mode 100644
index 0000000..ab8955e
--- /dev/null
+++ b/lib/pantallas/pantalla_principal.dart
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_reglas.dart b/lib/pantallas/pantalla_reglas.dart
new file mode 100644
index 0000000..7444fb1
--- /dev/null
+++ b/lib/pantallas/pantalla_reglas.dart
@@ -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,
+ )),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_resultado.dart b/lib/pantallas/pantalla_resultado.dart
new file mode 100644
index 0000000..754df77
--- /dev/null
+++ b/lib/pantallas/pantalla_resultado.dart
@@ -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 createState() => _PantallaResultadoState();
+}
+
+class _PantallaResultadoState extends State
+ with SingleTickerProviderStateMixin {
+ bool _revelado = false;
+ late AnimationController _animController;
+ late Animation _animOpacidad;
+
+ @override
+ void initState() {
+ super.initState();
+ _animController = AnimationController(
+ duration: const Duration(milliseconds: 2500),
+ vsync: this,
+ );
+ _animOpacidad = Tween(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();
+ 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()),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart
new file mode 100644
index 0000000..151b514
--- /dev/null
+++ b/lib/pantallas/pantalla_unirse.dart
@@ -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'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_ver_palabra.dart b/lib/pantallas/pantalla_ver_palabra.dart
new file mode 100644
index 0000000..ca0fa95
--- /dev/null
+++ b/lib/pantallas/pantalla_ver_palabra.dart
@@ -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 createState() => _PantallaVerPalabraState();
+}
+
+class _PantallaVerPalabraState extends State {
+ final Set _hanVisto = {};
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.watch();
+ 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();
+ 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'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pantallas/pantalla_votacion.dart b/lib/pantallas/pantalla_votacion.dart
new file mode 100644
index 0000000..26b5188
--- /dev/null
+++ b/lib/pantallas/pantalla_votacion.dart
@@ -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 createState() => _PantallaVotacionState();
+}
+
+class _PantallaVotacionState extends State {
+ String? _seleccionado;
+
+ @override
+ Widget build(BuildContext context) {
+ final estado = context.watch();
+ 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'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart
new file mode 100644
index 0000000..c347414
--- /dev/null
+++ b/lib/servicios/servicio_nearby.dart
@@ -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 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;
+ return MensajeP2P(
+ tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']),
+ datos: mapa['datos'] as Map,
+ );
+ }
+}
+
+/// 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 _dispositivos = [];
+
+ bool get esHost => _esHost;
+ bool get conectado => _conectado;
+ String? get endpointId => _endpointId;
+ List get dispositivos => List.unmodifiable(_dispositivos);
+
+ /// Inicia como host (anunciando el endpoint)
+ Future 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 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 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 enviarATodos(MensajeP2P mensaje) async {
+ for (final id in _dispositivos) {
+ await enviarMensaje(id, mensaje);
+ }
+ }
+
+ /// Desconecta y limpia
+ Future 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,
+ });
+ }
+}
diff --git a/lib/servicios/servicio_notas.dart b/lib/servicios/servicio_notas.dart
new file mode 100644
index 0000000..9c9be24
--- /dev/null
+++ b/lib/servicios/servicio_notas.dart
@@ -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 guardarNotas(
+ String jugadorId,
+ Map 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