diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16b994b --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release +build/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..56546de --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + - platform: android + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..a502cb2 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "es.freetimelab.elimpostor" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "es.freetimelab.elimpostor" + minSdk = flutter.minSdkVersion + targetSdk = 34 + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7eea1e4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt new file mode 100644 index 0000000..9bf1cae --- /dev/null +++ b/android/app/src/main/kotlin/es/freetimelab/el_impostor/MainActivity.kt @@ -0,0 +1,5 @@ +package es.freetimelab.el_impostor + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..ca7fe06 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/palabras.json b/assets/palabras.json new file mode 100644 index 0000000..739599c --- /dev/null +++ b/assets/palabras.json @@ -0,0 +1,124 @@ +{ + "categorias": { + "animales": [ + "Perro", "Gato", "Caballo", "Vaca", "Cerdo", "Oveja", "Cabra", "Gallina", "Pato", "Conejo", + "León", "Tigre", "Elefante", "Jirafa", "Cebra", "Hipopótamo", "Rinoceronte", "Gorila", "Chimpancé", "Orangután", + "Águila", "Halcón", "Búho", "Colibrí", "Pelícano", "Flamenco", "Pingüino", "Loro", "Canario", "Cuervo", + "Tiburón", "Delfín", "Ballena", "Pulpo", "Medusa", "Tortuga", "Cocodrilo", "Serpiente", "Iguana", "Camaleón", + "Abeja", "Mariposa", "Hormiga", "Araña", "Escorpión", "Libélula", "Escarabajo", "Mariquita", "Luciérnaga", "Grillo", + "Lobo", "Zorro", "Oso", "Ciervo", "Jabalí", "Ardilla", "Castor", "Nutria", "Mapache", "Tejón", + "Camello", "Llama", "Alpaca", "Bisonte", "Búfalo", "Alce", "Reno", "Antílope", "Gacela", "Ñu", + "Foca", "Morsa", "Narval", "Orca", "Mantarraya", "Pez espada", "Salmón", "Trucha", "Atún", "Sardina", + "Koala", "Canguro", "Ornitorrinco", "Wombat", "Panda", "Lémur", "Suricata", "Hiena", "Leopardo", "Pantera", + "Tucán", "Pavo real", "Avestruz", "Cisne", "Gaviota", "Albatros", "Buitre", "Cigüeña", "Garza", "Grulla" + ], + "comida": [ + "Pizza", "Hamburguesa", "Paella", "Tortilla", "Croqueta", "Patatas fritas", "Sushi", "Ramen", "Tacos", "Burrito", + "Pasta", "Risotto", "Lasaña", "Raviolis", "Ñoquis", "Canelones", "Macarrones", "Espaguetis", "Fideos", "Tallarines", + "Ensalada", "Gazpacho", "Sopa", "Crema", "Guiso", "Estofado", "Cocido", "Puchero", "Potaje", "Lentejas", + "Pan", "Croissant", "Baguette", "Churros", "Donut", "Magdalena", "Galleta", "Bizcocho", "Tarta", "Flan", + "Helado", "Natillas", "Arroz con leche", "Turrón", "Chocolate", "Caramelo", "Chicle", "Piruleta", "Gominola", "Regaliz", + "Manzana", "Plátano", "Naranja", "Fresa", "Uva", "Sandía", "Melón", "Piña", "Mango", "Kiwi", + "Tomate", "Cebolla", "Ajo", "Pimiento", "Zanahoria", "Calabaza", "Berenjena", "Calabacín", "Pepino", "Lechuga", + "Pollo asado", "Filete", "Costillas", "Albóndigas", "Salchicha", "Jamón", "Chorizo", "Bacon", "Pechuga", "Muslo", + "Queso", "Yogur", "Mantequilla", "Nata", "Leche", "Huevo", "Aceite", "Vinagre", "Sal", "Pimienta", + "Café", "Té", "Zumo", "Batido", "Limonada", "Cerveza", "Vino", "Sangría", "Cóctel", "Refresco" + ], + "paises": [ + "España", "Francia", "Italia", "Alemania", "Portugal", "Reino Unido", "Irlanda", "Holanda", "Bélgica", "Suiza", + "Austria", "Grecia", "Turquía", "Rusia", "Polonia", "Suecia", "Noruega", "Dinamarca", "Finlandia", "Islandia", + "Estados Unidos", "Canadá", "México", "Brasil", "Argentina", "Chile", "Colombia", "Perú", "Venezuela", "Ecuador", + "Japón", "China", "India", "Corea del Sur", "Tailandia", "Vietnam", "Indonesia", "Filipinas", "Malasia", "Singapur", + "Egipto", "Marruecos", "Sudáfrica", "Nigeria", "Kenia", "Etiopía", "Tanzania", "Ghana", "Senegal", "Túnez", + "Australia", "Nueva Zelanda", "Cuba", "Jamaica", "Costa Rica", "Panamá", "Uruguay", "Paraguay", "Bolivia", "Honduras", + "Croacia", "República Checa", "Hungría", "Rumanía", "Bulgaria", "Serbia", "Ucrania", "Lituania", "Letonia", "Estonia", + "Israel", "Líbano", "Jordania", "Arabia Saudí", "Emiratos Árabes", "Qatar", "Irán", "Irak", "Pakistán", "Bangladesh", + "Nepal", "Sri Lanka", "Mongolia", "Camboya", "Myanmar", "Taiwán", "Macao", "Hong Kong", "Laos", "Brunéi", + "Islas Maldivas", "Madagascar", "Mozambique", "Angola", "Camerún", "Costa de Marfil", "Mali", "Níger", "Congo", "Uganda" + ], + "deportes": [ + "Fútbol", "Baloncesto", "Tenis", "Natación", "Atletismo", "Ciclismo", "Boxeo", "Judo", "Karate", "Taekwondo", + "Golf", "Rugby", "Cricket", "Béisbol", "Hockey", "Voleibol", "Balonmano", "Waterpolo", "Esgrima", "Tiro con arco", + "Esquí", "Snowboard", "Patinaje", "Surf", "Windsurf", "Vela", "Remo", "Piragüismo", "Buceo", "Escalada", + "Gimnasia", "Halterofilia", "Lucha", "Sumo", "MMA", "Kickboxing", "Muay thai", "Capoeira", "Wrestling", "Sambo", + "Pádel", "Squash", "Bádminton", "Ping pong", "Billar", "Dardos", "Bolos", "Petanca", "Croquet", "Polo", + "Fórmula 1", "Motociclismo", "Rally", "Karting", "NASCAR", "Motocross", "Trial", "Enduro", "Superbike", "Dragster", + "Skateboard", "BMX", "Parkour", "Breakdance", "Cheerleading", "Acrobacia", "Trapecio", "Cuerda floja", "Slackline", "Bungee", + "Ajedrez", "Damas", "Backgammon", "Go", "Dominó", "Póker", "Bridge", "Mahjong", "Scrabble", "Risk", + "Triatlón", "Decatlón", "Maratón", "Ultratrail", "Ironman", "Duatlón", "Cross", "Orientación", "Geocaching", "Senderismo", + "Pelota vasca", "Jai alai", "Hurling", "Lacrosse", "Kabaddi", "Sepak takraw", "Bossaball", "Quidditch", "Ultimate frisbee", "Spikeball" + ], + "profesiones": [ + "Médico", "Enfermera", "Dentista", "Veterinario", "Farmacéutico", "Cirujano", "Psicólogo", "Fisioterapeuta", "Optometrista", "Nutricionista", + "Abogado", "Juez", "Notario", "Fiscal", "Detective", "Policía", "Bombero", "Soldado", "Espía", "Guardaespaldas", + "Profesor", "Maestro", "Catedrático", "Tutor", "Pedagogo", "Logopeda", "Director de colegio", "Investigador", "Científico", "Arqueólogo", + "Arquitecto", "Ingeniero", "Electricista", "Fontanero", "Carpintero", "Albañil", "Pintor", "Soldador", "Mecánico", "Cerrajero", + "Chef", "Camarero", "Barista", "Sommelier", "Pastelero", "Panadero", "Carnicero", "Pescadero", "Frutero", "Heladero", + "Actor", "Director de cine", "Guionista", "Cámara", "Productor", "Cantante", "Músico", "DJ", "Bailarín", "Mago", + "Periodista", "Escritor", "Editor", "Fotógrafo", "Diseñador", "Ilustrador", "Animador", "Traductor", "Locutor", "Presentador", + "Piloto", "Astronauta", "Marinero", "Capitán", "Camionero", "Taxista", "Conductor de bus", "Maquinista", "Cartero", "Mensajero", + "Programador", "Hacker", "Administrador de sistemas", "Community manager", "Data scientist", "Youtuber", "Streamer", "Gamer", "Tester", "Scrum master", + "Agricultor", "Ganadero", "Jardinero", "Apicultor", "Leñador", "Minero", "Geólogo", "Biólogo", "Botánico", "Zoólogo" + ], + "objetos": [ + "Teléfono", "Ordenador", "Televisión", "Radio", "Reloj", "Cámara", "Auriculares", "Altavoz", "Micrófono", "Mando", + "Silla", "Mesa", "Sofá", "Cama", "Armario", "Estantería", "Lámpara", "Espejo", "Alfombra", "Cortina", + "Cuchillo", "Tenedor", "Cuchara", "Plato", "Vaso", "Taza", "Sartén", "Olla", "Batidora", "Microondas", + "Llave", "Candado", "Puerta", "Ventana", "Escalera", "Ascensor", "Columpio", "Tobogán", "Trampolín", "Hamaca", + "Libro", "Cuaderno", "Bolígrafo", "Lápiz", "Goma", "Regla", "Tijeras", "Pegamento", "Pincel", "Rotulador", + "Paraguas", "Abanico", "Gafas", "Guantes", "Bufanda", "Gorro", "Cinturón", "Corbata", "Anillo", "Collar", + "Balón", "Raqueta", "Bicicleta", "Patinete", "Monopatín", "Comba", "Peonza", "Dado", "Puzzle", "Muñeca", + "Martillo", "Destornillador", "Alicate", "Sierra", "Taladro", "Nivel", "Metro", "Llave inglesa", "Tornillo", "Clavo", + "Maleta", "Mochila", "Bolso", "Cartera", "Monedero", "Sobre", "Paquete", "Caja", "Cubo", "Botella", + "Vela", "Cerilla", "Mechero", "Linterna", "Brújula", "Mapa", "Globo terráqueo", "Telescopio", "Microscopio", "Lupa" + ], + "lugares": [ + "Playa", "Montaña", "Bosque", "Desierto", "Selva", "Pradera", "Volcán", "Glaciar", "Cascada", "Cueva", + "Hospital", "Colegio", "Universidad", "Biblioteca", "Museo", "Teatro", "Cine", "Discoteca", "Restaurante", "Cafetería", + "Supermercado", "Farmacia", "Peluquería", "Banco", "Correos", "Gasolinera", "Taller", "Lavandería", "Gimnasio", "Piscina", + "Aeropuerto", "Estación de tren", "Puerto", "Autopista", "Puente", "Túnel", "Rotonda", "Parking", "Peaje", "Parada de bus", + "Iglesia", "Mezquita", "Sinagoga", "Templo", "Catedral", "Monasterio", "Cementerio", "Ermita", "Santuario", "Capilla", + "Parque", "Jardín", "Zoo", "Acuario", "Circo", "Feria", "Parque acuático", "Parque de atracciones", "Estadio", "Campo de fútbol", + "Castillo", "Palacio", "Torre", "Muralla", "Fortaleza", "Pirámide", "Coliseo", "Acueducto", "Faro", "Molino", + "Oficina", "Fábrica", "Almacén", "Laboratorio", "Estudio", "Despacho", "Sala de reuniones", "Coworking", "Taller", "Nave industrial", + "Cárcel", "Comisaría", "Cuartel", "Juzgado", "Ayuntamiento", "Parlamento", "Embajada", "Consulado", "Aduana", "Frontera", + "Isla", "Oasis", "Arrecife", "Marisma", "Pantano", "Acantilado", "Valle", "Cañón", "Meseta", "Delta" + ], + "peliculas": [ + "Titanic", "Avatar", "Star Wars", "Harry Potter", "El Señor de los Anillos", "Matrix", "Jurassic Park", "Indiana Jones", "Rocky", "Terminator", + "El Padrino", "Gladiator", "Braveheart", "Forrest Gump", "El Rey León", "Toy Story", "Frozen", "Coco", "Up", "Wall-E", + "Inception", "Interstellar", "Gravity", "The Martian", "Alien", "Predator", "Robocop", "Blade Runner", "Mad Max", "Tron", + "Batman", "Superman", "Spider-Man", "Iron Man", "Los Vengadores", "X-Men", "Aquaman", "Thor", "Hulk", "Deadpool", + "Piratas del Caribe", "Misión Imposible", "James Bond", "Jason Bourne", "Fast & Furious", "John Wick", "Kill Bill", "Kingsman", "Jack Reacher", "Equalizer", + "Shrek", "Madagascar", "Buscando a Nemo", "Monstruos S.A.", "Ratatouille", "Los Increíbles", "Zootopia", "Inside Out", "Soul", "Luca", + "Titanic", "Ghost", "Dirty Dancing", "Grease", "Pretty Woman", "Notting Hill", "Love Actually", "La La Land", "Moulin Rouge", "Casablanca", + "El Exorcista", "It", "Saw", "Scream", "Halloween", "Pesadilla en Elm Street", "El Resplandor", "Poltergeist", "Drácula", "Frankenstein", + "Regreso al Futuro", "E.T.", "Los Goonies", "Karate Kid", "Cazafantasmas", "Gremlins", "Beetlejuice", "Labyrinth", "Willow", "La Historia Interminable", + "El Silencio de los Corderos", "Seven", "El Club de la Lucha", "Memento", "Shutter Island", "Gone Girl", "Zodiac", "Prisioneros", "Mystic River", "El Juego de Ender" + ], + "musica": [ + "Guitarra", "Piano", "Batería", "Bajo", "Violín", "Saxofón", "Trompeta", "Flauta", "Arpa", "Acordeón", + "Rock", "Pop", "Jazz", "Blues", "Reggaetón", "Salsa", "Bachata", "Cumbia", "Tango", "Flamenco", + "Rap", "Hip hop", "Trap", "R&B", "Soul", "Funk", "Disco", "House", "Techno", "Dubstep", + "Metal", "Punk", "Grunge", "Indie", "Ska", "Reggae", "Country", "Folk", "Góspel", "Coral", + "Ópera", "Sinfonía", "Concierto", "Sonata", "Vals", "Bolero", "Serenata", "Himno", "Marcha", "Polca", + "Micrófono", "Amplificador", "Altavoz", "Cascos", "Vinilo", "Cassette", "CD", "MP3", "Tocadiscos", "Metrónomo", + "Ukelele", "Banjo", "Mandolina", "Cítara", "Laúd", "Oboe", "Clarinete", "Fagot", "Tuba", "Trombón", + "Pandereta", "Maracas", "Bongó", "Djembé", "Cajón flamenco", "Castañuelas", "Triángulo", "Xilófono", "Gong", "Platillos", + "Karaoke", "Playback", "Coro", "Dúo", "Solo", "Cuarteto", "Orquesta", "Banda", "DJ", "Cantautor", + "Festival", "Concierto", "Gira", "Discoteca", "Verbena", "Jam session", "Open mic", "Recital", "Serenata", "Ensayo" + ], + "tecnologia": [ + "Smartphone", "Tablet", "Portátil", "Smartwatch", "Auriculares Bluetooth", "Drone", "Robot", "Impresora 3D", "Realidad virtual", "Realidad aumentada", + "WiFi", "Bluetooth", "GPS", "NFC", "USB", "HDMI", "Ethernet", "Fibra óptica", "Satélite", "Antena", + "Google", "Amazon", "Apple", "Microsoft", "Tesla", "Netflix", "Spotify", "TikTok", "Instagram", "WhatsApp", + "Inteligencia artificial", "Blockchain", "Criptomoneda", "Bitcoin", "NFT", "Metaverso", "Cloud computing", "Big data", "IoT", "5G", + "Videojuego", "Consola", "Mando", "Joystick", "Arcade", "VR", "Streaming", "Podcast", "Blog", "Meme", + "Código QR", "Contraseña", "Huella digital", "Reconocimiento facial", "Cifrado", "Firewall", "Antivirus", "Backup", "Servidor", "Base de datos", + "Python", "JavaScript", "HTML", "CSS", "Java", "Swift", "Kotlin", "Rust", "Go", "Ruby", + "Linux", "Windows", "macOS", "Android", "iOS", "Ubuntu", "Chrome OS", "Arduino", "Raspberry Pi", "BIOS", + "Píxel", "Megabyte", "Gigabyte", "Terabyte", "RAM", "SSD", "Procesador", "Tarjeta gráfica", "Placa base", "Fuente de alimentación", + "Email", "Spam", "Phishing", "Hacker", "Bug", "Parche", "Actualización", "App", "Widget", "Plugin" + ] + } +} diff --git a/lib/estado/estado_juego.dart b/lib/estado/estado_juego.dart new file mode 100644 index 0000000..8caef26 --- /dev/null +++ b/lib/estado/estado_juego.dart @@ -0,0 +1,221 @@ +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import '../modelos/jugador.dart'; +import '../modelos/partida.dart'; +import '../modelos/palabra.dart'; +import '../servicios/servicio_notas.dart'; + +/// Estado global del juego gestionado con Provider +class EstadoJuego extends ChangeNotifier { + BancoPalabras? _banco; + Partida? _partida; + final Map _votos = {}; // votanteId -> votadoId + bool _cargando = false; + + BancoPalabras? get banco => _banco; + Partida? get partida => _partida; + Map get votos => Map.unmodifiable(_votos); + bool get cargando => _cargando; + + Future cargarBanco() async { + _cargando = true; + notifyListeners(); + _banco = await BancoPalabras.cargar(); + _cargando = false; + notifyListeners(); + } + + /// Crea una nueva partida con la configuración dada y lista de jugadores + void crearPartida({ + required ConfigPartida config, + required List nombresJugadores, + }) { + if (_banco == null) return; + if (nombresJugadores.length < 3) return; + + final rng = Random(); + + // Seleccionar palabra + final palabra = _banco!.palabraAleatoria(config.categoria); + final categoriaReal = + _banco!.categoriaDepalabra(palabra) ?? config.categoria; + + // Crear jugadores + final jugadores = nombresJugadores.asMap().entries.map((e) { + return Jugador( + id: 'j${e.key}', + nombre: e.value, + ); + }).toList(); + + // Asignar impostores aleatoriamente + final indices = List.generate(jugadores.length, (i) => i); + indices.shuffle(rng); + final numImpostores = + config.numImpostores.clamp(1, jugadores.length ~/ 3); + for (int i = 0; i < numImpostores; i++) { + jugadores[indices[i]].esImpostor = true; + } + + // Asignar palabras + for (final j in jugadores) { + if (!j.esImpostor) { + j.palabra = palabra; + } + } + + _partida = Partida( + config: config, + jugadores: jugadores, + palabraSecreta: palabra, + categoriaReal: categoriaReal, + ); + + _votos.clear(); + ServicioNotas.limpiarNotas(); + notifyListeners(); + } + + /// Avanza a la fase de debate + void iniciarDebate() { + if (_partida == null) return; + _partida!.fase = FaseJuego.debate; + notifyListeners(); + } + + /// Avanza a la fase de votación + void iniciarVotacion() { + if (_partida == null) return; + _partida!.fase = FaseJuego.votacion; + _votos.clear(); + notifyListeners(); + } + + /// Registra un voto (modo un solo móvil) + void registrarVoto(String votanteId, String votadoId) { + _votos[votanteId] = votadoId; + notifyListeners(); + } + + /// Elimina un voto + void eliminarVoto(String votanteId) { + _votos.remove(votanteId); + notifyListeners(); + } + + /// Comprueba si todos los jugadores activos (no eliminados) han votado + bool todosHanVotado() { + if (_partida == null) return false; + final activos = _partida!.jugadoresActivos; + return activos.every((j) => _votos.containsKey(j.id)); + } + + /// Procesa los votos y determina el eliminado + ResultadoVotacion? procesarVotacion() { + if (_partida == null) return null; + + // Contar votos + final conteo = {}; + for (final votado in _votos.values) { + conteo[votado] = (conteo[votado] ?? 0) + 1; + } + + if (conteo.isEmpty) return null; + + // Encontrar máximo + final maxVotos = conteo.values.reduce(max); + final masVotados = + conteo.entries.where((e) => e.value == maxVotos).toList(); + + // En caso de empate, elegir aleatoriamente + final rng = Random(); + final eliminadoId = + masVotados[rng.nextInt(masVotados.length)].key; + + final eliminado = _partida!.jugadores.firstWhere((j) => j.id == eliminadoId); + eliminado.eliminado = true; + + final resultado = ResultadoVotacion( + eliminadoId: eliminadoId, + eliminadoNombre: eliminado.nombre, + eraImpostor: eliminado.esImpostor, + votos: Map.from(_votos), + ); + + _partida!.historialVotaciones.add(resultado); + _partida!.fase = FaseJuego.resultado; + notifyListeners(); + + return resultado; + } + + /// Comprueba si la partida ha terminado y actualiza el estado + bool comprobarFinPartida() { + if (_partida == null) return false; + + final impostoresVivos = _partida!.impostoresActivos.length; + final jugadoresVivos = _partida!.jugadoresNormalesActivos.length; + + // Los jugadores ganan si no quedan impostores + if (impostoresVivos == 0) { + _partida!.ganador = 'jugadores'; + _partida!.fase = FaseJuego.finPartida; + notifyListeners(); + return true; + } + + // Los impostores ganan si son >= que los jugadores normales + if (impostoresVivos >= jugadoresVivos) { + _partida!.ganador = 'impostores'; + _partida!.fase = FaseJuego.finPartida; + notifyListeners(); + return true; + } + + return false; + } + + /// Avanza a la fase de adivinanza del impostor + void iniciarAdivinanza() { + if (_partida == null) return; + _partida!.fase = FaseJuego.adivinanza; + notifyListeners(); + } + + /// El impostor intenta adivinar la palabra + bool intentarAdivinar(String intento) { + if (_partida == null) return false; + final acierto = + intento.trim().toLowerCase() == + _partida!.palabraSecreta.trim().toLowerCase(); + if (acierto) { + _partida!.ganador = 'impostores'; + _partida!.fase = FaseJuego.finPartida; + notifyListeners(); + } + return acierto; + } + + /// Inicia la siguiente ronda + void siguienteRonda() { + if (_partida == null) return; + _partida!.rondaActual++; + _partida!.fase = FaseJuego.debate; + _votos.clear(); + notifyListeners(); + } + + /// Revancha: mismos jugadores, nueva palabra + void revancha() { + if (_partida == null || _banco == null) return; + final nombres = _partida!.jugadores.map((j) => j.nombre).toList(); + crearPartida(config: _partida!.config, nombresJugadores: nombres); + } + + /// Limpia la partida actual + void limpiar() { + _partida = null; + _votos.clear(); + notifyListeners(); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..cb0d94f --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'estado/estado_juego.dart'; +import 'tema/tema_app.dart'; +import 'pantallas/pantalla_principal.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: TemaApp.colorFondo, + ), + ); + runApp(const ElImpostorApp()); +} + +class ElImpostorApp extends StatelessWidget { + const ElImpostorApp({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => EstadoJuego()..cargarBanco(), + child: MaterialApp( + title: 'El Impostor', + theme: TemaApp.obtenerTema(), + debugShowCheckedModeBanner: false, + home: const PantallaCarga(), + ), + ); + } +} + +class PantallaCarga extends StatelessWidget { + const PantallaCarga({super.key}); + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + + if (estado.cargando || estado.banco == null) { + return Scaffold( + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🎭', style: TextStyle(fontSize: 72)), + const SizedBox(height: 24), + Text( + 'El Impostor', + style: Theme.of(context).textTheme.headlineLarge, + ), + const SizedBox(height: 16), + const CircularProgressIndicator(color: TemaApp.colorAcento), + const SizedBox(height: 12), + Text( + 'Cargando palabras...', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + + return const PantallaPrincipal(); + } +} diff --git a/lib/modelos/jugador.dart b/lib/modelos/jugador.dart new file mode 100644 index 0000000..ca18067 --- /dev/null +++ b/lib/modelos/jugador.dart @@ -0,0 +1,51 @@ +/// Representa un jugador en la partida +class Jugador { + final String id; + final String nombre; + bool esImpostor; + bool eliminado; + String? palabra; + String? endpointId; // Para modo multimóvil + Map notas; // nombre_jugador -> nota + + Jugador({ + required this.id, + required this.nombre, + this.esImpostor = false, + this.eliminado = false, + this.palabra, + this.endpointId, + Map? notas, + }) : notas = notas ?? {}; + + Jugador copiar({ + bool? esImpostor, + bool? eliminado, + String? palabra, + String? endpointId, + }) { + return Jugador( + id: id, + nombre: nombre, + esImpostor: esImpostor ?? this.esImpostor, + eliminado: eliminado ?? this.eliminado, + palabra: palabra ?? this.palabra, + endpointId: endpointId ?? this.endpointId, + notas: Map.from(notas), + ); + } + + Map toJson() => { + 'id': id, + 'nombre': nombre, + 'esImpostor': esImpostor, + 'eliminado': eliminado, + }; + + factory Jugador.fromJson(Map json) => Jugador( + id: json['id'] as String, + nombre: json['nombre'] as String, + esImpostor: json['esImpostor'] as bool? ?? false, + eliminado: json['eliminado'] as bool? ?? false, + ); +} diff --git a/lib/modelos/palabra.dart b/lib/modelos/palabra.dart new file mode 100644 index 0000000..394b61d --- /dev/null +++ b/lib/modelos/palabra.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/services.dart'; + +/// Categorías disponibles en el banco de palabras +class BancoPalabras { + final Map> categorias; + + BancoPalabras(this.categorias); + + static BancoPalabras? _instancia; + + static Future cargar() async { + if (_instancia != null) return _instancia!; + final jsonStr = await rootBundle.loadString('assets/palabras.json'); + final data = json.decode(jsonStr) as Map; + final cats = data['categorias'] as Map; + final mapa = >{}; + for (final entrada in cats.entries) { + mapa[entrada.key] = List.from(entrada.value); + } + _instancia = BancoPalabras(mapa); + return _instancia!; + } + + List get nombresCategorias => categorias.keys.toList(); + + /// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null) + String palabraAleatoria(String? categoria) { + final rng = Random(); + if (categoria == null || categoria == 'todas') { + final todasPalabras = categorias.values.expand((l) => l).toList(); + return todasPalabras[rng.nextInt(todasPalabras.length)]; + } + final lista = categorias[categoria]!; + return lista[rng.nextInt(lista.length)]; + } + + /// Devuelve la categoría a la que pertenece una palabra + String? categoriaDepalabra(String palabra) { + for (final entrada in categorias.entries) { + if (entrada.value.contains(palabra)) return entrada.key; + } + return null; + } + + static String nombreBonitoCategoria(String clave) { + const nombres = { + 'todas': 'Todas', + 'animales': 'Animales', + 'comida': 'Comida', + 'paises': 'Países', + 'deportes': 'Deportes', + 'profesiones': 'Profesiones', + 'objetos': 'Objetos', + 'lugares': 'Lugares', + 'peliculas': 'Películas', + 'musica': 'Música', + 'tecnologia': 'Tecnología', + }; + return nombres[clave] ?? clave; + } +} diff --git a/lib/modelos/partida.dart b/lib/modelos/partida.dart new file mode 100644 index 0000000..33946bd --- /dev/null +++ b/lib/modelos/partida.dart @@ -0,0 +1,79 @@ +import 'jugador.dart'; + +/// Configuración de una partida +class ConfigPartida { + final bool modoMultimovil; + final String categoria; // 'todas' o nombre de categoría + final int numImpostores; + final bool pistaImpostor; + final int? tiempoDebateSegundos; // null = sin límite + + const ConfigPartida({ + this.modoMultimovil = false, + this.categoria = 'todas', + this.numImpostores = 1, + this.pistaImpostor = false, + this.tiempoDebateSegundos, + }); +} + +/// Fases del juego +enum FaseJuego { + configuracion, + verPalabra, + debate, + votacion, + resultado, + adivinanza, // El impostor intenta adivinar la palabra + finPartida, +} + +/// Resultado de una ronda de votación +class ResultadoVotacion { + final String eliminadoId; + final String eliminadoNombre; + final bool eraImpostor; + final Map votos; // votante -> votado + + const ResultadoVotacion({ + required this.eliminadoId, + required this.eliminadoNombre, + required this.eraImpostor, + required this.votos, + }); +} + +/// Estado completo de una partida +class Partida { + final ConfigPartida config; + final List jugadores; + final String palabraSecreta; + final String categoriaReal; + FaseJuego fase; + int rondaActual; + final List historialVotaciones; + String? ganador; // 'jugadores' | 'impostores' | null + + Partida({ + required this.config, + required this.jugadores, + required this.palabraSecreta, + required this.categoriaReal, + this.fase = FaseJuego.verPalabra, + this.rondaActual = 1, + List? historialVotaciones, + this.ganador, + }) : historialVotaciones = historialVotaciones ?? []; + + List get jugadoresActivos => + jugadores.where((j) => !j.eliminado).toList(); + + List get impostoresActivos => + jugadoresActivos.where((j) => j.esImpostor).toList(); + + List get jugadoresNormalesActivos => + jugadoresActivos.where((j) => !j.esImpostor).toList(); + + int get impostoresTotales => + jugadores.where((j) => j.esImpostor).length; +} diff --git a/lib/pantallas/pantalla_adivinanza.dart b/lib/pantallas/pantalla_adivinanza.dart new file mode 100644 index 0000000..c894fcc --- /dev/null +++ b/lib/pantallas/pantalla_adivinanza.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_debate.dart'; +import 'pantalla_fin_partida.dart'; + +class PantallaAdivinanza extends StatefulWidget { + const PantallaAdivinanza({super.key}); + + @override + State createState() => _PantallaAdivinanzaState(); +} + +class _PantallaAdivinanzaState extends State { + final _controlador = TextEditingController(); + bool? _acierto; + + @override + void dispose() { + _controlador.dispose(); + super.dispose(); + } + + void _intentarAdivinar() { + final estado = context.read(); + final resultado = estado.intentarAdivinar(_controlador.text); + setState(() => _acierto = resultado); + } + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + return Scaffold( + appBar: AppBar( + title: const Text('🎯 Adivinanza del impostor'), + automaticallyImplyLeading: false, + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🎭', style: TextStyle(fontSize: 64)), + const SizedBox(height: 16), + Text( + 'El impostor eliminado puede\nintentar adivinar la palabra', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Si acierta, ¡los impostores ganan!', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(height: 32), + + if (_acierto == null) ...[ + TextField( + controller: _controlador, + decoration: const InputDecoration( + hintText: '¿Cuál crees que es la palabra?', + prefixIcon: Icon(Icons.search), + ), + textCapitalization: TextCapitalization.sentences, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 20), + onSubmitted: (_) => _intentarAdivinar(), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + // No intenta adivinar, siguiente ronda + final fin = estado.comprobarFinPartida(); + if (fin) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaFinPartida(), + ), + ); + } else { + estado.siguienteRonda(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaDebate(), + ), + ); + } + }, + child: const Text('No intentar'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _controlador.text.trim().isNotEmpty + ? _intentarAdivinar + : null, + icon: const Icon(Icons.send), + label: const Text('Adivinar'), + ), + ), + ], + ), + ], + + if (_acierto == true) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: TemaApp.colorAcento.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: TemaApp.colorAcento), + ), + child: Column( + children: [ + const Text('🎭🎉', style: TextStyle(fontSize: 48)), + const SizedBox(height: 12), + Text( + '¡Ha acertado!', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(color: TemaApp.colorAcento), + ), + const SizedBox(height: 8), + Text( + 'La palabra era: ${partida.palabraSecreta}', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + '¡Los impostores ganan!', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaFinPartida(), + ), + ); + }, + icon: const Icon(Icons.emoji_events), + label: const Text('Ver resultado final'), + ), + ), + ], + + if (_acierto == false) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: TemaApp.colorVerde.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: TemaApp.colorVerde), + ), + child: Column( + children: [ + const Text('❌', style: TextStyle(fontSize: 48)), + const SizedBox(height: 12), + Text( + '¡No ha acertado!', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(color: TemaApp.colorVerde), + ), + const SizedBox(height: 8), + Text( + 'La partida continúa...', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + final fin = estado.comprobarFinPartida(); + if (fin) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaFinPartida(), + ), + ); + } else { + estado.siguienteRonda(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaDebate(), + ), + ); + } + }, + icon: const Icon(Icons.skip_next), + label: const Text('Siguiente ronda'), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart new file mode 100644 index 0000000..9beebd0 --- /dev/null +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -0,0 +1,330 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../modelos/palabra.dart'; +import '../modelos/partida.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_ver_palabra.dart'; + +class PantallaCrearPartida extends StatefulWidget { + const PantallaCrearPartida({super.key}); + + @override + State createState() => _PantallaCrearPartidaState(); +} + +class _PantallaCrearPartidaState extends State { + bool _modoMultimovil = false; + String _categoria = 'todas'; + int _numImpostores = 1; + bool _pistaImpostor = false; + int? _tiempoDebate; + final List _jugadores = []; + final _controladorNombre = TextEditingController(); + + final _opcionesTiempo = [null, 60, 120, 180, 300]; + final _etiquetasTiempo = ['Sin límite', '1 min', '2 min', '3 min', '5 min']; + + int get _maxImpostores => (_jugadores.length / 3).floor().clamp(1, 4); + + void _agregarJugador() { + final nombre = _controladorNombre.text.trim(); + if (nombre.isEmpty) return; + if (_jugadores.contains(nombre)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ya existe un jugador con ese nombre')), + ); + return; + } + if (_jugadores.length >= 20) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Máximo 20 jugadores')), + ); + return; + } + setState(() { + _jugadores.add(nombre); + _controladorNombre.clear(); + if (_numImpostores > _maxImpostores) { + _numImpostores = _maxImpostores; + } + }); + } + + void _eliminarJugador(int index) { + setState(() { + _jugadores.removeAt(index); + if (_numImpostores > _maxImpostores && _maxImpostores > 0) { + _numImpostores = _maxImpostores; + } + }); + } + + void _iniciarPartida() { + if (_jugadores.length < 3) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Se necesitan al menos 3 jugadores')), + ); + return; + } + + final estado = context.read(); + estado.crearPartida( + config: ConfigPartida( + modoMultimovil: _modoMultimovil, + categoria: _categoria, + numImpostores: _numImpostores, + pistaImpostor: _pistaImpostor, + tiempoDebateSegundos: _tiempoDebate, + ), + nombresJugadores: _jugadores, + ); + + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const PantallaVerPalabra()), + ); + } + + @override + void dispose() { + _controladorNombre.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final categorias = ['todas', ...?estado.banco?.nombresCategorias]; + + return Scaffold( + appBar: AppBar(title: const Text('Crear partida')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Modo de juego + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Modo de juego', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Un solo móvil'), + icon: Icon(Icons.phone_android), + ), + ButtonSegment( + value: true, + label: Text('Multimóvil'), + icon: Icon(Icons.devices), + ), + ], + selected: {_modoMultimovil}, + onSelectionChanged: (valor) { + setState(() => _modoMultimovil = valor.first); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + + // Categoría + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Categoría', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: DropdownButtonFormField( + initialValue: _categoria, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.category), + ), + items: categorias.map((c) { + return DropdownMenuItem( + value: c, + child: Text(BancoPalabras.nombreBonitoCategoria(c)), + ); + }).toList(), + onChanged: (v) => setState(() => _categoria = v!), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + + // Jugadores + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Jugadores (${_jugadores.length})', + style: Theme.of(context).textTheme.titleLarge), + Text('3-20', + style: Theme.of(context).textTheme.bodyMedium), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _controladorNombre, + decoration: const InputDecoration( + hintText: 'Nombre del jugador', + prefixIcon: Icon(Icons.person_add), + ), + textCapitalization: TextCapitalization.words, + onSubmitted: (_) => _agregarJugador(), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _agregarJugador, + icon: const Icon(Icons.add), + ), + ], + ), + const SizedBox(height: 8), + ..._jugadores.asMap().entries.map((e) { + return ListTile( + leading: CircleAvatar( + backgroundColor: TemaApp.colorTarjeta, + child: Text('${e.key + 1}', + style: + const TextStyle(color: TemaApp.colorTexto)), + ), + title: Text(e.value), + trailing: IconButton( + icon: const Icon(Icons.close, color: TemaApp.colorAcento), + onPressed: () => _eliminarJugador(e.key), + ), + dense: true, + ); + }), + ], + ), + ), + ), + const SizedBox(height: 12), + + // Configuración de partida + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Configuración', + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + + // Número de impostores + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('🎭 Impostores'), + Row( + children: [ + IconButton( + onPressed: _numImpostores > 1 + ? () => setState(() => _numImpostores--) + : null, + icon: const Icon(Icons.remove_circle_outline), + ), + Text('$_numImpostores', + style: Theme.of(context) + .textTheme + .titleLarge), + IconButton( + onPressed: _numImpostores < _maxImpostores + ? () => setState(() => _numImpostores++) + : null, + icon: const Icon(Icons.add_circle_outline), + ), + ], + ), + ], + ), + + // Pista para impostor + SwitchListTile( + title: const Text('🔍 Pista para impostor'), + subtitle: const Text( + 'El impostor conoce la categoría'), + value: _pistaImpostor, + onChanged: (v) => + setState(() => _pistaImpostor = v), + contentPadding: EdgeInsets.zero, + ), + + // Temporizador + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('⏱️ Tiempo de debate'), + DropdownButton( + value: _tiempoDebate, + items: List.generate( + _opcionesTiempo.length, + (i) => DropdownMenuItem( + value: _opcionesTiempo[i], + child: Text(_etiquetasTiempo[i]), + ), + ), + onChanged: (v) => + setState(() => _tiempoDebate = v), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Botón iniciar + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: _jugadores.length >= 3 ? _iniciarPartida : null, + icon: const Icon(Icons.play_arrow), + label: const Text('Iniciar partida'), + style: ElevatedButton.styleFrom( + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_debate.dart b/lib/pantallas/pantalla_debate.dart new file mode 100644 index 0000000..7517bd8 --- /dev/null +++ b/lib/pantallas/pantalla_debate.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_notas.dart'; +import 'pantalla_votacion.dart'; + +class PantallaDebate extends StatefulWidget { + const PantallaDebate({super.key}); + + @override + State createState() => _PantallaDebateState(); +} + +class _PantallaDebateState extends State { + Timer? _timer; + int _segundosRestantes = 0; + bool _tiempoAgotado = false; + + @override + void initState() { + super.initState(); + final estado = context.read(); + final tiempo = estado.partida?.config.tiempoDebateSegundos; + if (tiempo != null) { + _segundosRestantes = tiempo; + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_segundosRestantes > 0) { + setState(() => _segundosRestantes--); + } else { + timer.cancel(); + setState(() => _tiempoAgotado = true); + } + }); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + String _formatearTiempo(int segundos) { + final min = segundos ~/ 60; + final seg = segundos % 60; + return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}'; + } + + void _irAVotacion() { + final estado = context.read(); + estado.iniciarVotacion(); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const PantallaVotacion()), + ); + } + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + final tieneTemporizador = partida.config.tiempoDebateSegundos != null; + final progreso = tieneTemporizador + ? _segundosRestantes / partida.config.tiempoDebateSegundos! + : 0.0; + + return Scaffold( + appBar: AppBar( + title: Text('Debate - Ronda ${partida.rondaActual}'), + automaticallyImplyLeading: false, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Temporizador + if (tieneTemporizador) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _tiempoAgotado + ? TemaApp.colorAcento.withValues(alpha: 0.3) + : TemaApp.colorTarjeta, + borderRadius: BorderRadius.circular(16), + border: _tiempoAgotado + ? Border.all(color: TemaApp.colorAcento, width: 2) + : null, + ), + child: Column( + children: [ + Text( + _tiempoAgotado ? '⏰ ¡Tiempo agotado!' : '⏱️ Tiempo restante', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: _tiempoAgotado + ? TemaApp.colorAcento + : TemaApp.colorTextoSecundario, + ), + ), + const SizedBox(height: 8), + Text( + _formatearTiempo(_segundosRestantes), + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontSize: 48, + fontWeight: FontWeight.bold, + color: _segundosRestantes < 10 && !_tiempoAgotado + ? TemaApp.colorAcento + : TemaApp.colorTexto, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progreso, + backgroundColor: TemaApp.colorSuperficie, + valueColor: AlwaysStoppedAnimation( + _segundosRestantes < 10 + ? TemaApp.colorAcento + : TemaApp.colorVerde, + ), + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + + // Jugadores activos + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Jugadores en debate', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + '${partida.jugadoresActivos.length} activos • ${partida.impostoresActivos.length} impostor(es) ocultos', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Expanded( + child: ListView.builder( + itemCount: partida.jugadores.length, + itemBuilder: (context, index) { + final j = partida.jugadores[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: j.eliminado + ? Colors.grey + : TemaApp.colorAcento, + child: Text( + j.eliminado ? '💀' : '${index + 1}', + style: const TextStyle( + color: Colors.white, fontSize: 14), + ), + ), + title: Text( + j.nombre, + style: TextStyle( + decoration: j.eliminado + ? TextDecoration.lineThrough + : null, + color: j.eliminado + ? TemaApp.colorTextoSecundario + : TemaApp.colorTexto, + ), + ), + subtitle: j.eliminado + ? const Text('Eliminado') + : null, + dense: true, + ); + }, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 16), + + // Botones + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaNotas(), + ), + ); + }, + icon: const Text('📝', style: TextStyle(fontSize: 18)), + label: const Text('Notas'), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: _irAVotacion, + icon: const Text('🗳️', style: TextStyle(fontSize: 18)), + label: const Text('Ir a votación'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_fin_partida.dart b/lib/pantallas/pantalla_fin_partida.dart new file mode 100644 index 0000000..03d9a5a --- /dev/null +++ b/lib/pantallas/pantalla_fin_partida.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../modelos/palabra.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_principal.dart'; +import 'pantalla_ver_palabra.dart'; + +class PantallaFinPartida extends StatelessWidget { + const PantallaFinPartida({super.key}); + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + final ganaronJugadores = partida.ganador == 'jugadores'; + final impostores = + partida.jugadores.where((j) => j.esImpostor).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('Fin de partida'), + automaticallyImplyLeading: false, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // Ganador + Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: ganaronJugadores + ? [TemaApp.colorVerde.withValues(alpha: 0.3), TemaApp.colorVerde.withValues(alpha: 0.1)] + : [TemaApp.colorAcento.withValues(alpha: 0.3), TemaApp.colorAcento.withValues(alpha: 0.1)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + child: Column( + children: [ + Text( + ganaronJugadores ? '🎉' : '🎭', + style: const TextStyle(fontSize: 64), + ), + const SizedBox(height: 16), + Text( + ganaronJugadores + ? '¡Los jugadores ganan!' + : '¡Los impostores ganan!', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith( + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Palabra secreta + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Text('🔍 La palabra era:', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + partida.palabraSecreta, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith( + color: TemaApp.colorNaranja, + fontSize: 32, + ), + ), + const SizedBox(height: 4), + Text( + 'Categoría: ${BancoPalabras.nombreBonitoCategoria(partida.categoriaReal)}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Impostores + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Text( + '🎭 ${impostores.length == 1 ? 'El impostor era:' : 'Los impostores eran:'}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ...impostores.map((j) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('🎭 ', + style: const TextStyle(fontSize: 18)), + Text( + j.nombre, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: TemaApp.colorAcento), + ), + if (j.eliminado) ...[ + const SizedBox(width: 8), + const Text('💀', + style: TextStyle(fontSize: 16)), + ], + ], + ), + )), + ], + ), + ), + ), + const SizedBox(height: 16), + + // Estadísticas de votaciones + if (partida.historialVotaciones.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('📊 Historial de votaciones', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ...partida.historialVotaciones + .asMap() + .entries + .map((entrada) { + final ronda = entrada.key + 1; + final resultado = entrada.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ronda $ronda: ${resultado.eliminadoNombre} ${resultado.eraImpostor ? '🎭' : '😇'}', + style: TextStyle( + fontWeight: FontWeight.bold, + color: resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + ...resultado.votos.entries.map((v) { + final votante = partida.jugadores + .firstWhere((j) => j.id == v.key); + final votado = partida.jugadores + .firstWhere((j) => j.id == v.value); + return Text( + ' ${votante.nombre} → ${votado.nombre}', + style: Theme.of(context) + .textTheme + .bodyMedium, + ); + }), + ], + ), + ); + }), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Botones + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + estado.revancha(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaVerPalabra(), + ), + ); + }, + icon: const Icon(Icons.replay), + label: const Text('Revancha'), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () { + estado.limpiar(); + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const PantallaPrincipal(), + ), + (route) => false, + ); + }, + icon: const Icon(Icons.home), + label: const Text('Menú principal'), + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_notas.dart b/lib/pantallas/pantalla_notas.dart new file mode 100644 index 0000000..802322a --- /dev/null +++ b/lib/pantallas/pantalla_notas.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../servicios/servicio_notas.dart'; +import '../tema/tema_app.dart'; + +class PantallaNotas extends StatefulWidget { + const PantallaNotas({super.key}); + + @override + State createState() => _PantallaNotasState(); +} + +class _PantallaNotasState extends State { + String? _jugadorSeleccionadoId; + final Map _controladores = {}; + final _controladorNotaLibre = TextEditingController(); + bool _cargado = false; + + @override + void dispose() { + for (final c in _controladores.values) { + c.dispose(); + } + _controladorNotaLibre.dispose(); + super.dispose(); + } + + Future _cargarNotas(String jugadorId) async { + final datos = await ServicioNotas.cargarNotas(jugadorId); + final notas = datos['notas'] as Map; + final notaLibre = datos['notaLibre'] as String; + + for (final entrada in notas.entries) { + if (_controladores.containsKey(entrada.key)) { + _controladores[entrada.key]!.text = entrada.value; + } + } + _controladorNotaLibre.text = notaLibre; + setState(() => _cargado = true); + } + + Future _guardarNotas() async { + if (_jugadorSeleccionadoId == null) return; + final notas = {}; + for (final entrada in _controladores.entries) { + if (entrada.value.text.isNotEmpty) { + notas[entrada.key] = entrada.value.text; + } + } + await ServicioNotas.guardarNotas( + _jugadorSeleccionadoId!, + notas, + _controladorNotaLibre.text, + ); + } + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + // Inicializar controladores + for (final j in partida.jugadores) { + _controladores.putIfAbsent(j.id, () => TextEditingController()); + } + + return Scaffold( + appBar: AppBar( + title: const Text('📝 Notas'), + actions: [ + if (_jugadorSeleccionadoId != null) + IconButton( + icon: const Icon(Icons.save), + onPressed: () async { + await _guardarNotas(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Notas guardadas')), + ); + } + }, + ), + ], + ), + body: _jugadorSeleccionadoId == null + ? _construirSelectorJugador(partida) + : _construirNotas(partida), + ); + } + + Widget _construirSelectorJugador(dynamic partida) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '¿Quién eres?', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Selecciona tu nombre para ver tus notas privadas', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: partida.jugadoresActivos.length, + itemBuilder: (context, index) { + final j = partida.jugadoresActivos[index]; + return Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: TemaApp.colorAcento, + child: Text('${index + 1}', + style: const TextStyle(color: Colors.white)), + ), + title: Text(j.nombre), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () { + setState(() { + _jugadorSeleccionadoId = j.id; + _cargado = false; + }); + _cargarNotas(j.id); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _construirNotas(dynamic partida) { + if (!_cargado) { + return const Center(child: CircularProgressIndicator()); + } + + final jugadorActual = partida.jugadores + .firstWhere((j) => j.id == _jugadorSeleccionadoId); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + await _guardarNotas(); + setState(() { + _jugadorSeleccionadoId = null; + _cargado = false; + for (final c in _controladores.values) { + c.clear(); + } + _controladorNotaLibre.clear(); + }); + }, + ), + Text( + 'Notas de ${jugadorActual.nombre}', + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + const SizedBox(height: 16), + + // Notas por jugador + Text( + 'Apuntes sobre cada jugador', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ), + const SizedBox(height: 8), + ...partida.jugadoresActivos.where((j) => j.id != _jugadorSeleccionadoId).map((j) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextField( + controller: _controladores[j.id], + decoration: InputDecoration( + labelText: j.nombre, + prefixIcon: const Icon(Icons.person, size: 20), + hintText: '¿Qué ha dicho? ¿Sospechoso?', + ), + maxLines: 2, + minLines: 1, + ), + ); + }), + + const SizedBox(height: 16), + Text( + 'Nota libre', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _controladorNotaLibre, + decoration: const InputDecoration( + hintText: 'Apuntes personales...', + prefixIcon: Icon(Icons.note, size: 20), + ), + maxLines: 5, + minLines: 3, + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_principal.dart b/lib/pantallas/pantalla_principal.dart new file mode 100644 index 0000000..ab8955e --- /dev/null +++ b/lib/pantallas/pantalla_principal.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_crear_partida.dart'; +import 'pantalla_reglas.dart'; +import 'pantalla_unirse.dart'; + +class PantallaPrincipal extends StatelessWidget { + const PantallaPrincipal({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Logo + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const LinearGradient( + colors: [TemaApp.colorAcento, TemaApp.colorNaranja], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: TemaApp.colorAcento.withValues(alpha: 0.4), + blurRadius: 30, + spreadRadius: 5, + ), + ], + ), + child: const Center( + child: Text( + '🎭', + style: TextStyle(fontSize: 56), + ), + ), + ), + const SizedBox(height: 24), + + // Título + Text( + 'El Impostor', + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontSize: 36, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 8), + Text( + 'Juego de deducción social', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 16, + ), + ), + const SizedBox(height: 48), + + // Botones + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaCrearPartida(), + ), + ); + }, + icon: const Text('🎮', style: TextStyle(fontSize: 20)), + label: const Text('Crear partida'), + ), + ), + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaUnirse(), + ), + ); + }, + icon: const Text('📱', style: TextStyle(fontSize: 20)), + label: const Text('Unirse a partida'), + ), + ), + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const PantallaReglas(), + ), + ); + }, + icon: const Text('📖', style: TextStyle(fontSize: 20)), + label: const Text('Cómo jugar'), + ), + ), + const SizedBox(height: 48), + + Text( + '3-20 jugadores • Sin internet', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 12, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_reglas.dart b/lib/pantallas/pantalla_reglas.dart new file mode 100644 index 0000000..7444fb1 --- /dev/null +++ b/lib/pantallas/pantalla_reglas.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import '../tema/tema_app.dart'; + +class PantallaReglas extends StatelessWidget { + const PantallaReglas({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('📖 Cómo jugar')), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _seccion( + context, + '🎭 ¿Qué es El Impostor?', + 'Un juego de deducción social para 3-20 jugadores. ' + 'Todos reciben una palabra secreta... ¡excepto el impostor! ' + 'Tu misión: descubrir quién finge.', + ), + _seccion( + context, + '🔍 ¿Cómo se juega?', + '1. Se reparten los roles: todos reciben la misma palabra, ' + 'excepto el/los impostores.\n\n' + '2. Debate: por turnos, cada jugador describe la palabra ' + 'SIN decirla directamente. El impostor debe fingir que la conoce.\n\n' + '3. Votación: al terminar el debate, todos votan a quién ' + 'creen que es el impostor.\n\n' + '4. Eliminación: el más votado queda eliminado y se revela ' + 'si era impostor o no.\n\n' + '5. Si era impostor, puede intentar adivinar la palabra. ' + 'Si acierta, ¡los impostores ganan!', + ), + _seccion( + context, + '🏆 ¿Quién gana?', + '• Jugadores: ganan si eliminan a TODOS los impostores.\n' + '• Impostores: ganan si no son descubiertos hasta que ' + 'queden igual o menos jugadores normales que impostores, ' + 'o si adivinan la palabra al ser eliminados.', + ), + _seccion( + context, + '💡 Consejos para jugadores', + '• Da pistas sutiles que demuestren que conoces la palabra, ' + 'pero no tan obvias que el impostor las use.\n' + '• Observa quién da respuestas vagas o genéricas.\n' + '• Usa las notas para apuntar lo que dice cada uno.\n' + '• No digas la palabra directamente, ¡eso ayuda al impostor!', + ), + _seccion( + context, + '🎭 Consejos para el impostor', + '• Escucha atentamente las pistas de los demás.\n' + '• Intenta deducir la palabra para dar pistas creíbles.\n' + '• No seas el primero en hablar si no estás seguro.\n' + '• Si te dan la categoría como pista, úsala a tu favor.\n' + '• Acusa a otros para desviar la atención.', + ), + _seccion( + context, + '📱 Modos de juego', + '• Un solo móvil: todos comparten el dispositivo. ' + 'Cada jugador ve su palabra pulsando y manteniendo un botón.\n\n' + '• Multimóvil: cada jugador usa su propio dispositivo. ' + 'Se conectan por Bluetooth/WiFi Direct sin necesidad de internet.', + ), + _ejemplo( + context, + '✏️ Ejemplo de partida', + 'Palabra secreta: "Pizza"\n\n' + '• Ana: "Se come caliente" ✓\n' + '• Carlos: "Viene en una caja" ✓\n' + '• Eva (impostor): "Es muy popular" 🤔\n' + '• David: "Tiene queso" ✓\n\n' + 'Eva dio una respuesta muy genérica... ¡Sospechosa!', + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } + + Widget _seccion(BuildContext context, String titulo, String contenido) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titulo, + style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text(contenido, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + )), + ], + ), + ), + ), + ); + } + + Widget _ejemplo(BuildContext context, String titulo, String contenido) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + color: TemaApp.colorNaranja.withValues(alpha: 0.15), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(titulo, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: TemaApp.colorNaranja, + )), + const SizedBox(height: 8), + Text(contenido, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + height: 1.5, + )), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_resultado.dart b/lib/pantallas/pantalla_resultado.dart new file mode 100644 index 0000000..754df77 --- /dev/null +++ b/lib/pantallas/pantalla_resultado.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../modelos/partida.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_adivinanza.dart'; +import 'pantalla_debate.dart'; +import 'pantalla_fin_partida.dart'; + +class PantallaResultado extends StatefulWidget { + final ResultadoVotacion resultado; + + const PantallaResultado({super.key, required this.resultado}); + + @override + State createState() => _PantallaResultadoState(); +} + +class _PantallaResultadoState extends State + with SingleTickerProviderStateMixin { + bool _revelado = false; + late AnimationController _animController; + late Animation _animOpacidad; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + duration: const Duration(milliseconds: 2500), + vsync: this, + ); + _animOpacidad = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animController, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ); + + // Iniciar animación de suspense + Future.delayed(const Duration(milliseconds: 500), () { + _animController.forward().then((_) { + setState(() => _revelado = true); + }); + }); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final estado = context.read(); + final partida = estado.partida; + + return Scaffold( + appBar: AppBar( + title: const Text('Resultado'), + automaticallyImplyLeading: false, + ), + body: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Animación de suspense + if (!_revelado) ...[ + const Text('🥁', style: TextStyle(fontSize: 64)), + const SizedBox(height: 16), + Text( + 'Revelando...', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 24), + const CircularProgressIndicator(color: TemaApp.colorAcento), + ], + + if (_revelado) ...[ + // Resultado revelado + FadeTransition( + opacity: _animOpacidad, + child: Column( + children: [ + Text( + widget.resultado.eraImpostor ? '🎭' : '😇', + style: const TextStyle(fontSize: 72), + ), + const SizedBox(height: 16), + Text( + widget.resultado.eliminadoNombre, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith(fontSize: 32), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: widget.resultado.eraImpostor + ? TemaApp.colorVerde.withValues(alpha: 0.3) + : TemaApp.colorAcento.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(30), + border: Border.all( + color: widget.resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + child: Text( + widget.resultado.eraImpostor + ? '¡Era IMPOSTOR! 🎉' + : 'Era INOCENTE 😱', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: widget.resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + ), + const SizedBox(height: 24), + + // Detalle de votos + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Votos de esta ronda', + style: Theme.of(context) + .textTheme + .titleMedium), + const SizedBox(height: 8), + ...widget.resultado.votos.entries.map((e) { + final votante = partida?.jugadores + .firstWhere((j) => j.id == e.key); + final votado = partida?.jugadores + .firstWhere((j) => j.id == e.value); + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 2), + child: Text( + '${votante?.nombre ?? '?'} → ${votado?.nombre ?? '?'}', + style: TextStyle( + color: e.value == + widget.resultado.eliminadoId + ? TemaApp.colorAcento + : TemaApp.colorTextoSecundario, + ), + ), + ); + }), + ], + ), + ), + ), + const SizedBox(height: 24), + + // Acciones + _construirBotones(context, estado), + ], + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _construirBotones(BuildContext context, EstadoJuego estado) { + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + // Comprobar si la partida terminó + final finPartida = estado.comprobarFinPartida(); + + if (finPartida) { + return SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const PantallaFinPartida()), + ); + }, + icon: const Icon(Icons.emoji_events), + label: const Text('Ver resultado final'), + ), + ); + } + + // Si era impostor, puede intentar adivinar + if (widget.resultado.eraImpostor) { + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaAdivinanza(), + ), + ); + }, + icon: const Text('🎯', style: TextStyle(fontSize: 18)), + label: const Text('¿El impostor adivina la palabra?'), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _siguienteRonda(context, estado), + icon: const Icon(Icons.skip_next), + label: const Text('Siguiente ronda'), + ), + ), + ], + ); + } + + return SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () => _siguienteRonda(context, estado), + icon: const Icon(Icons.skip_next), + label: const Text('Siguiente ronda'), + ), + ); + } + + void _siguienteRonda(BuildContext context, EstadoJuego estado) { + estado.siguienteRonda(); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const PantallaDebate()), + ); + } +} diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart new file mode 100644 index 0000000..151b514 --- /dev/null +++ b/lib/pantallas/pantalla_unirse.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../tema/tema_app.dart'; + +class PantallaUnirse extends StatelessWidget { + const PantallaUnirse({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Unirse a partida')), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📱', style: TextStyle(fontSize: 64)), + const SizedBox(height: 24), + Text( + 'Modo multimóvil', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + 'Escanea el código QR que muestra el host ' + 'para conectarte a la partida vía Bluetooth/WiFi Direct.', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: TemaApp.colorNaranja.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: TemaApp.colorNaranja.withValues(alpha: 0.5)), + ), + child: Column( + children: [ + const Text('🚧', style: TextStyle(fontSize: 32)), + const SizedBox(height: 8), + Text( + 'Próximamente', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(height: 8), + Text( + 'La conexión multimóvil con Nearby Connections ' + 'requiere dispositivos Android físicos.\n\n' + 'Por ahora, usa el modo "Un solo móvil" ' + 'para jugar en un dispositivo compartido.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back), + label: const Text('Volver'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_ver_palabra.dart b/lib/pantallas/pantalla_ver_palabra.dart new file mode 100644 index 0000000..ca0fa95 --- /dev/null +++ b/lib/pantallas/pantalla_ver_palabra.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../modelos/palabra.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_debate.dart'; + +class PantallaVerPalabra extends StatefulWidget { + const PantallaVerPalabra({super.key}); + + @override + State createState() => _PantallaVerPalabraState(); +} + +class _PantallaVerPalabraState extends State { + final Set _hanVisto = {}; + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + final todosHanVisto = + partida.jugadores.every((j) => _hanVisto.contains(j.id)); + + return Scaffold( + appBar: AppBar( + title: const Text('Ver tu palabra'), + automaticallyImplyLeading: false, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Text( + 'Cada jugador debe ver su palabra en secreto', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Ronda ${partida.rondaActual}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: partida.jugadores.length, + itemBuilder: (context, index) { + final jugador = partida.jugadores[index]; + final haVisto = _hanVisto.contains(jugador.id); + return Card( + color: haVisto + ? TemaApp.colorVerde.withValues(alpha: 0.2) + : TemaApp.colorTarjeta, + child: ListTile( + leading: CircleAvatar( + backgroundColor: haVisto + ? TemaApp.colorVerde + : TemaApp.colorAcento, + child: Text( + haVisto ? '✓' : '${index + 1}', + style: + const TextStyle(color: Colors.white), + ), + ), + title: Text(jugador.nombre), + subtitle: Text( + haVisto ? 'Ya ha visto su palabra' : 'Pulsa para ver', + ), + trailing: haVisto + ? const Icon(Icons.check_circle, + color: TemaApp.colorVerde) + : const Icon(Icons.visibility, + color: TemaApp.colorTextoSecundario), + onTap: haVisto + ? null + : () => _mostrarPalabra(context, jugador.id), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: todosHanVisto + ? () { + estado.iniciarDebate(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const PantallaDebate(), + ), + ); + } + : null, + icon: const Icon(Icons.forum), + label: Text(todosHanVisto + ? 'Todos han visto → Iniciar debate' + : 'Faltan ${partida.jugadores.length - _hanVisto.length} jugadores'), + ), + ), + ], + ), + ), + ); + } + + void _mostrarPalabra(BuildContext context, String jugadorId) { + final estado = context.read(); + final partida = estado.partida!; + final jugador = partida.jugadores.firstWhere((j) => j.id == jugadorId); + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => _PantallaRevelarPalabra( + nombre: jugador.nombre, + esImpostor: jugador.esImpostor, + palabra: partida.palabraSecreta, + pistaActiva: partida.config.pistaImpostor, + categoria: partida.categoriaReal, + onVisto: () { + setState(() => _hanVisto.add(jugadorId)); + }, + ), + ), + ); + } +} + +class _PantallaRevelarPalabra extends StatefulWidget { + final String nombre; + final bool esImpostor; + final String palabra; + final bool pistaActiva; + final String categoria; + final VoidCallback onVisto; + + const _PantallaRevelarPalabra({ + required this.nombre, + required this.esImpostor, + required this.palabra, + required this.pistaActiva, + required this.categoria, + required this.onVisto, + }); + + @override + State<_PantallaRevelarPalabra> createState() => + _PantallaRevelarPalabraState(); +} + +class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> { + bool _manteniendo = false; + bool _visto = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.nombre)), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + widget.nombre, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 32), + + // Zona de revelación + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: _manteniendo + ? (widget.esImpostor + ? TemaApp.colorAcento.withValues(alpha: 0.3) + : TemaApp.colorVerde.withValues(alpha: 0.3)) + : TemaApp.colorTarjeta, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: _manteniendo + ? (widget.esImpostor + ? TemaApp.colorAcento + : TemaApp.colorVerde) + : Colors.transparent, + width: 2, + ), + ), + child: _manteniendo + ? Column( + children: [ + Text( + widget.esImpostor ? '🎭' : '🔍', + style: const TextStyle(fontSize: 48), + ), + const SizedBox(height: 16), + Text( + widget.esImpostor + ? '¡Eres el impostor!' + : 'Tu palabra es:', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith( + color: widget.esImpostor + ? TemaApp.colorAcento + : TemaApp.colorVerde, + ), + ), + if (!widget.esImpostor) ...[ + const SizedBox(height: 12), + Text( + widget.palabra, + style: Theme.of(context) + .textTheme + .headlineLarge + ?.copyWith( + fontSize: 32, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ], + if (widget.esImpostor && widget.pistaActiva) ...[ + const SizedBox(height: 12), + Text( + 'Pista: ${BancoPalabras.nombreBonitoCategoria(widget.categoria)}', + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + ], + ], + ) + : Column( + children: [ + const Text('🔒', style: TextStyle(fontSize: 48)), + const SizedBox(height: 16), + Text( + 'Mantén pulsado para ver tu palabra', + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Asegúrate de que nadie más mira', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Botón mantener pulsado + GestureDetector( + onLongPressStart: (_) { + setState(() { + _manteniendo = true; + _visto = true; + }); + }, + onLongPressEnd: (_) { + setState(() => _manteniendo = false); + }, + child: Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: _manteniendo + ? [TemaApp.colorNaranja, TemaApp.colorAcento] + : [TemaApp.colorAcento, TemaApp.colorAcento], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + _manteniendo + ? '👁️ Mostrando...' + : '👆 Mantén pulsado para ver', + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + const SizedBox(height: 24), + + if (_visto) + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + widget.onVisto(); + Navigator.pop(context); + }, + icon: const Icon(Icons.check), + label: const Text('He visto mi palabra'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_votacion.dart b/lib/pantallas/pantalla_votacion.dart new file mode 100644 index 0000000..26b5188 --- /dev/null +++ b/lib/pantallas/pantalla_votacion.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_juego.dart'; +import '../tema/tema_app.dart'; +import 'pantalla_resultado.dart'; + +class PantallaVotacion extends StatefulWidget { + const PantallaVotacion({super.key}); + + @override + State createState() => _PantallaVotacionState(); +} + +class _PantallaVotacionState extends State { + String? _seleccionado; + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final partida = estado.partida; + if (partida == null) return const SizedBox.shrink(); + + final activos = partida.jugadoresActivos; + final todosVotaron = estado.todosHanVotado(); + + // Modo un solo móvil + if (!partida.config.modoMultimovil) { + return _construirVotacionUnMovil(context, estado, partida, activos, todosVotaron); + } + + // Modo multimóvil sería similar pero controlado por Nearby + return _construirVotacionUnMovil(context, estado, partida, activos, todosVotaron); + } + + Widget _construirVotacionUnMovil( + BuildContext context, + EstadoJuego estado, + partida, + List activos, + bool todosVotaron, + ) { + // Encontrar el siguiente votante que no haya votado + final sinVotar = activos + .where((j) => !estado.votos.containsKey(j.id)) + .toList(); + + if (todosVotaron) { + return _construirTodosVotaron(context, estado); + } + + final votanteActual = sinVotar.isNotEmpty ? sinVotar[0] : activos[0]; + final puedenRecibir = activos.where((j) => j.id != votanteActual.id).toList(); + + return Scaffold( + appBar: AppBar( + title: const Text('🗳️ Votación'), + automaticallyImplyLeading: false, + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Progreso de votos + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: TemaApp.colorTarjeta, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Text( + 'Turno de votar:', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + votanteActual.nombre, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(height: 8), + Text( + 'Votos: ${estado.votos.length}/${activos.length}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: estado.votos.length / activos.length, + backgroundColor: TemaApp.colorSuperficie, + valueColor: const AlwaysStoppedAnimation(TemaApp.colorAcento), + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + Text( + '¿Quién crees que es el impostor?', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // Lista de candidatos + Expanded( + child: ListView.builder( + itemCount: puedenRecibir.length, + itemBuilder: (context, index) { + final candidato = puedenRecibir[index]; + final seleccionado = _seleccionado == candidato.id; + return Card( + color: seleccionado + ? TemaApp.colorAcento.withValues(alpha: 0.3) + : TemaApp.colorTarjeta, + child: ListTile( + leading: CircleAvatar( + backgroundColor: seleccionado + ? TemaApp.colorAcento + : TemaApp.colorSuperficie, + child: Text('${index + 1}', + style: const TextStyle(color: Colors.white)), + ), + title: Text(candidato.nombre), + trailing: seleccionado + ? const Icon(Icons.check_circle, + color: TemaApp.colorAcento) + : const Icon(Icons.radio_button_unchecked), + onTap: () { + setState(() => _seleccionado = candidato.id); + }, + ), + ); + }, + ), + ), + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: _seleccionado != null + ? () { + estado.registrarVoto( + votanteActual.id, _seleccionado!); + setState(() { + _seleccionado = null; + }); + } + : null, + icon: const Icon(Icons.how_to_vote), + label: const Text('Confirmar voto'), + ), + ), + ], + ), + ), + ); + } + + Widget _construirTodosVotaron(BuildContext context, EstadoJuego estado) { + return Scaffold( + appBar: AppBar( + title: const Text('🗳️ Votación completa'), + automaticallyImplyLeading: false, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🗳️', style: TextStyle(fontSize: 64)), + const SizedBox(height: 24), + Text( + '¡Todos han votado!', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 16), + Text( + 'Pulsa para revelar el resultado', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + final resultado = estado.procesarVotacion(); + if (resultado != null) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => + PantallaResultado(resultado: resultado), + ), + ); + } + }, + icon: const Icon(Icons.visibility), + label: const Text('Revelar resultado'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart new file mode 100644 index 0000000..c347414 --- /dev/null +++ b/lib/servicios/servicio_nearby.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +/// Tipos de mensajes en el protocolo P2P +enum TipoMensaje { + salaInfo, + partidaInicio, + fase, + votacionResultado, + partidaFin, + unirse, + voto, + listo, +} + +/// Mensaje del protocolo P2P entre dispositivos +class MensajeP2P { + final TipoMensaje tipo; + final Map datos; + + MensajeP2P({required this.tipo, required this.datos}); + + String toJson() => json.encode({ + 'tipo': tipo.name, + 'datos': datos, + }); + + factory MensajeP2P.fromJson(String jsonStr) { + final mapa = json.decode(jsonStr) as Map; + return MensajeP2P( + tipo: TipoMensaje.values.firstWhere((t) => t.name == mapa['tipo']), + datos: mapa['datos'] as Map, + ); + } +} + +/// Servicio para conexiones P2P usando Google Nearby Connections API. +/// +/// Este servicio encapsula toda la lógica de Nearby Connections. +/// Requiere dispositivos Android físicos para funcionar. +/// En la versión actual, se provee la estructura para integración futura. +class ServicioNearby extends ChangeNotifier { + bool _esHost = false; + bool _conectado = false; + String? _endpointId; + final List _dispositivos = []; + + bool get esHost => _esHost; + bool get conectado => _conectado; + String? get endpointId => _endpointId; + List get dispositivos => List.unmodifiable(_dispositivos); + + /// Inicia como host (anunciando el endpoint) + Future iniciarHost(String nombreSala) async { + // Nota: nearby_connections requiere permisos de ubicación y Bluetooth + // que deben solicitarse antes de iniciar. + // Implementación con el paquete nearby_connections: + // + // try { + // await Nearby().startAdvertising( + // nombreSala, + // Strategy.P2P_STAR, + // onConnectionInitiated: _onConexionIniciada, + // onConnectionResult: _onResultadoConexion, + // onDisconnected: _onDesconexion, + // serviceId: 'es.freetimelab.elimpostor', + // ); + // _esHost = true; + // _endpointId = nombreSala; + // notifyListeners(); + // return true; + // } catch (e) { + // debugPrint('Error iniciando host: $e'); + // return false; + // } + + _esHost = true; + _endpointId = nombreSala; + notifyListeners(); + return true; + } + + /// Conecta a un host escaneado via QR + Future conectarAHost(String endpointId, String nombre) async { + // Implementación con el paquete nearby_connections: + // + // try { + // await Nearby().startDiscovery( + // nombre, + // Strategy.P2P_STAR, + // onEndpointFound: (id, name, serviceId) { + // Nearby().requestConnection(nombre, id, + // onConnectionInitiated: _onConexionIniciada, + // onConnectionResult: _onResultadoConexion, + // onDisconnected: _onDesconexion, + // ); + // }, + // onEndpointLost: (id) {}, + // serviceId: 'es.freetimelab.elimpostor', + // ); + // return true; + // } catch (e) { + // debugPrint('Error conectando: $e'); + // return false; + // } + + _conectado = true; + notifyListeners(); + return true; + } + + /// Envía un mensaje a un dispositivo específico + Future enviarMensaje(String endpointId, MensajeP2P mensaje) async { + // Implementación: + // final bytes = Uint8List.fromList(utf8.encode(mensaje.toJson())); + // await Nearby().sendBytesPayload(endpointId, bytes); + debugPrint('Enviar a $endpointId: ${mensaje.toJson()}'); + } + + /// Envía un mensaje a todos los dispositivos conectados + Future enviarATodos(MensajeP2P mensaje) async { + for (final id in _dispositivos) { + await enviarMensaje(id, mensaje); + } + } + + /// Desconecta y limpia + Future desconectar() async { + // await Nearby().stopAllEndpoints(); + // await Nearby().stopAdvertising(); + // await Nearby().stopDiscovery(); + _esHost = false; + _conectado = false; + _endpointId = null; + _dispositivos.clear(); + notifyListeners(); + } + + /// Genera los datos para el código QR de conexión + String generarDatosQR(String nombreSala) { + return json.encode({ + 'app': 'elimpostor', + 'endpoint': _endpointId, + 'sala': nombreSala, + }); + } +} diff --git a/lib/servicios/servicio_notas.dart b/lib/servicios/servicio_notas.dart new file mode 100644 index 0000000..9c9be24 --- /dev/null +++ b/lib/servicios/servicio_notas.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Servicio para persistir las notas de los jugadores localmente +class ServicioNotas { + static const _clavePrefix = 'notas_'; + + /// Guarda las notas de un jugador para una partida + static Future guardarNotas( + String jugadorId, + Map notas, + String notaLibre, + ) async { + final prefs = await SharedPreferences.getInstance(); + final datos = { + 'notas': notas, + 'notaLibre': notaLibre, + }; + await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos)); + } + + /// Carga las notas de un jugador + static Future> cargarNotas(String jugadorId) async { + final prefs = await SharedPreferences.getInstance(); + final str = prefs.getString('$_clavePrefix$jugadorId'); + if (str == null) { + return {'notas': {}, 'notaLibre': ''}; + } + final datos = json.decode(str) as Map; + return { + 'notas': Map.from(datos['notas'] ?? {}), + 'notaLibre': datos['notaLibre'] ?? '', + }; + } + + /// Limpia todas las notas (al iniciar nueva partida) + static Future 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); + } + } +} diff --git a/lib/tema/tema_app.dart b/lib/tema/tema_app.dart new file mode 100644 index 0000000..25bd18d --- /dev/null +++ b/lib/tema/tema_app.dart @@ -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; + }), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..2ce1785 --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..e43701a --- /dev/null +++ b/pubspec.yaml @@ -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 diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..2e5ab14 --- /dev/null +++ b/test/widget_test.dart @@ -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); + }); +}