From 6cede55d44f77522ca12e7c112819f8fbe10fb82 Mon Sep 17 00:00:00 2001 From: Marco Date: Sun, 19 May 2024 21:18:47 +0200 Subject: [PATCH] Simplify flow and allow site reloads --- lib/api/{register.dart => game_info.dart} | 16 +- lib/chess_bloc/chess_bloc.dart | 6 +- lib/chess_bloc/chess_events.dart | 2 + lib/connection/ws_connection.dart | 19 +- lib/pages/chess_game.dart | 9 +- ...host_game.dart => create_game_widget.dart} | 64 +++++-- lib/pages/join_game_handle_widget.dart | 126 ++++++++++++ lib/pages/lobby_selector.dart | 180 +++--------------- lib/utils/chess_router.dart | 30 +-- lib/utils/config.dart | 2 +- lib/utils/passphrase.dart | 30 +++ test/widget_test.dart | 7 +- 12 files changed, 265 insertions(+), 226 deletions(-) rename lib/api/{register.dart => game_info.dart} (88%) rename lib/pages/{host_game.dart => create_game_widget.dart} (71%) create mode 100644 lib/pages/join_game_handle_widget.dart create mode 100644 lib/utils/passphrase.dart diff --git a/lib/api/register.dart b/lib/api/game_info.dart similarity index 88% rename from lib/api/register.dart rename to lib/api/game_info.dart index 927b7c2..c34adc7 100644 --- a/lib/api/register.dart +++ b/lib/api/game_info.dart @@ -1,27 +1,27 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; -class PlayerInfo { +class GameInfo { final UuidValue? playerID; final UuidValue? lobbyID; final String? passphrase; - const PlayerInfo({ + const GameInfo({ required this.playerID, required this.lobbyID, required this.passphrase, }); - factory PlayerInfo.empty() { - return const PlayerInfo(playerID: null, lobbyID: null, passphrase: null); + factory GameInfo.empty() { + return const GameInfo(playerID: null, lobbyID: null, passphrase: null); } - factory PlayerInfo.fromJson(Map json) { + factory GameInfo.fromJson(Map json) { final playerid = UuidValue.fromString(json['playerID']); final lobbyid = UuidValue.fromString(json['lobbyID']); final passphrase = json['passphrase']; - return PlayerInfo( + return GameInfo( playerID: playerid, lobbyID: lobbyid, passphrase: passphrase); } @@ -59,7 +59,7 @@ class PlayerInfo { await prefs.setBool("contains", false); } - static Future get() async { + static Future get() async { final SharedPreferences prefs = await SharedPreferences.getInstance(); var contains = prefs.getBool("contains"); var playerID = prefs.getString("playerID"); @@ -74,7 +74,7 @@ class PlayerInfo { return null; } - return PlayerInfo( + return GameInfo( playerID: UuidValue.fromString(playerID), lobbyID: UuidValue.fromString(lobbyID), passphrase: passphrase); diff --git a/lib/chess_bloc/chess_bloc.dart b/lib/chess_bloc/chess_bloc.dart index ab63ebe..43ec3d6 100644 --- a/lib/chess_bloc/chess_bloc.dart +++ b/lib/chess_bloc/chess_bloc.dart @@ -74,12 +74,10 @@ class ChessBloc extends Bloc { .recordMove(event.startSquare, event.endSquare, event.position); } - turnColor = event.turnColor; - emit( ChessBoardState( - state.bottomColor, - turnColor, + event.playerColor, + event.turnColor, event.position, move, true, diff --git a/lib/chess_bloc/chess_events.dart b/lib/chess_bloc/chess_events.dart index d65d1e8..2b8d873 100644 --- a/lib/chess_bloc/chess_events.dart +++ b/lib/chess_bloc/chess_events.dart @@ -9,6 +9,7 @@ class ReceivedBoardState extends ChessEvent { final ChessPosition position; final ChessCoordinate squareInCheck; final ChessColor turnColor; + final ChessColor playerColor; ReceivedBoardState({ required this.startSquare, @@ -16,6 +17,7 @@ class ReceivedBoardState extends ChessEvent { required this.position, required this.squareInCheck, required this.turnColor, + required this.playerColor, }); } diff --git a/lib/connection/ws_connection.dart b/lib/connection/ws_connection.dart index 3c43b57..bd94c71 100644 --- a/lib/connection/ws_connection.dart +++ b/lib/connection/ws_connection.dart @@ -6,7 +6,7 @@ import 'package:mchess/api/move.dart'; import 'package:mchess/api/websocket_message.dart'; import 'package:mchess/chess_bloc/chess_bloc.dart'; import 'package:mchess/chess_bloc/chess_events.dart'; -import 'package:mchess/api/register.dart'; +import 'package:mchess/api/game_info.dart'; import 'package:mchess/chess_bloc/chess_position.dart'; import 'package:mchess/connection_cubit/connection_cubit.dart'; import 'package:mchess/utils/chess_router.dart'; @@ -100,14 +100,15 @@ class ServerConnection { if (apiMessage.position != null) { ChessBloc.getInstance().add( ReceivedBoardState( - startSquare: move?.from, - endSquare: move?.to, - position: ChessPositionManager.getInstance() - .fromPGNString(apiMessage.position!), - squareInCheck: ChessCoordinate.fromApiCoordinate( - apiMessage.squareInCheck ?? - const ApiCoordinate(col: 0, row: 0)), - turnColor: ChessColor.fromApiColor(apiMessage.turnColor!)), + startSquare: move?.from, + endSquare: move?.to, + position: ChessPositionManager.getInstance() + .fromPGNString(apiMessage.position!), + squareInCheck: ChessCoordinate.fromApiCoordinate( + apiMessage.squareInCheck ?? const ApiCoordinate(col: 0, row: 0)), + turnColor: ChessColor.fromApiColor(apiMessage.turnColor!), + playerColor: ChessColor.fromApiColor(apiMessage.playerColor!), + ), ); } else { log('Error: no position received'); diff --git a/lib/pages/chess_game.dart b/lib/pages/chess_game.dart index 13f33ce..0236268 100644 --- a/lib/pages/chess_game.dart +++ b/lib/pages/chess_game.dart @@ -12,14 +12,7 @@ import 'package:universal_platform/universal_platform.dart'; import 'package:uuid/uuid.dart'; class ChessGame extends StatefulWidget { - final UuidValue playerID; - final UuidValue lobbyID; - final String? passphrase; - const ChessGame( - {required this.playerID, - required this.lobbyID, - required this.passphrase, - super.key}); + const ChessGame({super.key}); @override State createState() => _ChessGameState(); diff --git a/lib/pages/host_game.dart b/lib/pages/create_game_widget.dart similarity index 71% rename from lib/pages/host_game.dart rename to lib/pages/create_game_widget.dart index 757b171..5db7430 100644 --- a/lib/pages/host_game.dart +++ b/lib/pages/create_game_widget.dart @@ -5,41 +5,48 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; -import 'package:mchess/api/register.dart'; +import 'package:mchess/api/game_info.dart'; import 'package:mchess/connection_cubit/connection_cubit.dart'; import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:mchess/pages/chess_game.dart'; import 'package:mchess/utils/config.dart' as config; +import 'package:mchess/utils/passphrase.dart'; +import 'package:universal_platform/universal_platform.dart'; -class HostGameWidget extends StatefulWidget { - const HostGameWidget({super.key}); +class CreateGameWidget extends StatefulWidget { + const CreateGameWidget({super.key}); @override - State createState() => _HostGameWidgetState(); + State createState() => _CreateGameWidgetState(); } -class _HostGameWidgetState extends State { - late Future registerResponse; +class _CreateGameWidgetState extends State { + late Future registerResponse; late ChessGameArguments chessGameArgs; @override void initState() { registerResponse = hostPrivateGame(); + + registerResponse.then((args) { + if (args == null) return; + + chessGameArgs = ChessGameArguments( + lobbyID: args.lobbyID!, + playerID: args.playerID!, + passphrase: args.passphrase); + }); + connectToWebsocket(registerResponse); super.initState(); } - void connectToWebsocket(Future resp) { + void connectToWebsocket(Future resp) { resp.then((value) { if (value == null) return; - chessGameArgs = ChessGameArguments( - lobbyID: value.lobbyID!, - playerID: value.playerID!, - passphrase: value.passphrase); - ConnectionCubit.getInstance().connect( value.playerID!.uuid, value.lobbyID!.uuid, @@ -50,9 +57,22 @@ class _HostGameWidgetState extends State { @override Widget build(BuildContext context) { + FloatingActionButton? fltnBtn; + if (UniversalPlatform.isLinux || + UniversalPlatform.isMacOS || + UniversalPlatform.isWindows) { + fltnBtn = FloatingActionButton( + child: const Icon(Icons.cancel), + onPressed: () { + context.pop(); + }, + ); + } + return Scaffold( + floatingActionButton: fltnBtn, body: Center( - child: FutureBuilder( + child: FutureBuilder( future: registerResponse, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { @@ -67,8 +87,10 @@ class _HostGameWidgetState extends State { listener: (context, state) { // We wait for our opponent to connect if (state.opponentConnected) { - snapshot.data?.store(); - context.pushReplacement('/game', extra: chessGameArgs); + //TODO: is goNamed the correct way to navigate? + context.goNamed('game', + pathParameters: {'phrase': passphrase.toURL()}, + extra: chessGameArgs); } }, child: Column( @@ -108,22 +130,22 @@ class _HostGameWidgetState extends State { ); } - Future hostPrivateGame() async { + Future hostPrivateGame() async { Response response; try { response = await http.get(Uri.parse(config.getHostURL()), headers: {"Accept": "application/json"}); } catch (e) { - log(e.toString()); - - if (!context.mounted) return null; + log('Exception: ${e.toString()}'); const snackBar = SnackBar( backgroundColor: Colors.amberAccent, content: Text("mChess server is not responding. Try again or give up"), ); Future.delayed(const Duration(seconds: 2), () { + if (!context.mounted) return null; + ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar(snackBar); context.goNamed('lobbySelector'); // We go back to the lobby selector @@ -132,8 +154,8 @@ class _HostGameWidgetState extends State { } if (response.statusCode == 200) { - log(response.body); - var info = PlayerInfo.fromJson(jsonDecode(response.body)); + var info = GameInfo.fromJson(jsonDecode(response.body)); + info.store(); return info; } return null; diff --git a/lib/pages/join_game_handle_widget.dart b/lib/pages/join_game_handle_widget.dart new file mode 100644 index 0000000..4d865c5 --- /dev/null +++ b/lib/pages/join_game_handle_widget.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:mchess/api/game_info.dart'; +import 'package:mchess/connection_cubit/connection_cubit.dart'; +import 'package:mchess/pages/chess_game.dart'; +import 'package:mchess/utils/config.dart' as config; + +class JoinGameHandleWidget extends StatefulWidget { + final String passphrase; + const JoinGameHandleWidget({required this.passphrase, super.key}); + + @override + State createState() => _JoinGameHandleWidgetState(); +} + +class _JoinGameHandleWidgetState extends State { + late Future joinGameFuture; + + @override + void initState() { + joinGameFuture = joinPrivateGame(widget.passphrase); + joinGameFuture.then( + (value) { + if (value != null) { + switchToGame(value); + } + }, + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return const ChessGame(); + } + + void switchToGame(GameInfo info) { + var chessGameArgs = ChessGameArguments( + lobbyID: info.lobbyID!, + playerID: info.playerID!, + passphrase: info.passphrase); + + ConnectionCubit.getInstance().connect( + info.playerID!.uuid, + info.lobbyID!.uuid, + info.passphrase, + ); + + if (!chessGameArgs.isValid()) { + context.goNamed('lobbySelector'); + const snackBar = SnackBar( + backgroundColor: Colors.amberAccent, + content: Text("Game information is corrupted"), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + + return; + } + } + + Future joinPrivateGame(String phrase) async { + http.Response response; + var existingInfo = await GameInfo.get(); + log('lobbyID: ${existingInfo?.lobbyID} and playerID: ${existingInfo?.playerID} and passphrase: "${existingInfo?.passphrase}"'); + + GameInfo info; + if (existingInfo?.passphrase == phrase) { + // We have player info for this exact passphrase + info = GameInfo( + playerID: existingInfo?.playerID, + lobbyID: existingInfo?.lobbyID, + passphrase: phrase); + } else { + info = GameInfo(playerID: null, lobbyID: null, passphrase: phrase); + } + + try { + response = await http.post(Uri.parse(config.getJoinURL()), + body: jsonEncode(info), headers: {"Accept": "application/json"}); + } catch (e) { + log(e.toString()); + + if (!context.mounted) return null; + + const snackBar = SnackBar( + backgroundColor: Colors.amberAccent, + content: Text("mChess server is not responding. Try again or give up"), + ); + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + return null; + } + + if (response.statusCode == HttpStatus.notFound) { + const snackBar = SnackBar( + backgroundColor: Colors.amberAccent, + content: Text("Passphrase could not be found."), + ); + + if (!context.mounted) return null; + + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + return null; + } + + if (response.statusCode == HttpStatus.ok) { + var info = GameInfo.fromJson(jsonDecode(response.body)); + info.store(); + log('Player info received from server: '); + log('lobbyID: ${info.lobbyID}'); + log('playerID: ${info.playerID}'); + log('passphrase: ${info.passphrase}'); + + return info; + } + + return null; + } +} diff --git a/lib/pages/lobby_selector.dart b/lib/pages/lobby_selector.dart index 5c3385b..353d73b 100644 --- a/lib/pages/lobby_selector.dart +++ b/lib/pages/lobby_selector.dart @@ -1,15 +1,6 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:http/http.dart' as http; -import 'package:mchess/api/register.dart'; -import 'package:mchess/connection/ws_connection.dart'; -import 'package:mchess/connection_cubit/connection_cubit.dart'; -import 'package:mchess/pages/chess_game.dart'; -import 'package:mchess/utils/config.dart' as config; +import 'package:mchess/utils/passphrase.dart'; class LobbySelector extends StatefulWidget { const LobbySelector({super.key}); @@ -19,9 +10,7 @@ class LobbySelector extends StatefulWidget { } class _LobbySelectorState extends State { - final buttonStyle = const ButtonStyle(); final phraseController = TextEditingController(); - late Future joinGameFuture; @override Widget build(BuildContext context) { @@ -31,7 +20,7 @@ class _LobbySelectorState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( - onPressed: () => buildJoinOrHostDialog(context), + onPressed: () => context.goNamed('createGame'), child: const Row( mainAxisSize: MainAxisSize.min, children: [ @@ -39,7 +28,21 @@ class _LobbySelectorState extends State { SizedBox( width: 10, ), - Text('Private game') + Text('Create private game') + ], + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () => buildEnterPassphraseDialog(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.mail), + SizedBox( + width: 10, + ), + Text('Join private game') ], ), ), @@ -49,42 +52,6 @@ class _LobbySelectorState extends State { ); } - Future buildJoinOrHostDialog(BuildContext context) { - //TODO: find a better place to disconnect old websocket connection - ServerConnection.getInstance().disconnectExistingConnection(); - - return showDialog( - context: context, - builder: (BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - body: AlertDialog( - title: const Text('Host or join?'), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () => context.pop(), - ), - TextButton( - child: const Text('Host'), - onPressed: () { - context.pop(); //close dialog before going to host - context.goNamed('host'); - }), - TextButton( - child: const Text('Join'), - onPressed: () { - context.pop(); //close dialog before going to next dialog - buildEnterPassphraseDialog(context); - }, - ), - ], - ), - ); - }, - ); - } - Future buildEnterPassphraseDialog(BuildContext context) { return showDialog( context: context, @@ -97,15 +64,11 @@ class _LobbySelectorState extends State { title: const Text('Enter the passphrase here:'), content: TextField( controller: phraseController, - onSubmitted: (val) { - submitPassphrase(builderContext); - }, + onSubmitted: (val) => submitAction(val), decoration: InputDecoration( hintText: 'Enter passphrase here', suffixIcon: IconButton( - onPressed: () { - submitPassphrase(builderContext); - }, + onPressed: () => submitAction(phraseController.text), icon: const Icon(Icons.check), )), ), @@ -125,107 +88,8 @@ class _LobbySelectorState extends State { ); } - void submitPassphrase(BuildContext ctx) { - joinGameFuture = joinPrivateGame(ctx); - joinGameFuture.then((value) { - if (value != null) { - phraseController.clear(); - ctx.pop(); - switchToGame(value); - } - }); - } - - void switchToGame(PlayerInfo info) { - var chessGameArgs = ChessGameArguments( - lobbyID: info.lobbyID!, - playerID: info.playerID!, - passphrase: info.passphrase); - - ConnectionCubit.getInstance().connect( - info.playerID!.uuid, - info.lobbyID!.uuid, - info.passphrase, - ); - - if (!chessGameArgs.isValid()) { - context.goNamed('lobbySelector'); - const snackBar = SnackBar( - backgroundColor: Colors.amberAccent, - content: Text("Game information is corrupted"), - ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - - return; - } - - context.goNamed('game', extra: chessGameArgs); - } - - Future joinPrivateGame(BuildContext context) async { - http.Response response; - - var existingInfo = await PlayerInfo.get(); - - log("lobbyID: ${existingInfo?.lobbyID} and playerID: ${existingInfo?.playerID} and passphrase: ${existingInfo?.passphrase}"); - - PlayerInfo info; - if (existingInfo?.passphrase == phraseController.text) { - // We have player info for this exact passphrase - info = PlayerInfo( - playerID: existingInfo?.playerID, - lobbyID: existingInfo?.lobbyID, - passphrase: phraseController.text); - } else { - info = PlayerInfo( - playerID: null, lobbyID: null, passphrase: phraseController.text); - } - - var decodedInfo = jsonEncode(info); - log("decodedInfo: $decodedInfo"); - - try { - response = await http.post(Uri.parse(config.getJoinURL()), - body: decodedInfo, headers: {"Accept": "application/json"}); - } catch (e) { - log(e.toString()); - - if (!context.mounted) return null; - - const snackBar = SnackBar( - backgroundColor: Colors.amberAccent, - content: Text("mChess server is not responding. Try again or give up"), - ); - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - return null; - } - - if (response.statusCode == HttpStatus.notFound) { - const snackBar = SnackBar( - backgroundColor: Colors.amberAccent, - content: Text("Passphrase could not be found."), - ); - - if (!context.mounted) return null; - - ScaffoldMessenger.of(context).clearSnackBars(); - ScaffoldMessenger.of(context).showSnackBar(snackBar); - return null; - } - - if (response.statusCode == HttpStatus.ok) { - var info = PlayerInfo.fromJson(jsonDecode(response.body)); - info.store(); - log('Player info received from server: '); - log('lobbyID: ${info.lobbyID}'); - log('playerID: ${info.playerID}'); - log('passphrase: ${info.passphrase}'); - - return info; - } - - return null; + void submitAction(String phrase) { + context.goNamed('game', pathParameters: {'phrase': phrase.toURL()}); + phraseController.clear(); } } diff --git a/lib/utils/chess_router.dart b/lib/utils/chess_router.dart index b9f667a..aab6974 100644 --- a/lib/utils/chess_router.dart +++ b/lib/utils/chess_router.dart @@ -1,8 +1,12 @@ +import 'dart:developer'; + import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; -import 'package:mchess/pages/chess_game.dart'; +import 'package:mchess/connection/ws_connection.dart'; +import 'package:mchess/pages/join_game_handle_widget.dart'; import 'package:mchess/pages/lobby_selector.dart'; -import 'package:mchess/pages/host_game.dart'; +import 'package:mchess/pages/create_game_widget.dart'; +import 'package:mchess/utils/passphrase.dart'; final navigatorKey = GlobalKey(); @@ -27,21 +31,25 @@ class ChessAppRouter { }, routes: [ GoRoute( - path: 'host', - name: 'host', + path: 'createGame', + name: 'createGame', builder: (context, state) { - return const HostGameWidget(); + return const CreateGameWidget(); }), GoRoute( - path: 'game', + path: 'game/:phrase', name: 'game', builder: (context, state) { - var args = state.extra as ChessGameArguments; + ServerConnection.getInstance().disconnectExistingConnection(); - return ChessGame( - lobbyID: args.lobbyID, - playerID: args.playerID, - passphrase: args.passphrase, + var urlPhrase = state.pathParameters['phrase']; + if (urlPhrase == null) { + log('in /game route builder: url phrase null'); + return const LobbySelector(); + } + + return JoinGameHandleWidget( + passphrase: urlPhrase.toPhraseWithSpaces(), ); }, ) diff --git a/lib/utils/config.dart b/lib/utils/config.dart index 863dedb..c110b8a 100644 --- a/lib/utils/config.dart +++ b/lib/utils/config.dart @@ -1,7 +1,7 @@ const prodURL = 'chess.sw-gross.de:9999'; const debugURL = 'localhost:8080'; -const useDbgUrl = false; +const useDbgUrl = true; String getHostURL() { var prot = 'https'; diff --git a/lib/utils/passphrase.dart b/lib/utils/passphrase.dart new file mode 100644 index 0000000..fa2f7a3 --- /dev/null +++ b/lib/utils/passphrase.dart @@ -0,0 +1,30 @@ +extension PassphaseURL on String { + String capitalize() { + return "${this[0].toUpperCase()}${substring(1).toLowerCase()}"; + } + + String toURL() { + var words = split(' '); + + for (var i = 0; i < words.length; i++) { + words[i] = words[i].capitalize(); + } + + return words.join(); + } + + String toPhraseWithSpaces() { + var phrase = ''; + + for (var i = 0; i < length; i++) { + if (this[i] == this[i].toUpperCase()) { + phrase += ' '; + } + phrase += this[i].toLowerCase(); + } + + phrase = phrase.trim(); + + return phrase.toLowerCase(); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 9ac2aef..721f7e3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -8,16 +8,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mchess/pages/chess_game.dart'; -import 'package:uuid/uuid.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(ChessGame( - playerID: UuidValue.fromString("test"), - lobbyID: UuidValue.fromString("testLobbyId"), - passphrase: 'test', - )); + await tester.pumpWidget(const ChessGame()); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);