From 23472707ad7a6817c236b8cdc704d853a76278c0 Mon Sep 17 00:00:00 2001 From: ShanaiaBot Date: Sat, 4 Apr 2026 03:09:51 +0200 Subject: [PATCH] feat: modo multidispositivo con Nearby Connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ServicioNearby completo: P2P_STAR, auto-accept, protocolo mensajes - PantallaLobbyHost: QR code + lista jugadores tiempo real - PantallaUnirse: escaneo QR + conexión + sala espera - Protocolo MensajeP2P: salaInfo, partidaInicio, fase, voto, resultado, fin - Manejo desconexiones jugador/host - l10n: nuevas keys es/en - Version bump 1.1.0+5 --- lib/l10n/app_en.arb | 121 ++++- lib/l10n/app_es.arb | 99 ++-- lib/l10n/generated/app_localizations.dart | 78 +++ lib/l10n/generated/app_localizations_ar.dart | 41 ++ lib/l10n/generated/app_localizations_ca.dart | 41 ++ lib/l10n/generated/app_localizations_de.dart | 41 ++ lib/l10n/generated/app_localizations_en.dart | 41 ++ lib/l10n/generated/app_localizations_es.dart | 41 ++ lib/l10n/generated/app_localizations_eu.dart | 41 ++ lib/l10n/generated/app_localizations_fr.dart | 41 ++ lib/l10n/generated/app_localizations_hi.dart | 41 ++ lib/l10n/generated/app_localizations_it.dart | 41 ++ lib/l10n/generated/app_localizations_ja.dart | 41 ++ lib/l10n/generated/app_localizations_ko.dart | 41 ++ lib/l10n/generated/app_localizations_nl.dart | 41 ++ lib/l10n/generated/app_localizations_pl.dart | 41 ++ lib/l10n/generated/app_localizations_pt.dart | 41 ++ lib/l10n/generated/app_localizations_ru.dart | 41 ++ lib/l10n/generated/app_localizations_tr.dart | 41 ++ lib/l10n/generated/app_localizations_zh.dart | 41 ++ lib/main.dart | 4 + lib/pantallas/pantalla_lobby_host.dart | 209 ++++++++ lib/pantallas/pantalla_unirse.dart | 281 +++++++++-- lib/servicios/servicio_nearby.dart | 473 ++++++++++++++++--- pubspec.yaml | 2 +- 25 files changed, 1799 insertions(+), 165 deletions(-) create mode 100644 lib/pantallas/pantalla_lobby_host.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5b889e7..601edd4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -24,7 +24,13 @@ "categoryMusic": "Music", "categoryTechnology": "Technology", "playersCount": "Players ({count})", - "@playersCount": {"placeholders": {"count": {"type": "int"}}}, + "@playersCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "playersRangeHint": "3-20", "playerNameHint": "Player name", "playerAlreadyExists": "A player with that name already exists", @@ -44,35 +50,77 @@ "seeYourWord": "See your word", "eachPlayerMustSee": "Each player must see their word in secret", "roundNumber": "Round {round}", - "@roundNumber": {"placeholders": {"round": {"type": "int"}}}, + "@roundNumber": { + "placeholders": { + "round": { + "type": "int" + } + } + }, "alreadySeen": "Already seen their word", "tapToSee": "Tap to see", "allSeenStartDebate": "Everyone has seen → Start discussion", "playersRemaining": "{count} players remaining", - "@playersRemaining": {"placeholders": {"count": {"type": "int"}}}, + "@playersRemaining": { + "placeholders": { + "count": { + "type": "int" + } + } + }, "youAreImpostor": "You are the impostor!", "yourWordIs": "Your word is:", "clueCategory": "Clue: {category}", - "@clueCategory": {"placeholders": {"category": {"type": "String"}}}, + "@clueCategory": { + "placeholders": { + "category": { + "type": "String" + } + } + }, "holdToSeeWord": "Hold to see your word", "makeSureNoOneLooks": "Make sure no one else is looking", "showingWord": "👁️ Showing...", "holdToSee": "👆 Hold to see", "seenMyWord": "I've seen my word", "debateRound": "Discussion - Round {round}", - "@debateRound": {"placeholders": {"round": {"type": "int"}}}, + "@debateRound": { + "placeholders": { + "round": { + "type": "int" + } + } + }, "timeUp": "⏰ Time's up!", "timeRemaining": "⏱️ Time remaining", "playersInDebate": "Players in discussion", "activePlayersInfo": "{active} active • {impostors} hidden impostor(s)", - "@activePlayersInfo": {"placeholders": {"active": {"type": "int"}, "impostors": {"type": "int"}}}, + "@activePlayersInfo": { + "placeholders": { + "active": { + "type": "int" + }, + "impostors": { + "type": "int" + } + } + }, "eliminated": "Eliminated", "notes": "Notes", "goToVoting": "Go to voting", "voting": "🗳️ Voting", "turnToVote": "Your turn to vote:", "votesProgress": "Votes: {current}/{total}", - "@votesProgress": {"placeholders": {"current": {"type": "int"}, "total": {"type": "int"}}}, + "@votesProgress": { + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, "whoIsImpostor": "Who do you think is the impostor?", "confirmVote": "Confirm vote", "votingComplete": "🗳️ Voting complete", @@ -95,7 +143,13 @@ "guess": "Guess", "correctGuess": "Correct guess!", "theWordWas": "The word was: {word}", - "@theWordWas": {"placeholders": {"word": {"type": "String"}}}, + "@theWordWas": { + "placeholders": { + "word": { + "type": "String" + } + } + }, "impostorsWin": "The impostors win!", "wrongGuess": "Wrong guess!", "gameContinues": "The game continues...", @@ -103,12 +157,27 @@ "playersWin": "The players win!", "theSecretWordWas": "🔍 The word was:", "categoryLabel": "Category: {category}", - "@categoryLabel": {"placeholders": {"category": {"type": "String"}}}, + "@categoryLabel": { + "placeholders": { + "category": { + "type": "String" + } + } + }, "theImpostorWas": "🎭 The impostor was:", "theImpostorsWere": "🎭 The impostors were:", "votingHistory": "📊 Voting history", "roundElimination": "Round {round}: {name}", - "@roundElimination": {"placeholders": {"round": {"type": "int"}, "name": {"type": "String"}}}, + "@roundElimination": { + "placeholders": { + "round": { + "type": "int" + }, + "name": { + "type": "String" + } + } + }, "rematch": "Rematch", "mainMenu": "Main menu", "notesTitle": "📝 Notes", @@ -116,7 +185,13 @@ "whoAreYou": "Who are you?", "selectYourName": "Select your name to view your private notes", "notesOf": "{name}'s notes", - "@notesOf": {"placeholders": {"name": {"type": "String"}}}, + "@notesOf": { + "placeholders": { + "name": { + "type": "String" + } + } + }, "notesAboutPlayers": "Notes about each player", "playerNoteHint": "What did they say? Suspicious?", "freeNote": "Free note", @@ -154,5 +229,25 @@ "about": "About", "version": "Version", "developer": "Developer", - "licenses": "Licenses" -} + "licenses": "Licenses", + "scanToJoin": "Scan QR to join", + "connectedPlayers": "Connected players", + "waitingForPlayers": "Waiting for players...", + "needMorePlayers": "Need {count} more players", + "@needMorePlayers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "starting": "Starting...", + "enterNameAndScan": "Enter your name and scan the host's QR", + "yourName": "Your name", + "nameRequired": "Enter your name", + "connectingTo": "Connecting to", + "scanQR": "Scan QR", + "scanHostQR": "Point at the host's QR code", + "connectedWaiting": "Connected!", + "waitingForHost": "Waiting for the host to start the game..." +} \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e5e3b39..4f2195a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1,20 +1,16 @@ { "@@locale": "es", - "appTitle": "Farolero", "subtitle": "Juego de deducción social", "loadingWords": "Cargando palabras...", "playersRange": "3-20 jugadores • Sin internet", - "createGame": "Crear partida", "joinGame": "Unirse a partida", "howToPlay": "Cómo jugar", "settings": "Ajustes", - "gameMode": "Modo de juego", "singleDevice": "Un solo móvil", "multiDevice": "Multimóvil", - "category": "Categoría", "categoryAll": "Todas", "categoryAnimals": "Animales", @@ -27,11 +23,12 @@ "categoryMovies": "Películas", "categoryMusic": "Música", "categoryTechnology": "Tecnología", - "playersCount": "Jugadores ({count})", "@playersCount": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, "playersRangeHint": "3-20", @@ -39,7 +36,6 @@ "playerAlreadyExists": "Ya existe un jugador con ese nombre", "maxPlayersReached": "Máximo 20 jugadores", "minPlayersRequired": "Se necesitan al menos 3 jugadores", - "configuration": "Configuración", "impostors": "🎭 Impostores", "impostorClue": "🔍 Pista para impostor", @@ -50,15 +46,15 @@ "twoMin": "2 min", "threeMin": "3 min", "fiveMin": "5 min", - "startGame": "Iniciar partida", - "seeYourWord": "Ver tu palabra", "eachPlayerMustSee": "Cada jugador debe ver su palabra en secreto", "roundNumber": "Ronda {round}", "@roundNumber": { "placeholders": { - "round": {"type": "int"} + "round": { + "type": "int" + } } }, "alreadySeen": "Ya ha visto su palabra", @@ -67,16 +63,19 @@ "playersRemaining": "Faltan {count} jugadores", "@playersRemaining": { "placeholders": { - "count": {"type": "int"} + "count": { + "type": "int" + } } }, - "youAreImpostor": "¡Eres el impostor!", "yourWordIs": "Tu palabra es:", "clueCategory": "Pista: {category}", "@clueCategory": { "placeholders": { - "category": {"type": "String"} + "category": { + "type": "String" + } } }, "holdToSeeWord": "Mantén pulsado para ver tu palabra", @@ -84,11 +83,12 @@ "showingWord": "👁️ Mostrando...", "holdToSee": "👆 Mantén pulsado para ver", "seenMyWord": "He visto mi palabra", - "debateRound": "Debate - Ronda {round}", "@debateRound": { "placeholders": { - "round": {"type": "int"} + "round": { + "type": "int" + } } }, "timeUp": "⏰ ¡Tiempo agotado!", @@ -97,21 +97,28 @@ "activePlayersInfo": "{active} activos • {impostors} impostor(es) ocultos", "@activePlayersInfo": { "placeholders": { - "active": {"type": "int"}, - "impostors": {"type": "int"} + "active": { + "type": "int" + }, + "impostors": { + "type": "int" + } } }, "eliminated": "Eliminado", "notes": "Notas", "goToVoting": "Ir a votación", - "voting": "🗳️ Votación", "turnToVote": "Turno de votar:", "votesProgress": "Votos: {current}/{total}", "@votesProgress": { "placeholders": { - "current": {"type": "int"}, - "total": {"type": "int"} + "current": { + "type": "int" + }, + "total": { + "type": "int" + } } }, "whoIsImpostor": "¿Quién crees que es el impostor?", @@ -120,7 +127,6 @@ "allVoted": "¡Todos han votado!", "tapToReveal": "Pulsa para revelar el resultado", "revealResult": "Revelar resultado", - "result": "Resultado", "revealing": "Revelando...", "wasImpostor": "¡Era IMPOSTOR! 🎉", @@ -129,7 +135,6 @@ "seeEndResult": "Ver resultado final", "impostorGuessWord": "¿El impostor adivina la palabra?", "nextRound": "Siguiente ronda", - "impostorGuessTitle": "🎯 Adivinanza del impostor", "impostorCanGuess": "El impostor eliminado puede\nintentar adivinar la palabra", "ifCorrectImpostorsWin": "Si acierta, ¡los impostores ganan!", @@ -140,20 +145,23 @@ "theWordWas": "La palabra era: {word}", "@theWordWas": { "placeholders": { - "word": {"type": "String"} + "word": { + "type": "String" + } } }, "impostorsWin": "¡Los impostores ganan!", "wrongGuess": "¡No ha acertado!", "gameContinues": "La partida continúa...", - "gameOver": "Fin de partida", "playersWin": "¡Los jugadores ganan!", "theSecretWordWas": "🔍 La palabra era:", "categoryLabel": "Categoría: {category}", "@categoryLabel": { "placeholders": { - "category": {"type": "String"} + "category": { + "type": "String" + } } }, "theImpostorWas": "🎭 El impostor era:", @@ -162,13 +170,16 @@ "roundElimination": "Ronda {round}: {name}", "@roundElimination": { "placeholders": { - "round": {"type": "int"}, - "name": {"type": "String"} + "round": { + "type": "int" + }, + "name": { + "type": "String" + } } }, "rematch": "Revancha", "mainMenu": "Menú principal", - "notesTitle": "📝 Notas", "notesSaved": "Notas guardadas", "whoAreYou": "¿Quién eres?", @@ -176,14 +187,15 @@ "notesOf": "Notas de {name}", "@notesOf": { "placeholders": { - "name": {"type": "String"} + "name": { + "type": "String" + } } }, "notesAboutPlayers": "Apuntes sobre cada jugador", "playerNoteHint": "¿Qué ha dicho? ¿Sospechoso?", "freeNote": "Nota libre", "freeNoteHint": "Apuntes personales...", - "rulesTitle": "📖 Cómo jugar", "rulesWhatIsTitle": "🎭 ¿Qué es Farolero?", "rulesWhatIsBody": "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.", @@ -199,20 +211,17 @@ "rulesModesBody": "• 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.", "rulesExampleTitle": "✏️ Ejemplo de partida", "rulesExampleBody": "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\nEva dio una respuesta muy genérica... ¡Sospechosa!", - "joinGameTitle": "Unirse a partida", "multiDeviceMode": "Modo multimóvil", "scanQrDescription": "Escanea el código QR que muestra el host para conectarte a la partida vía Bluetooth/WiFi Direct.", "comingSoon": "Próximamente", "nearbyNotAvailable": "La conexión multimóvil con Nearby Connections requiere dispositivos Android físicos.\n\nPor ahora, usa el modo \"Un solo móvil\" para jugar en un dispositivo compartido.", "back": "Volver", - "yes": "Sí", "no": "No", "cancel": "Cancelar", "accept": "Aceptar", "next": "Siguiente", - "settingsTitle": "Ajustes", "language": "Idioma", "soundVolume": "Volumen de efectos", @@ -220,5 +229,25 @@ "about": "Acerca de", "version": "Versión", "developer": "Desarrollador", - "licenses": "Licencias" -} + "licenses": "Licencias", + "scanToJoin": "Escanea el QR para unirte", + "connectedPlayers": "Jugadores conectados", + "waitingForPlayers": "Esperando jugadores...", + "needMorePlayers": "Faltan {count} jugadores más", + "@needMorePlayers": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "starting": "Iniciando...", + "enterNameAndScan": "Escribe tu nombre y escanea el QR del host", + "yourName": "Tu nombre", + "nameRequired": "Escribe tu nombre", + "connectingTo": "Conectando a", + "scanQR": "Escanear QR", + "scanHostQR": "Apunta al QR del host", + "connectedWaiting": "¡Conectado!", + "waitingForHost": "Esperando a que el host inicie la partida..." +} \ No newline at end of file diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 3073f5c..f684d96 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -992,6 +992,84 @@ abstract class AppLocalizations { /// In es, this message translates to: /// **'Licencias'** String get licenses; + + /// No description provided for @scanToJoin. + /// + /// In es, this message translates to: + /// **'Escanea el QR para unirte'** + String get scanToJoin; + + /// No description provided for @connectedPlayers. + /// + /// In es, this message translates to: + /// **'Jugadores conectados'** + String get connectedPlayers; + + /// No description provided for @waitingForPlayers. + /// + /// In es, this message translates to: + /// **'Esperando jugadores...'** + String get waitingForPlayers; + + /// No description provided for @needMorePlayers. + /// + /// In es, this message translates to: + /// **'Faltan {count} jugadores más'** + String needMorePlayers(int count); + + /// No description provided for @starting. + /// + /// In es, this message translates to: + /// **'Iniciando...'** + String get starting; + + /// No description provided for @enterNameAndScan. + /// + /// In es, this message translates to: + /// **'Escribe tu nombre y escanea el QR del host'** + String get enterNameAndScan; + + /// No description provided for @yourName. + /// + /// In es, this message translates to: + /// **'Tu nombre'** + String get yourName; + + /// No description provided for @nameRequired. + /// + /// In es, this message translates to: + /// **'Escribe tu nombre'** + String get nameRequired; + + /// No description provided for @connectingTo. + /// + /// In es, this message translates to: + /// **'Conectando a'** + String get connectingTo; + + /// No description provided for @scanQR. + /// + /// In es, this message translates to: + /// **'Escanear QR'** + String get scanQR; + + /// No description provided for @scanHostQR. + /// + /// In es, this message translates to: + /// **'Apunta al QR del host'** + String get scanHostQR; + + /// No description provided for @connectedWaiting. + /// + /// In es, this message translates to: + /// **'¡Conectado!'** + String get connectedWaiting; + + /// No description provided for @waitingForHost. + /// + /// In es, this message translates to: + /// **'Esperando a que el host inicie la partida...'** + String get waitingForHost; } class _AppLocalizationsDelegate diff --git a/lib/l10n/generated/app_localizations_ar.dart b/lib/l10n/generated/app_localizations_ar.dart index 80f5906..dc41020 100644 --- a/lib/l10n/generated/app_localizations_ar.dart +++ b/lib/l10n/generated/app_localizations_ar.dart @@ -470,4 +470,45 @@ class AppLocalizationsAr extends AppLocalizations { @override String get licenses => 'التراخيص'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_ca.dart b/lib/l10n/generated/app_localizations_ca.dart index 65caf03..a6da1e1 100644 --- a/lib/l10n/generated/app_localizations_ca.dart +++ b/lib/l10n/generated/app_localizations_ca.dart @@ -473,4 +473,45 @@ class AppLocalizationsCa extends AppLocalizations { @override String get licenses => 'Llicències'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_de.dart b/lib/l10n/generated/app_localizations_de.dart index 1160919..8b9e2dd 100644 --- a/lib/l10n/generated/app_localizations_de.dart +++ b/lib/l10n/generated/app_localizations_de.dart @@ -476,4 +476,45 @@ class AppLocalizationsDe extends AppLocalizations { @override String get licenses => 'Lizenzen'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index a49dfe5..a679eaf 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -471,4 +471,45 @@ class AppLocalizationsEn extends AppLocalizations { @override String get licenses => 'Licenses'; + + @override + String get scanToJoin => 'Scan QR to join'; + + @override + String get connectedPlayers => 'Connected players'; + + @override + String get waitingForPlayers => 'Waiting for players...'; + + @override + String needMorePlayers(int count) { + return 'Need $count more players'; + } + + @override + String get starting => 'Starting...'; + + @override + String get enterNameAndScan => 'Enter your name and scan the host\'s QR'; + + @override + String get yourName => 'Your name'; + + @override + String get nameRequired => 'Enter your name'; + + @override + String get connectingTo => 'Connecting to'; + + @override + String get scanQR => 'Scan QR'; + + @override + String get scanHostQR => 'Point at the host\'s QR code'; + + @override + String get connectedWaiting => 'Connected!'; + + @override + String get waitingForHost => 'Waiting for the host to start the game...'; } diff --git a/lib/l10n/generated/app_localizations_es.dart b/lib/l10n/generated/app_localizations_es.dart index 8810b42..e467f87 100644 --- a/lib/l10n/generated/app_localizations_es.dart +++ b/lib/l10n/generated/app_localizations_es.dart @@ -472,4 +472,45 @@ class AppLocalizationsEs extends AppLocalizations { @override String get licenses => 'Licencias'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_eu.dart b/lib/l10n/generated/app_localizations_eu.dart index bb9b4c2..0bbdd53 100644 --- a/lib/l10n/generated/app_localizations_eu.dart +++ b/lib/l10n/generated/app_localizations_eu.dart @@ -475,4 +475,45 @@ class AppLocalizationsEu extends AppLocalizations { @override String get licenses => 'Lizentziak'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_fr.dart b/lib/l10n/generated/app_localizations_fr.dart index 95af26e..a57b250 100644 --- a/lib/l10n/generated/app_localizations_fr.dart +++ b/lib/l10n/generated/app_localizations_fr.dart @@ -473,4 +473,45 @@ class AppLocalizationsFr extends AppLocalizations { @override String get licenses => 'Licences'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_hi.dart b/lib/l10n/generated/app_localizations_hi.dart index 7dd5255..db8c8a1 100644 --- a/lib/l10n/generated/app_localizations_hi.dart +++ b/lib/l10n/generated/app_localizations_hi.dart @@ -472,4 +472,45 @@ class AppLocalizationsHi extends AppLocalizations { @override String get licenses => 'लाइसेंस'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_it.dart b/lib/l10n/generated/app_localizations_it.dart index 707fb68..5d54513 100644 --- a/lib/l10n/generated/app_localizations_it.dart +++ b/lib/l10n/generated/app_localizations_it.dart @@ -473,4 +473,45 @@ class AppLocalizationsIt extends AppLocalizations { @override String get licenses => 'Licenze'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_ja.dart b/lib/l10n/generated/app_localizations_ja.dart index 2d8b9e7..c6d030f 100644 --- a/lib/l10n/generated/app_localizations_ja.dart +++ b/lib/l10n/generated/app_localizations_ja.dart @@ -470,4 +470,45 @@ class AppLocalizationsJa extends AppLocalizations { @override String get licenses => 'ライセンス'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_ko.dart b/lib/l10n/generated/app_localizations_ko.dart index 5a96f6c..f8e9175 100644 --- a/lib/l10n/generated/app_localizations_ko.dart +++ b/lib/l10n/generated/app_localizations_ko.dart @@ -470,4 +470,45 @@ class AppLocalizationsKo extends AppLocalizations { @override String get licenses => '라이선스'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_nl.dart b/lib/l10n/generated/app_localizations_nl.dart index d054d0a..32f7fde 100644 --- a/lib/l10n/generated/app_localizations_nl.dart +++ b/lib/l10n/generated/app_localizations_nl.dart @@ -473,4 +473,45 @@ class AppLocalizationsNl extends AppLocalizations { @override String get licenses => 'Licenties'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_pl.dart b/lib/l10n/generated/app_localizations_pl.dart index 94f6793..f03c352 100644 --- a/lib/l10n/generated/app_localizations_pl.dart +++ b/lib/l10n/generated/app_localizations_pl.dart @@ -473,4 +473,45 @@ class AppLocalizationsPl extends AppLocalizations { @override String get licenses => 'Licencje'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_pt.dart b/lib/l10n/generated/app_localizations_pt.dart index 3e0d441..77bc5d4 100644 --- a/lib/l10n/generated/app_localizations_pt.dart +++ b/lib/l10n/generated/app_localizations_pt.dart @@ -474,4 +474,45 @@ class AppLocalizationsPt extends AppLocalizations { @override String get licenses => 'Licenças'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_ru.dart b/lib/l10n/generated/app_localizations_ru.dart index dcd156b..aec4de1 100644 --- a/lib/l10n/generated/app_localizations_ru.dart +++ b/lib/l10n/generated/app_localizations_ru.dart @@ -473,4 +473,45 @@ class AppLocalizationsRu extends AppLocalizations { @override String get licenses => 'Лицензии'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_tr.dart b/lib/l10n/generated/app_localizations_tr.dart index 565d678..3b45621 100644 --- a/lib/l10n/generated/app_localizations_tr.dart +++ b/lib/l10n/generated/app_localizations_tr.dart @@ -472,4 +472,45 @@ class AppLocalizationsTr extends AppLocalizations { @override String get licenses => 'Lisanslar'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index 983c294..3413f19 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -469,6 +469,47 @@ class AppLocalizationsZh extends AppLocalizations { @override String get licenses => '许可证'; + + @override + String get scanToJoin => 'Escanea el QR para unirte'; + + @override + String get connectedPlayers => 'Jugadores conectados'; + + @override + String get waitingForPlayers => 'Esperando jugadores...'; + + @override + String needMorePlayers(int count) { + return 'Faltan $count jugadores más'; + } + + @override + String get starting => 'Iniciando...'; + + @override + String get enterNameAndScan => 'Escribe tu nombre y escanea el QR del host'; + + @override + String get yourName => 'Tu nombre'; + + @override + String get nameRequired => 'Escribe tu nombre'; + + @override + String get connectingTo => 'Conectando a'; + + @override + String get scanQR => 'Escanear QR'; + + @override + String get scanHostQR => 'Apunta al QR del host'; + + @override + String get connectedWaiting => '¡Conectado!'; + + @override + String get waitingForHost => 'Esperando a que el host inicie la partida...'; } /// The translations for Chinese, as used in Taiwan (`zh_TW`). diff --git a/lib/main.dart b/lib/main.dart index ca9b07e..7433f8e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:provider/provider.dart'; import 'estado/estado_juego.dart'; import 'servicios/servicio_idioma.dart'; +import 'servicios/servicio_nearby.dart'; import 'tema/tema_app.dart'; import 'pantallas/pantalla_principal.dart'; @@ -34,6 +35,9 @@ class FaroleroApp extends StatelessWidget { ChangeNotifierProvider( create: (_) => ServicioIdioma()..cargar(), ), + ChangeNotifierProvider( + create: (_) => ServicioNearby(), + ), ], child: Consumer( builder: (context, servicioIdioma, _) { diff --git a/lib/pantallas/pantalla_lobby_host.dart b/lib/pantallas/pantalla_lobby_host.dart new file mode 100644 index 0000000..9a1542f --- /dev/null +++ b/lib/pantallas/pantalla_lobby_host.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import '../servicios/servicio_nearby.dart'; +import '../tema/tema_app.dart'; + +/// Pantalla de lobby del host: muestra QR y lista de jugadores conectados +class PantallaLobbyHost extends StatefulWidget { + final String nombreSala; + final VoidCallback onIniciar; + + const PantallaLobbyHost({ + super.key, + required this.nombreSala, + required this.onIniciar, + }); + + @override + State createState() => _PantallaLobbyHostState(); +} + +class _PantallaLobbyHostState extends State { + bool _iniciando = false; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final nearby = context.watch(); + final jugadores = nearby.jugadores; + final totalJugadores = jugadores.length + 1; // +1 host + + return Scaffold( + appBar: AppBar( + title: Text(widget.nombreSala), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + await nearby.desconectar(); + if (context.mounted) Navigator.pop(context); + }, + ), + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // QR Code + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: QrImageView( + data: nearby.generarDatosQR(widget.nombreSala), + version: QrVersions.auto, + size: 180, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 12), + Text( + l10n.scanToJoin, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + + // Lista de jugadores + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + l10n.connectedPlayers, + style: Theme.of(context).textTheme.titleLarge, + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: totalJugadores >= 3 + ? TemaApp.colorVerde.withValues(alpha: 0.2) + : TemaApp.colorNaranja.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$totalJugadores', + style: TextStyle( + color: totalJugadores >= 3 + ? TemaApp.colorVerde + : TemaApp.colorNaranja, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // Host (yo) + _buildJugadorTile( + nombre: nearby.miNombre ?? 'Host', + esHost: true, + ), + + // Jugadores conectados + Expanded( + child: jugadores.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📱', style: TextStyle(fontSize: 48)), + const SizedBox(height: 12), + Text( + l10n.waitingForPlayers, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ) + : ListView.builder( + itemCount: jugadores.length, + itemBuilder: (context, index) { + final j = jugadores[index]; + return _buildJugadorTile(nombre: j.nombre); + }, + ), + ), + ], + ), + ), + + // Botón iniciar + if (totalJugadores < 3) + Text( + l10n.needMorePlayers(3 - totalJugadores), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: totalJugadores >= 3 && !_iniciando + ? () { + setState(() => _iniciando = true); + widget.onIniciar(); + } + : null, + icon: const Icon(Icons.play_arrow), + label: Text(_iniciando ? l10n.starting : l10n.startGame), + ), + ), + ], + ), + ), + ); + } + + Widget _buildJugadorTile({required String nombre, bool esHost = false}) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: TemaApp.colorTarjeta, + borderRadius: BorderRadius.circular(12), + border: esHost + ? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5)) + : null, + ), + child: Row( + children: [ + Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)), + const SizedBox(width: 12), + Expanded( + child: Text( + nombre, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + if (esHost) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: TemaApp.colorAcento.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'HOST', + style: TextStyle( + color: TemaApp.colorAcento, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index f12a13c..d1905b2 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -1,70 +1,265 @@ import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:provider/provider.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; +import '../servicios/servicio_nearby.dart'; import '../tema/tema_app.dart'; -class PantallaUnirse extends StatelessWidget { +/// Pantalla para unirse a una partida multidispositivo +class PantallaUnirse extends StatefulWidget { const PantallaUnirse({super.key}); + @override + State createState() => _PantallaUnirseState(); +} + +class _PantallaUnirseState extends State { + final _nombreController = TextEditingController(); + final _formKey = GlobalKey(); + bool _escaneando = false; + bool _conectando = false; + String? _error; + String? _salaEncontrada; + + @override + void dispose() { + _nombreController.dispose(); + super.dispose(); + } + + Future _iniciarEscaneo() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _escaneando = true; + _error = null; + }); + } + + Future _onQRDetectado(BarcodeCapture capture) async { + if (_conectando) return; + + for (final barcode in capture.barcodes) { + final valor = barcode.rawValue; + if (valor == null) continue; + + final datos = ServicioNearby.parsearQR(valor); + if (datos != null) { + setState(() { + _escaneando = false; + _conectando = true; + _salaEncontrada = datos['sala'] as String? ?? 'Sala'; + }); + + // Iniciar búsqueda de hosts via Nearby + final nearby = context.read(); + final ok = await nearby.buscarHosts(_nombreController.text.trim()); + + if (!ok && mounted) { + setState(() { + _conectando = false; + _error = 'No se pudo iniciar la búsqueda. Verifica Bluetooth y ubicación.'; + }); + } + return; + } + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final nearby = context.watch(); + + // Si estamos conectados, mostrar pantalla de espera + if (nearby.conectado && !nearby.esHost) { + return _buildPantallaEspera(context, l10n, nearby); + } return Scaffold( - appBar: AppBar(title: Text(l10n.joinGameTitle)), + appBar: AppBar( + title: Text(l10n.joinGameTitle), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + await nearby.desconectar(); + if (context.mounted) Navigator.pop(context); + }, + ), + ), + body: _escaneando + ? _buildEscaner(context, l10n) + : _buildFormulario(context, l10n), + ); + } + + Widget _buildFormulario(BuildContext context, AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.all(32), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📱', style: TextStyle(fontSize: 64)), + const SizedBox(height: 24), + Text( + l10n.joinGameTitle, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + l10n.enterNameAndScan, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + // Campo nombre + TextFormField( + controller: _nombreController, + decoration: InputDecoration( + labelText: l10n.yourName, + prefixIcon: const Icon(Icons.person), + ), + validator: (v) { + if (v == null || v.trim().isEmpty) { + return l10n.nameRequired; + } + return null; + }, + textCapitalization: TextCapitalization.words, + ), + const SizedBox(height: 24), + + if (_conectando) ...[ + const CircularProgressIndicator(color: TemaApp.colorAcento), + const SizedBox(height: 12), + Text( + '${l10n.connectingTo} ${_salaEncontrada ?? ""}...', + style: Theme.of(context).textTheme.bodyMedium, + ), + ] else ...[ + // Botón escanear QR + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _iniciarEscaneo, + icon: const Icon(Icons.qr_code_scanner), + label: Text(l10n.scanQR), + ), + ), + ], + + if (_error != null) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: TemaApp.colorAcento.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _error!, + style: const TextStyle(color: TemaApp.colorAcento), + textAlign: TextAlign.center, + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildEscaner(BuildContext context, AppLocalizations l10n) { + return Stack( + children: [ + MobileScanner( + onDetect: _onQRDetectado, + ), + // Overlay + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.8), + ], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.scanHostQR, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: Colors.white, + ), + ), + const SizedBox(height: 16), + OutlinedButton( + onPressed: () => setState(() => _escaneando = false), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white), + ), + child: Text(l10n.cancel), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildPantallaEspera( + BuildContext context, + AppLocalizations l10n, + ServicioNearby nearby, + ) { + return Scaffold( + appBar: AppBar( + title: Text(_salaEncontrada ?? l10n.joinGameTitle), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () async { + await nearby.desconectar(); + if (context.mounted) Navigator.pop(context); + }, + ), + ), body: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text('📱', style: TextStyle(fontSize: 64)), + const Text('✅', style: TextStyle(fontSize: 64)), const SizedBox(height: 24), Text( - l10n.multiDeviceMode, + l10n.connectedWaiting, style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: 16), - Text( - l10n.scanQrDescription, - 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( - l10n.comingSoon, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: TemaApp.colorNaranja, - ), - ), - const SizedBox(height: 8), - Text( - l10n.nearbyNotAvailable, - style: Theme.of(context).textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - ], - ), + const SizedBox(height: 12), + Text( + '${l10n.yourName}: ${_nombreController.text}', + style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 32), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back), - label: Text(l10n.back), - ), + const CircularProgressIndicator(color: TemaApp.colorNaranja), + const SizedBox(height: 16), + Text( + l10n.waitingForHost, + style: Theme.of(context).textTheme.bodyMedium, ), ], ), diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index f552ee6..241342d 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; +import 'package:nearby_connections/nearby_connections.dart'; /// Tipos de mensajes en el protocolo P2P enum TipoMensaje { @@ -11,6 +12,8 @@ enum TipoMensaje { unirse, voto, listo, + ping, + jugadorDesconectado, } /// Mensaje del protocolo P2P entre dispositivos @@ -32,107 +35,414 @@ class MensajeP2P { datos: mapa['datos'] as Map, ); } + + Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson())); } +/// Info de un jugador conectado +class JugadorConectado { + final String endpointId; + final String nombre; + bool listo; + + JugadorConectado({ + required this.endpointId, + required this.nombre, + this.listo = false, + }); +} + +/// Callback para mensajes recibidos +typedef OnMensajeCallback = void Function(String endpointId, MensajeP2P mensaje); + /// 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 { + static const _serviceId = 'es.freetimelab.farolero'; + bool _esHost = false; bool _conectado = false; - String? _endpointId; - final List _dispositivos = []; + bool _buscando = false; + bool _anunciando = false; + String? _miEndpointId; + String? _hostEndpointId; + String? _nombreSala; + String? _miNombre; + + final Map _jugadores = {}; + final List _listeners = []; + + // Estado para clientes + String? _palabraRecibida; + bool? _soyImpostor; + String? _faseActual; + Map? _datosPartida; bool get esHost => _esHost; bool get conectado => _conectado; - String? get endpointId => _endpointId; - List get dispositivos => List.unmodifiable(_dispositivos); + bool get buscando => _buscando; + bool get anunciando => _anunciando; + String? get miEndpointId => _miEndpointId; + String? get hostEndpointId => _hostEndpointId; + String? get nombreSala => _nombreSala; + String? get miNombre => _miNombre; + String? get palabraRecibida => _palabraRecibida; + bool? get soyImpostor => _soyImpostor; + String? get faseActual => _faseActual; + Map? get datosPartida => _datosPartida; + + List get jugadores => _jugadores.values.toList(); + int get numJugadoresConectados => _jugadores.length; + + /// Registra un listener de mensajes + void onMensaje(OnMensajeCallback callback) { + _listeners.add(callback); + } + + /// Elimina un listener + void removeMensajeListener(OnMensajeCallback callback) { + _listeners.remove(callback); + } + + void _notificarMensaje(String endpointId, MensajeP2P mensaje) { + for (final listener in _listeners) { + listener(endpointId, mensaje); + } + } + + // ==================== HOST ==================== /// 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.farolero', - // ); - // _esHost = true; - // _endpointId = nombreSala; - // notifyListeners(); - // return true; - // } catch (e) { - // debugPrint('Error iniciando host: $e'); - // return false; - // } + Future iniciarHost(String nombreSala, String miNombre) async { + _nombreSala = nombreSala; + _miNombre = miNombre; - _esHost = true; - _endpointId = nombreSala; - notifyListeners(); - return true; + try { + final resultado = await Nearby().startAdvertising( + miNombre, + Strategy.P2P_STAR, + onConnectionInitiated: _onConexionIniciada, + onConnectionResult: _onResultadoConexion, + onDisconnected: _onDesconexion, + serviceId: _serviceId, + ); + + if (resultado) { + _esHost = true; + _anunciando = true; + _conectado = true; + notifyListeners(); + return true; + } + return false; + } catch (e) { + debugPrint('Error iniciando host: $e'); + return false; + } } - /// 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.farolero', - // ); - // return true; - // } catch (e) { - // debugPrint('Error conectando: $e'); - // return false; - // } + // ==================== CLIENTE ==================== - _conectado = true; - notifyListeners(); - return true; + /// Busca hosts disponibles + Future buscarHosts(String miNombre) async { + _miNombre = miNombre; + + try { + final resultado = await Nearby().startDiscovery( + miNombre, + Strategy.P2P_STAR, + onEndpointFound: _onEndpointEncontrado, + onEndpointLost: _onEndpointPerdido, + serviceId: _serviceId, + ); + + if (resultado) { + _buscando = true; + notifyListeners(); + return true; + } + return false; + } catch (e) { + debugPrint('Error buscando hosts: $e'); + return false; + } } + /// Conecta a un host específico + Future conectarAHost(String endpointId, String miNombre) async { + try { + await Nearby().requestConnection( + miNombre, + endpointId, + onConnectionInitiated: _onConexionIniciada, + onConnectionResult: _onResultadoConexion, + onDisconnected: _onDesconexion, + ); + return true; + } catch (e) { + debugPrint('Error conectando a host: $e'); + return false; + } + } + + // ==================== CALLBACKS NEARBY ==================== + + void _onConexionIniciada(String endpointId, ConnectionInfo info) { + debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}'); + // Auto-aceptar conexiones + Nearby().acceptConnection( + endpointId, + onPayLoadRecieved: _onPayloadRecibido, + onPayloadTransferUpdate: _onPayloadUpdate, + ); + } + + void _onResultadoConexion(String endpointId, Status status) { + debugPrint('Resultado conexión $endpointId: $status'); + if (status == Status.CONNECTED) { + if (_esHost) { + // Host: esperar mensaje 'unirse' del cliente + debugPrint('Cliente conectado: $endpointId'); + } else { + // Cliente: conectado al host + _hostEndpointId = endpointId; + _conectado = true; + // Enviar mensaje de unirse + enviarMensaje(endpointId, MensajeP2P( + tipo: TipoMensaje.unirse, + datos: {'nombre': _miNombre ?? 'Jugador'}, + )); + } + notifyListeners(); + } else { + debugPrint('Conexión fallida con $endpointId'); + } + } + + void _onDesconexion(String endpointId) { + debugPrint('Desconexión: $endpointId'); + if (_esHost) { + final jugador = _jugadores.remove(endpointId); + if (jugador != null) { + // Notificar a todos que se desconectó + enviarATodos(MensajeP2P( + tipo: TipoMensaje.jugadorDesconectado, + datos: {'nombre': jugador.nombre, 'endpointId': endpointId}, + )); + } + } else { + // Cliente perdió conexión con host + _conectado = false; + _hostEndpointId = null; + } + notifyListeners(); + } + + void _onEndpointEncontrado(String endpointId, String endpointName, String serviceId) { + debugPrint('Host encontrado: $endpointName ($endpointId)'); + // Auto-conectar al primer host encontrado + conectarAHost(endpointId, _miNombre ?? 'Jugador'); + } + + void _onEndpointPerdido(String? endpointId) { + debugPrint('Endpoint perdido: $endpointId'); + } + + void _onPayloadRecibido(String endpointId, Payload payload) { + if (payload.type == PayloadType.BYTES && payload.bytes != null) { + try { + final jsonStr = utf8.decode(payload.bytes!); + final mensaje = MensajeP2P.fromJson(jsonStr); + _procesarMensaje(endpointId, mensaje); + } catch (e) { + debugPrint('Error procesando payload: $e'); + } + } + } + + void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) { + // No necesitamos trackear progreso para bytes pequeños + } + + // ==================== PROCESAMIENTO DE MENSAJES ==================== + + void _procesarMensaje(String endpointId, MensajeP2P mensaje) { + debugPrint('Mensaje de $endpointId: ${mensaje.tipo.name}'); + + if (_esHost) { + _procesarMensajeHost(endpointId, mensaje); + } else { + _procesarMensajeCliente(endpointId, mensaje); + } + + _notificarMensaje(endpointId, mensaje); + } + + void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { + switch (mensaje.tipo) { + case TipoMensaje.unirse: + final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; + _jugadores[endpointId] = JugadorConectado( + endpointId: endpointId, + nombre: nombre, + ); + // Enviar info de sala al nuevo jugador + enviarMensaje(endpointId, MensajeP2P( + tipo: TipoMensaje.salaInfo, + datos: { + 'sala': _nombreSala, + 'jugadores': _jugadores.values.map((j) => { + 'nombre': j.nombre, + 'endpointId': j.endpointId, + }).toList(), + }, + )); + notifyListeners(); + break; + + case TipoMensaje.voto: + // Propagar al flujo de juego + _notificarMensaje(endpointId, mensaje); + break; + + case TipoMensaje.listo: + final jugador = _jugadores[endpointId]; + if (jugador != null) { + jugador.listo = true; + notifyListeners(); + } + break; + + default: + break; + } + } + + void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) { + switch (mensaje.tipo) { + case TipoMensaje.salaInfo: + _datosPartida = mensaje.datos; + notifyListeners(); + break; + + case TipoMensaje.partidaInicio: + _palabraRecibida = mensaje.datos['palabra'] as String?; + _soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + _datosPartida = mensaje.datos; + notifyListeners(); + break; + + case TipoMensaje.fase: + _faseActual = mensaje.datos['fase'] as String?; + _datosPartida = mensaje.datos; + notifyListeners(); + break; + + case TipoMensaje.votacionResultado: + _datosPartida = mensaje.datos; + notifyListeners(); + break; + + case TipoMensaje.partidaFin: + _datosPartida = mensaje.datos; + notifyListeners(); + break; + + case TipoMensaje.jugadorDesconectado: + notifyListeners(); + break; + + default: + break; + } + } + + // ==================== ENVÍO ==================== + /// 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()}'); + try { + await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); + } catch (e) { + debugPrint('Error enviando a $endpointId: $e'); + } } - /// Envía un mensaje a todos los dispositivos conectados + /// Envía un mensaje a todos los dispositivos conectados (solo host) Future enviarATodos(MensajeP2P mensaje) async { - for (final id in _dispositivos) { + for (final id in _jugadores.keys) { await enviarMensaje(id, mensaje); } } - /// Desconecta y limpia + // ==================== HOST: ACCIONES DE JUEGO ==================== + + /// Host envía inicio de partida con la palabra de cada jugador + Future enviarInicioPartida({ + required String palabraSecreta, + required String categoria, + required Map impostores, // endpointId -> esImpostor + }) async { + for (final entry in _jugadores.entries) { + final esImpostor = impostores[entry.key] ?? false; + await enviarMensaje(entry.key, MensajeP2P( + tipo: TipoMensaje.partidaInicio, + datos: { + 'palabra': esImpostor ? null : palabraSecreta, + 'esImpostor': esImpostor, + 'categoria': categoria, + 'numJugadores': _jugadores.length + 1, // +1 por el host + }, + )); + } + } + + /// Host envía cambio de fase + Future enviarCambioFase(String fase, [Map? extra]) async { + final datos = {'fase': fase, ...?extra}; + await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); + } + + /// Host envía resultado de votación + Future enviarResultadoVotacion(Map resultado) async { + await enviarATodos(MensajeP2P( + tipo: TipoMensaje.votacionResultado, + datos: resultado, + )); + } + + /// Host envía fin de partida + Future enviarFinPartida(Map resultado) async { + await enviarATodos(MensajeP2P( + tipo: TipoMensaje.partidaFin, + datos: resultado, + )); + } + + // ==================== LIMPIEZA ==================== + + /// Desconecta y limpia todo Future desconectar() async { - // await Nearby().stopAllEndpoints(); - // await Nearby().stopAdvertising(); - // await Nearby().stopDiscovery(); + try { + await Nearby().stopAllEndpoints(); + if (_anunciando) await Nearby().stopAdvertising(); + if (_buscando) await Nearby().stopDiscovery(); + } catch (e) { + debugPrint('Error desconectando: $e'); + } + _esHost = false; _conectado = false; - _endpointId = null; - _dispositivos.clear(); + _buscando = false; + _anunciando = false; + _miEndpointId = null; + _hostEndpointId = null; + _nombreSala = null; + _miNombre = null; + _palabraRecibida = null; + _soyImpostor = null; + _faseActual = null; + _datosPartida = null; + _jugadores.clear(); notifyListeners(); } @@ -140,8 +450,25 @@ class ServicioNearby extends ChangeNotifier { String generarDatosQR(String nombreSala) { return json.encode({ 'app': 'farolero', - 'endpoint': _endpointId, 'sala': nombreSala, + 'host': _miNombre, }); } + + /// Parsea datos de QR escaneado + static Map? parsearQR(String datos) { + try { + final mapa = json.decode(datos) as Map; + if (mapa['app'] == 'farolero') return mapa; + return null; + } catch (_) { + return null; + } + } + + @override + void dispose() { + desconectar(); + super.dispose(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 5678add..a0775b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: farolero description: "Farolero — Juego de deducción social. ¿Quién finge saber?" publish_to: 'none' -version: 1.0.3+4 +version: 1.1.0+5 environment: sdk: ^3.11.1