feat: modo multidispositivo con Nearby Connections
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 9s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m10s

- 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
This commit is contained in:
ShanaiaBot
2026-04-04 03:09:51 +02:00
parent f453ce6e0d
commit 23472707ad
25 changed files with 1799 additions and 165 deletions

View File

@@ -24,7 +24,13 @@
"categoryMusic": "Music", "categoryMusic": "Music",
"categoryTechnology": "Technology", "categoryTechnology": "Technology",
"playersCount": "Players ({count})", "playersCount": "Players ({count})",
"@playersCount": {"placeholders": {"count": {"type": "int"}}}, "@playersCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"playersRangeHint": "3-20", "playersRangeHint": "3-20",
"playerNameHint": "Player name", "playerNameHint": "Player name",
"playerAlreadyExists": "A player with that name already exists", "playerAlreadyExists": "A player with that name already exists",
@@ -44,35 +50,77 @@
"seeYourWord": "See your word", "seeYourWord": "See your word",
"eachPlayerMustSee": "Each player must see their word in secret", "eachPlayerMustSee": "Each player must see their word in secret",
"roundNumber": "Round {round}", "roundNumber": "Round {round}",
"@roundNumber": {"placeholders": {"round": {"type": "int"}}}, "@roundNumber": {
"placeholders": {
"round": {
"type": "int"
}
}
},
"alreadySeen": "Already seen their word", "alreadySeen": "Already seen their word",
"tapToSee": "Tap to see", "tapToSee": "Tap to see",
"allSeenStartDebate": "Everyone has seen → Start discussion", "allSeenStartDebate": "Everyone has seen → Start discussion",
"playersRemaining": "{count} players remaining", "playersRemaining": "{count} players remaining",
"@playersRemaining": {"placeholders": {"count": {"type": "int"}}}, "@playersRemaining": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"youAreImpostor": "You are the impostor!", "youAreImpostor": "You are the impostor!",
"yourWordIs": "Your word is:", "yourWordIs": "Your word is:",
"clueCategory": "Clue: {category}", "clueCategory": "Clue: {category}",
"@clueCategory": {"placeholders": {"category": {"type": "String"}}}, "@clueCategory": {
"placeholders": {
"category": {
"type": "String"
}
}
},
"holdToSeeWord": "Hold to see your word", "holdToSeeWord": "Hold to see your word",
"makeSureNoOneLooks": "Make sure no one else is looking", "makeSureNoOneLooks": "Make sure no one else is looking",
"showingWord": "👁️ Showing...", "showingWord": "👁️ Showing...",
"holdToSee": "👆 Hold to see", "holdToSee": "👆 Hold to see",
"seenMyWord": "I've seen my word", "seenMyWord": "I've seen my word",
"debateRound": "Discussion - Round {round}", "debateRound": "Discussion - Round {round}",
"@debateRound": {"placeholders": {"round": {"type": "int"}}}, "@debateRound": {
"placeholders": {
"round": {
"type": "int"
}
}
},
"timeUp": "⏰ Time's up!", "timeUp": "⏰ Time's up!",
"timeRemaining": "⏱️ Time remaining", "timeRemaining": "⏱️ Time remaining",
"playersInDebate": "Players in discussion", "playersInDebate": "Players in discussion",
"activePlayersInfo": "{active} active • {impostors} hidden impostor(s)", "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", "eliminated": "Eliminated",
"notes": "Notes", "notes": "Notes",
"goToVoting": "Go to voting", "goToVoting": "Go to voting",
"voting": "🗳️ Voting", "voting": "🗳️ Voting",
"turnToVote": "Your turn to vote:", "turnToVote": "Your turn to vote:",
"votesProgress": "Votes: {current}/{total}", "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?", "whoIsImpostor": "Who do you think is the impostor?",
"confirmVote": "Confirm vote", "confirmVote": "Confirm vote",
"votingComplete": "🗳️ Voting complete", "votingComplete": "🗳️ Voting complete",
@@ -95,7 +143,13 @@
"guess": "Guess", "guess": "Guess",
"correctGuess": "Correct guess!", "correctGuess": "Correct guess!",
"theWordWas": "The word was: {word}", "theWordWas": "The word was: {word}",
"@theWordWas": {"placeholders": {"word": {"type": "String"}}}, "@theWordWas": {
"placeholders": {
"word": {
"type": "String"
}
}
},
"impostorsWin": "The impostors win!", "impostorsWin": "The impostors win!",
"wrongGuess": "Wrong guess!", "wrongGuess": "Wrong guess!",
"gameContinues": "The game continues...", "gameContinues": "The game continues...",
@@ -103,12 +157,27 @@
"playersWin": "The players win!", "playersWin": "The players win!",
"theSecretWordWas": "🔍 The word was:", "theSecretWordWas": "🔍 The word was:",
"categoryLabel": "Category: {category}", "categoryLabel": "Category: {category}",
"@categoryLabel": {"placeholders": {"category": {"type": "String"}}}, "@categoryLabel": {
"placeholders": {
"category": {
"type": "String"
}
}
},
"theImpostorWas": "🎭 The impostor was:", "theImpostorWas": "🎭 The impostor was:",
"theImpostorsWere": "🎭 The impostors were:", "theImpostorsWere": "🎭 The impostors were:",
"votingHistory": "📊 Voting history", "votingHistory": "📊 Voting history",
"roundElimination": "Round {round}: {name}", "roundElimination": "Round {round}: {name}",
"@roundElimination": {"placeholders": {"round": {"type": "int"}, "name": {"type": "String"}}}, "@roundElimination": {
"placeholders": {
"round": {
"type": "int"
},
"name": {
"type": "String"
}
}
},
"rematch": "Rematch", "rematch": "Rematch",
"mainMenu": "Main menu", "mainMenu": "Main menu",
"notesTitle": "📝 Notes", "notesTitle": "📝 Notes",
@@ -116,7 +185,13 @@
"whoAreYou": "Who are you?", "whoAreYou": "Who are you?",
"selectYourName": "Select your name to view your private notes", "selectYourName": "Select your name to view your private notes",
"notesOf": "{name}'s notes", "notesOf": "{name}'s notes",
"@notesOf": {"placeholders": {"name": {"type": "String"}}}, "@notesOf": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"notesAboutPlayers": "Notes about each player", "notesAboutPlayers": "Notes about each player",
"playerNoteHint": "What did they say? Suspicious?", "playerNoteHint": "What did they say? Suspicious?",
"freeNote": "Free note", "freeNote": "Free note",
@@ -154,5 +229,25 @@
"about": "About", "about": "About",
"version": "Version", "version": "Version",
"developer": "Developer", "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..."
} }

View File

@@ -1,20 +1,16 @@
{ {
"@@locale": "es", "@@locale": "es",
"appTitle": "Farolero", "appTitle": "Farolero",
"subtitle": "Juego de deducción social", "subtitle": "Juego de deducción social",
"loadingWords": "Cargando palabras...", "loadingWords": "Cargando palabras...",
"playersRange": "3-20 jugadores • Sin internet", "playersRange": "3-20 jugadores • Sin internet",
"createGame": "Crear partida", "createGame": "Crear partida",
"joinGame": "Unirse a partida", "joinGame": "Unirse a partida",
"howToPlay": "Cómo jugar", "howToPlay": "Cómo jugar",
"settings": "Ajustes", "settings": "Ajustes",
"gameMode": "Modo de juego", "gameMode": "Modo de juego",
"singleDevice": "Un solo móvil", "singleDevice": "Un solo móvil",
"multiDevice": "Multimóvil", "multiDevice": "Multimóvil",
"category": "Categoría", "category": "Categoría",
"categoryAll": "Todas", "categoryAll": "Todas",
"categoryAnimals": "Animales", "categoryAnimals": "Animales",
@@ -27,11 +23,12 @@
"categoryMovies": "Películas", "categoryMovies": "Películas",
"categoryMusic": "Música", "categoryMusic": "Música",
"categoryTechnology": "Tecnología", "categoryTechnology": "Tecnología",
"playersCount": "Jugadores ({count})", "playersCount": "Jugadores ({count})",
"@playersCount": { "@playersCount": {
"placeholders": { "placeholders": {
"count": {"type": "int"} "count": {
"type": "int"
}
} }
}, },
"playersRangeHint": "3-20", "playersRangeHint": "3-20",
@@ -39,7 +36,6 @@
"playerAlreadyExists": "Ya existe un jugador con ese nombre", "playerAlreadyExists": "Ya existe un jugador con ese nombre",
"maxPlayersReached": "Máximo 20 jugadores", "maxPlayersReached": "Máximo 20 jugadores",
"minPlayersRequired": "Se necesitan al menos 3 jugadores", "minPlayersRequired": "Se necesitan al menos 3 jugadores",
"configuration": "Configuración", "configuration": "Configuración",
"impostors": "🎭 Impostores", "impostors": "🎭 Impostores",
"impostorClue": "🔍 Pista para impostor", "impostorClue": "🔍 Pista para impostor",
@@ -50,15 +46,15 @@
"twoMin": "2 min", "twoMin": "2 min",
"threeMin": "3 min", "threeMin": "3 min",
"fiveMin": "5 min", "fiveMin": "5 min",
"startGame": "Iniciar partida", "startGame": "Iniciar partida",
"seeYourWord": "Ver tu palabra", "seeYourWord": "Ver tu palabra",
"eachPlayerMustSee": "Cada jugador debe ver su palabra en secreto", "eachPlayerMustSee": "Cada jugador debe ver su palabra en secreto",
"roundNumber": "Ronda {round}", "roundNumber": "Ronda {round}",
"@roundNumber": { "@roundNumber": {
"placeholders": { "placeholders": {
"round": {"type": "int"} "round": {
"type": "int"
}
} }
}, },
"alreadySeen": "Ya ha visto su palabra", "alreadySeen": "Ya ha visto su palabra",
@@ -67,16 +63,19 @@
"playersRemaining": "Faltan {count} jugadores", "playersRemaining": "Faltan {count} jugadores",
"@playersRemaining": { "@playersRemaining": {
"placeholders": { "placeholders": {
"count": {"type": "int"} "count": {
"type": "int"
}
} }
}, },
"youAreImpostor": "¡Eres el impostor!", "youAreImpostor": "¡Eres el impostor!",
"yourWordIs": "Tu palabra es:", "yourWordIs": "Tu palabra es:",
"clueCategory": "Pista: {category}", "clueCategory": "Pista: {category}",
"@clueCategory": { "@clueCategory": {
"placeholders": { "placeholders": {
"category": {"type": "String"} "category": {
"type": "String"
}
} }
}, },
"holdToSeeWord": "Mantén pulsado para ver tu palabra", "holdToSeeWord": "Mantén pulsado para ver tu palabra",
@@ -84,11 +83,12 @@
"showingWord": "👁️ Mostrando...", "showingWord": "👁️ Mostrando...",
"holdToSee": "👆 Mantén pulsado para ver", "holdToSee": "👆 Mantén pulsado para ver",
"seenMyWord": "He visto mi palabra", "seenMyWord": "He visto mi palabra",
"debateRound": "Debate - Ronda {round}", "debateRound": "Debate - Ronda {round}",
"@debateRound": { "@debateRound": {
"placeholders": { "placeholders": {
"round": {"type": "int"} "round": {
"type": "int"
}
} }
}, },
"timeUp": "⏰ ¡Tiempo agotado!", "timeUp": "⏰ ¡Tiempo agotado!",
@@ -97,21 +97,28 @@
"activePlayersInfo": "{active} activos • {impostors} impostor(es) ocultos", "activePlayersInfo": "{active} activos • {impostors} impostor(es) ocultos",
"@activePlayersInfo": { "@activePlayersInfo": {
"placeholders": { "placeholders": {
"active": {"type": "int"}, "active": {
"impostors": {"type": "int"} "type": "int"
},
"impostors": {
"type": "int"
}
} }
}, },
"eliminated": "Eliminado", "eliminated": "Eliminado",
"notes": "Notas", "notes": "Notas",
"goToVoting": "Ir a votación", "goToVoting": "Ir a votación",
"voting": "🗳️ Votación", "voting": "🗳️ Votación",
"turnToVote": "Turno de votar:", "turnToVote": "Turno de votar:",
"votesProgress": "Votos: {current}/{total}", "votesProgress": "Votos: {current}/{total}",
"@votesProgress": { "@votesProgress": {
"placeholders": { "placeholders": {
"current": {"type": "int"}, "current": {
"total": {"type": "int"} "type": "int"
},
"total": {
"type": "int"
}
} }
}, },
"whoIsImpostor": "¿Quién crees que es el impostor?", "whoIsImpostor": "¿Quién crees que es el impostor?",
@@ -120,7 +127,6 @@
"allVoted": "¡Todos han votado!", "allVoted": "¡Todos han votado!",
"tapToReveal": "Pulsa para revelar el resultado", "tapToReveal": "Pulsa para revelar el resultado",
"revealResult": "Revelar resultado", "revealResult": "Revelar resultado",
"result": "Resultado", "result": "Resultado",
"revealing": "Revelando...", "revealing": "Revelando...",
"wasImpostor": "¡Era IMPOSTOR! 🎉", "wasImpostor": "¡Era IMPOSTOR! 🎉",
@@ -129,7 +135,6 @@
"seeEndResult": "Ver resultado final", "seeEndResult": "Ver resultado final",
"impostorGuessWord": "¿El impostor adivina la palabra?", "impostorGuessWord": "¿El impostor adivina la palabra?",
"nextRound": "Siguiente ronda", "nextRound": "Siguiente ronda",
"impostorGuessTitle": "🎯 Adivinanza del impostor", "impostorGuessTitle": "🎯 Adivinanza del impostor",
"impostorCanGuess": "El impostor eliminado puede\nintentar adivinar la palabra", "impostorCanGuess": "El impostor eliminado puede\nintentar adivinar la palabra",
"ifCorrectImpostorsWin": "Si acierta, ¡los impostores ganan!", "ifCorrectImpostorsWin": "Si acierta, ¡los impostores ganan!",
@@ -140,20 +145,23 @@
"theWordWas": "La palabra era: {word}", "theWordWas": "La palabra era: {word}",
"@theWordWas": { "@theWordWas": {
"placeholders": { "placeholders": {
"word": {"type": "String"} "word": {
"type": "String"
}
} }
}, },
"impostorsWin": "¡Los impostores ganan!", "impostorsWin": "¡Los impostores ganan!",
"wrongGuess": "¡No ha acertado!", "wrongGuess": "¡No ha acertado!",
"gameContinues": "La partida continúa...", "gameContinues": "La partida continúa...",
"gameOver": "Fin de partida", "gameOver": "Fin de partida",
"playersWin": "¡Los jugadores ganan!", "playersWin": "¡Los jugadores ganan!",
"theSecretWordWas": "🔍 La palabra era:", "theSecretWordWas": "🔍 La palabra era:",
"categoryLabel": "Categoría: {category}", "categoryLabel": "Categoría: {category}",
"@categoryLabel": { "@categoryLabel": {
"placeholders": { "placeholders": {
"category": {"type": "String"} "category": {
"type": "String"
}
} }
}, },
"theImpostorWas": "🎭 El impostor era:", "theImpostorWas": "🎭 El impostor era:",
@@ -162,13 +170,16 @@
"roundElimination": "Ronda {round}: {name}", "roundElimination": "Ronda {round}: {name}",
"@roundElimination": { "@roundElimination": {
"placeholders": { "placeholders": {
"round": {"type": "int"}, "round": {
"name": {"type": "String"} "type": "int"
},
"name": {
"type": "String"
}
} }
}, },
"rematch": "Revancha", "rematch": "Revancha",
"mainMenu": "Menú principal", "mainMenu": "Menú principal",
"notesTitle": "📝 Notas", "notesTitle": "📝 Notas",
"notesSaved": "Notas guardadas", "notesSaved": "Notas guardadas",
"whoAreYou": "¿Quién eres?", "whoAreYou": "¿Quién eres?",
@@ -176,14 +187,15 @@
"notesOf": "Notas de {name}", "notesOf": "Notas de {name}",
"@notesOf": { "@notesOf": {
"placeholders": { "placeholders": {
"name": {"type": "String"} "name": {
"type": "String"
}
} }
}, },
"notesAboutPlayers": "Apuntes sobre cada jugador", "notesAboutPlayers": "Apuntes sobre cada jugador",
"playerNoteHint": "¿Qué ha dicho? ¿Sospechoso?", "playerNoteHint": "¿Qué ha dicho? ¿Sospechoso?",
"freeNote": "Nota libre", "freeNote": "Nota libre",
"freeNoteHint": "Apuntes personales...", "freeNoteHint": "Apuntes personales...",
"rulesTitle": "📖 Cómo jugar", "rulesTitle": "📖 Cómo jugar",
"rulesWhatIsTitle": "🎭 ¿Qué es Farolero?", "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.", "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.", "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", "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!", "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", "joinGameTitle": "Unirse a partida",
"multiDeviceMode": "Modo multimóvil", "multiDeviceMode": "Modo multimóvil",
"scanQrDescription": "Escanea el código QR que muestra el host para conectarte a la partida vía Bluetooth/WiFi Direct.", "scanQrDescription": "Escanea el código QR que muestra el host para conectarte a la partida vía Bluetooth/WiFi Direct.",
"comingSoon": "Próximamente", "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.", "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", "back": "Volver",
"yes": "Sí", "yes": "Sí",
"no": "No", "no": "No",
"cancel": "Cancelar", "cancel": "Cancelar",
"accept": "Aceptar", "accept": "Aceptar",
"next": "Siguiente", "next": "Siguiente",
"settingsTitle": "Ajustes", "settingsTitle": "Ajustes",
"language": "Idioma", "language": "Idioma",
"soundVolume": "Volumen de efectos", "soundVolume": "Volumen de efectos",
@@ -220,5 +229,25 @@
"about": "Acerca de", "about": "Acerca de",
"version": "Versión", "version": "Versión",
"developer": "Desarrollador", "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..."
} }

View File

@@ -992,6 +992,84 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'Licencias'** /// **'Licencias'**
String get licenses; 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 class _AppLocalizationsDelegate

View File

@@ -470,4 +470,45 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get licenses => 'التراخيص'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsCa extends AppLocalizations {
@override @override
String get licenses => 'Llicències'; 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...';
} }

View File

@@ -476,4 +476,45 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get licenses => 'Lizenzen'; 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...';
} }

View File

@@ -471,4 +471,45 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get licenses => 'Licenses'; 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...';
} }

View File

@@ -472,4 +472,45 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get licenses => 'Licencias'; 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...';
} }

View File

@@ -475,4 +475,45 @@ class AppLocalizationsEu extends AppLocalizations {
@override @override
String get licenses => 'Lizentziak'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get licenses => 'Licences'; 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...';
} }

View File

@@ -472,4 +472,45 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get licenses => 'लाइसेंस'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get licenses => 'Licenze'; 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...';
} }

View File

@@ -470,4 +470,45 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get licenses => 'ライセンス'; 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...';
} }

View File

@@ -470,4 +470,45 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get licenses => '라이선스'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get licenses => 'Licenties'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get licenses => 'Licencje'; 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...';
} }

View File

@@ -474,4 +474,45 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get licenses => 'Licenças'; 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...';
} }

View File

@@ -473,4 +473,45 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get licenses => 'Лицензии'; 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...';
} }

View File

@@ -472,4 +472,45 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get licenses => 'Lisanslar'; 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...';
} }

View File

@@ -469,6 +469,47 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get licenses => '许可证'; 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`). /// The translations for Chinese, as used in Taiwan (`zh_TW`).

View File

@@ -5,6 +5,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'estado/estado_juego.dart'; import 'estado/estado_juego.dart';
import 'servicios/servicio_idioma.dart'; import 'servicios/servicio_idioma.dart';
import 'servicios/servicio_nearby.dart';
import 'tema/tema_app.dart'; import 'tema/tema_app.dart';
import 'pantallas/pantalla_principal.dart'; import 'pantallas/pantalla_principal.dart';
@@ -34,6 +35,9 @@ class FaroleroApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => ServicioIdioma()..cargar(), create: (_) => ServicioIdioma()..cargar(),
), ),
ChangeNotifierProvider(
create: (_) => ServicioNearby(),
),
], ],
child: Consumer<ServicioIdioma>( child: Consumer<ServicioIdioma>(
builder: (context, servicioIdioma, _) { builder: (context, servicioIdioma, _) {

View File

@@ -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<PantallaLobbyHost> createState() => _PantallaLobbyHostState();
}
class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
bool _iniciando = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>();
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,
),
),
),
],
),
);
}
}

View File

@@ -1,70 +1,265 @@
import 'package:flutter/material.dart'; 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 'package:farolero/l10n/generated/app_localizations.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.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}); const PantallaUnirse({super.key});
@override
State<PantallaUnirse> createState() => _PantallaUnirseState();
}
class _PantallaUnirseState extends State<PantallaUnirse> {
final _nombreController = TextEditingController();
final _formKey = GlobalKey<FormState>();
bool _escaneando = false;
bool _conectando = false;
String? _error;
String? _salaEncontrada;
@override
void dispose() {
_nombreController.dispose();
super.dispose();
}
Future<void> _iniciarEscaneo() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_escaneando = true;
_error = null;
});
}
Future<void> _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<ServicioNearby>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>();
// Si estamos conectados, mostrar pantalla de espera
if (nearby.conectado && !nearby.esHost) {
return _buildPantallaEspera(context, l10n, nearby);
}
return Scaffold( 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( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('📱', style: TextStyle(fontSize: 64)), const Text('', style: TextStyle(fontSize: 64)),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
l10n.multiDeviceMode, l10n.connectedWaiting,
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 16),
Text(
l10n.scanQrDescription,
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 12),
Container( Text(
width: double.infinity, '${l10n.yourName}: ${_nombreController.text}',
padding: const EdgeInsets.all(20), style: Theme.of(context).textTheme.bodyLarge,
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: 32), const SizedBox(height: 32),
SizedBox( const CircularProgressIndicator(color: TemaApp.colorNaranja),
width: double.infinity, const SizedBox(height: 16),
child: OutlinedButton.icon( Text(
onPressed: () => Navigator.pop(context), l10n.waitingForHost,
icon: const Icon(Icons.arrow_back), style: Theme.of(context).textTheme.bodyMedium,
label: Text(l10n.back),
),
), ),
], ],
), ),

View File

@@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart';
/// Tipos de mensajes en el protocolo P2P /// Tipos de mensajes en el protocolo P2P
enum TipoMensaje { enum TipoMensaje {
@@ -11,6 +12,8 @@ enum TipoMensaje {
unirse, unirse,
voto, voto,
listo, listo,
ping,
jugadorDesconectado,
} }
/// Mensaje del protocolo P2P entre dispositivos /// Mensaje del protocolo P2P entre dispositivos
@@ -32,107 +35,414 @@ class MensajeP2P {
datos: mapa['datos'] as Map<String, dynamic>, datos: mapa['datos'] as Map<String, dynamic>,
); );
} }
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. /// 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 { class ServicioNearby extends ChangeNotifier {
static const _serviceId = 'es.freetimelab.farolero';
bool _esHost = false; bool _esHost = false;
bool _conectado = false; bool _conectado = false;
String? _endpointId; bool _buscando = false;
final List<String> _dispositivos = []; bool _anunciando = false;
String? _miEndpointId;
String? _hostEndpointId;
String? _nombreSala;
String? _miNombre;
final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = [];
// Estado para clientes
String? _palabraRecibida;
bool? _soyImpostor;
String? _faseActual;
Map<String, dynamic>? _datosPartida;
bool get esHost => _esHost; bool get esHost => _esHost;
bool get conectado => _conectado; bool get conectado => _conectado;
String? get endpointId => _endpointId; bool get buscando => _buscando;
List<String> get dispositivos => List.unmodifiable(_dispositivos); 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<String, dynamic>? get datosPartida => _datosPartida;
List<JugadorConectado> 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) /// Inicia como host (anunciando el endpoint)
Future<bool> iniciarHost(String nombreSala) async { Future<bool> iniciarHost(String nombreSala, String miNombre) async {
// Nota: nearby_connections requiere permisos de ubicación y Bluetooth _nombreSala = nombreSala;
// que deben solicitarse antes de iniciar. _miNombre = miNombre;
// 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;
// }
_esHost = true; try {
_endpointId = nombreSala; final resultado = await Nearby().startAdvertising(
notifyListeners(); miNombre,
return true; 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 // ==================== CLIENTE ====================
Future<bool> conectarAHost(String endpointId, String nombre) async {
// Implementación con el paquete nearby_connections:
//
// try {
// await Nearby().startDiscovery(
// nombre,
// Strategy.P2P_STAR,
// onEndpointFound: (id, name, serviceId) {
// Nearby().requestConnection(nombre, id,
// onConnectionInitiated: _onConexionIniciada,
// onConnectionResult: _onResultadoConexion,
// onDisconnected: _onDesconexion,
// );
// },
// onEndpointLost: (id) {},
// serviceId: 'es.freetimelab.farolero',
// );
// return true;
// } catch (e) {
// debugPrint('Error conectando: $e');
// return false;
// }
_conectado = true; /// Busca hosts disponibles
notifyListeners(); Future<bool> buscarHosts(String miNombre) async {
return true; _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<bool> 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 /// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async { Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
// Implementación: try {
// final bytes = Uint8List.fromList(utf8.encode(mensaje.toJson())); await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
// await Nearby().sendBytesPayload(endpointId, bytes); } catch (e) {
debugPrint('Enviar a $endpointId: ${mensaje.toJson()}'); 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<void> enviarATodos(MensajeP2P mensaje) async { Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _dispositivos) { for (final id in _jugadores.keys) {
await enviarMensaje(id, mensaje); await enviarMensaje(id, mensaje);
} }
} }
/// Desconecta y limpia // ==================== HOST: ACCIONES DE JUEGO ====================
/// Host envía inicio de partida con la palabra de cada jugador
Future<void> enviarInicioPartida({
required String palabraSecreta,
required String categoria,
required Map<String, bool> 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<void> enviarCambioFase(String fase, [Map<String, dynamic>? extra]) async {
final datos = {'fase': fase, ...?extra};
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
}
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos(MensajeP2P(
tipo: TipoMensaje.votacionResultado,
datos: resultado,
));
}
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos(MensajeP2P(
tipo: TipoMensaje.partidaFin,
datos: resultado,
));
}
// ==================== LIMPIEZA ====================
/// Desconecta y limpia todo
Future<void> desconectar() async { Future<void> desconectar() async {
// await Nearby().stopAllEndpoints(); try {
// await Nearby().stopAdvertising(); await Nearby().stopAllEndpoints();
// await Nearby().stopDiscovery(); if (_anunciando) await Nearby().stopAdvertising();
if (_buscando) await Nearby().stopDiscovery();
} catch (e) {
debugPrint('Error desconectando: $e');
}
_esHost = false; _esHost = false;
_conectado = false; _conectado = false;
_endpointId = null; _buscando = false;
_dispositivos.clear(); _anunciando = false;
_miEndpointId = null;
_hostEndpointId = null;
_nombreSala = null;
_miNombre = null;
_palabraRecibida = null;
_soyImpostor = null;
_faseActual = null;
_datosPartida = null;
_jugadores.clear();
notifyListeners(); notifyListeners();
} }
@@ -140,8 +450,25 @@ class ServicioNearby extends ChangeNotifier {
String generarDatosQR(String nombreSala) { String generarDatosQR(String nombreSala) {
return json.encode({ return json.encode({
'app': 'farolero', 'app': 'farolero',
'endpoint': _endpointId,
'sala': nombreSala, 'sala': nombreSala,
'host': _miNombre,
}); });
} }
/// Parsea datos de QR escaneado
static Map<String, dynamic>? parsearQR(String datos) {
try {
final mapa = json.decode(datos) as Map<String, dynamic>;
if (mapa['app'] == 'farolero') return mapa;
return null;
} catch (_) {
return null;
}
}
@override
void dispose() {
desconectar();
super.dispose();
}
} }

View File

@@ -1,7 +1,7 @@
name: farolero name: farolero
description: "Farolero — Juego de deducción social. ¿Quién finge saber?" description: "Farolero — Juego de deducción social. ¿Quién finge saber?"
publish_to: 'none' publish_to: 'none'
version: 1.0.3+4 version: 1.1.0+5
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1