El Impostor v0.1 — app Flutter completa

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

50
.gitignore vendored Normal file
View File

@@ -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

30
.metadata Normal file
View File

@@ -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'

28
analysis_options.yaml Normal file
View File

@@ -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

14
android/.gitignore vendored Normal file
View File

@@ -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

View File

@@ -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 = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permisos para Nearby Connections (Bluetooth + WiFi Direct) -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Cámara para escanear QR -->
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="El Impostor"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package es.freetimelab.el_impostor
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

24
android/build.gradle.kts Normal file
View File

@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -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

View File

@@ -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")

124
assets/palabras.json Normal file
View File

@@ -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"
]
}
}

View File

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

72
lib/main.dart Normal file
View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

514
pubspec.lock Normal file
View File

@@ -0,0 +1,514 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
nearby_connections:
dependency: "direct main"
description:
name: nearby_connections
sha256: "94d500bdb11f9a3db3b1cb2949ab438107e581f0142380efc17ecc8038e99369"
url: "https://pub.dev"
source: hosted
version: "4.3.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev"
source: hosted
version: "2.2.23"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.1 <4.0.0"
flutter: ">=3.38.4"

27
pubspec.yaml Normal file
View File

@@ -0,0 +1,27 @@
name: el_impostor
description: "El Impostor - Juego de deducción social"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ^3.11.1
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
shared_preferences: ^2.3.4
qr_flutter: ^4.1.0
mobile_scanner: ^6.0.5
google_fonts: ^6.2.1
nearby_connections: ^4.0.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true
assets:
- assets/palabras.json

9
test/widget_test.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:el_impostor/main.dart';
void main() {
testWidgets('App carga correctamente', (WidgetTester tester) async {
await tester.pumpWidget(const ElImpostorApp());
expect(find.text('El Impostor'), findsOneWidget);
});
}