Make games reloadable #12

Merged
marco merged 1 commits from simplify-flow into master 2024-05-19 19:48:28 +00:00
12 changed files with 273 additions and 229 deletions

View File

@ -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<String, dynamic> json) {
factory GameInfo.fromJson(Map<String, dynamic> 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<PlayerInfo?> get() async {
static Future<GameInfo?> 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);

View File

@ -31,7 +31,7 @@ class ChessApp extends StatelessWidget {
useMaterial3: true,
),
routerConfig: ChessAppRouter.getInstance().router,
title: 'mChess 1.0.5',
title: 'mChess 1.0.6',
),
);
}

View File

@ -51,10 +51,12 @@ class ChessBloc extends Bloc<ChessEvent, ChessBoardState> {
void flipBoard(ColorDetermined event, Emitter<ChessBoardState> emit) {
log("My Color is $myColor");
myColor = event.myColor;
myColor = event.playerColor;
emit(
ChessBoardState(
event.myColor,
event.playerColor,
state.newTurnColor,
state.position,
ChessMove.none(),
@ -74,12 +76,13 @@ class ChessBloc extends Bloc<ChessEvent, ChessBoardState> {
.recordMove(event.startSquare, event.endSquare, event.position);
}
myColor = event.playerColor;
turnColor = event.turnColor;
emit(
ChessBoardState(
state.bottomColor,
turnColor,
myColor,
event.turnColor,
event.position,
move,
true,

View File

@ -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,
});
}
@ -38,9 +40,9 @@ class InitBoard extends ChessEvent {
}
class ColorDetermined extends ChessEvent {
final ChessColor myColor;
final ChessColor playerColor;
ColorDetermined({required this.myColor});
ColorDetermined({required this.playerColor});
}
class InvalidMovePlayed extends ChessEvent {

View File

@ -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');
@ -118,7 +119,7 @@ class ServerConnection {
ConnectionCubit.getInstance().opponentConnected();
ChessBloc.getInstance().add(InitBoard());
ChessBloc.getInstance().add(ColorDetermined(
myColor: ChessColor.fromApiColor(apiMessage.playerColor!)));
playerColor: ChessColor.fromApiColor(apiMessage.playerColor!)));
}
void handleInvalidMoveMessage(ApiWebsocketMessage apiMessage) {

View File

@ -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<ChessGame> createState() => _ChessGameState();

View File

@ -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<HostGameWidget> createState() => _HostGameWidgetState();
State<CreateGameWidget> createState() => _CreateGameWidgetState();
}
class _HostGameWidgetState extends State<HostGameWidget> {
late Future<PlayerInfo?> registerResponse;
class _CreateGameWidgetState extends State<CreateGameWidget> {
late Future<GameInfo?> 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<PlayerInfo?> resp) {
void connectToWebsocket(Future<GameInfo?> 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<HostGameWidget> {
@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<PlayerInfo?>(
child: FutureBuilder<GameInfo?>(
future: registerResponse,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
@ -67,8 +87,10 @@ class _HostGameWidgetState extends State<HostGameWidget> {
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<HostGameWidget> {
);
}
Future<PlayerInfo?> hostPrivateGame() async {
Future<GameInfo?> 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<HostGameWidget> {
}
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;

View File

@ -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<JoinGameHandleWidget> createState() => _JoinGameHandleWidgetState();
}
class _JoinGameHandleWidgetState extends State<JoinGameHandleWidget> {
late Future<GameInfo?> 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<GameInfo?> 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;
}
}

View File

@ -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<LobbySelector> {
final buttonStyle = const ButtonStyle();
final phraseController = TextEditingController();
late Future<PlayerInfo?> joinGameFuture;
@override
Widget build(BuildContext context) {
@ -31,7 +20,7 @@ class _LobbySelectorState extends State<LobbySelector> {
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<LobbySelector> {
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<LobbySelector> {
);
}
Future<void> buildJoinOrHostDialog(BuildContext context) {
//TODO: find a better place to disconnect old websocket connection
ServerConnection.getInstance().disconnectExistingConnection();
return showDialog<void>(
context: context,
builder: (BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: AlertDialog(
title: const Text('Host or join?'),
actions: <Widget>[
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<void> buildEnterPassphraseDialog(BuildContext context) {
return showDialog<void>(
context: context,
@ -97,15 +64,11 @@ class _LobbySelectorState extends State<LobbySelector> {
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<LobbySelector> {
);
}
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<PlayerInfo?> 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();
}
}

View File

@ -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<NavigatorState>();
@ -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(),
);
},
)

30
lib/utils/passphrase.dart Normal file
View File

@ -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();
}
}

View File

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