From 623474e0c62c62fca458edc9f76214dba2d2ce9a Mon Sep 17 00:00:00 2001 From: Nate <n8r@tuta.io> Date: Mon, 10 Feb 2025 09:27:50 -0700 Subject: [PATCH] Initial web socket client support --- frontend/lib/features/room/game_room.dart | 33 +++++++-------- frontend/lib/providers/auth.dart | 49 +++++++++++++---------- frontend/lib/providers/dio.dart | 22 +++++++++- frontend/lib/providers/game_messages.dart | 43 ++++++++++++++++++++ shared_models/lib/room.dart | 48 ++++++++++++++++++++++ 5 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 frontend/lib/providers/game_messages.dart diff --git a/frontend/lib/features/room/game_room.dart b/frontend/lib/features/room/game_room.dart index 53d98d5..78c9a91 100644 --- a/frontend/lib/features/room/game_room.dart +++ b/frontend/lib/features/room/game_room.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:frontend/providers/auth.dart'; +import 'package:frontend/providers/game_messages.dart'; import 'package:go_router/go_router.dart'; import 'package:logging/logging.dart'; @@ -18,25 +19,19 @@ class GameRoomHome extends ConsumerStatefulWidget { class _GameRoomHomeState extends ConsumerState<GameRoomHome> { @override Widget build(BuildContext context) { - final jwtAsync = ref.watch(jwtNotifierProvider); - return Scaffold( - body: jwtAsync.when( - data: (jwt) { - if (jwt == null || jwt.roomUuid != widget.roomUuid) { - logger.fine('Tried to open room, but not authenticated / wrong room'); - // return home - context.go('/'); - } - return Column( - children: [ - Text('Authenticated.'), - Text('Welcome to room ${widget.roomUuid}'), - ], - ); - }, - loading: () => CircularProgressIndicator(), - error: (e, st) => Text('$e, $st'), - ), + final jwt = ref.watch(jwtBodyProvider); + if (jwt == null || jwt.roomUuid != widget.roomUuid) { + logger.fine('Tried to open room, but not authenticated / wrong room'); + // return home + context.go('/'); + } + // enstablish ws connection at /room/roomCode/ws and save to gameMessageProvider + ref.read(gameMessageNotifierProvider.notifier).connect(jwt!.roomUuid); + return Column( + children: [ + Text('Authenticated.'), + Text('Welcome to room ${widget.roomUuid}'), + ], ); } } diff --git a/frontend/lib/providers/auth.dart b/frontend/lib/providers/auth.dart index 1646e35..1875d9e 100644 --- a/frontend/lib/providers/auth.dart +++ b/frontend/lib/providers/auth.dart @@ -1,3 +1,4 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -11,7 +12,7 @@ final logger = Logger('provider/auth'); @riverpod class JwtNotifier extends _$JwtNotifier { @override - Future<JWTBody?> build() async { + Future<String?> build() async { if (!await SharedPreferencesAsync().containsKey('jwt')) { logger.fine('No JWT saved to client'); return null; @@ -23,20 +24,7 @@ class JwtNotifier extends _$JwtNotifier { return null; } - final payload = JwtDecoder.tryDecode(jwtString); - if (payload == null) { - logger.fine('Failed to decode JWT, removing key.'); - SharedPreferencesAsync().remove('jwt'); - return null; - } - - try { - final body = JWTBody.fromJson(payload); - return body; - } catch (e) { - logger.shout('Failed to parse JWT payload to JWTBody, something is wrong:', e, StackTrace.current); - return null; - } + return jwtString; } Future<void> setJwt(String jwt) async { @@ -50,11 +38,30 @@ class JwtNotifier extends _$JwtNotifier { logger.fine('Saving jwt token to shared prefs'); await SharedPreferencesAsync().setString('jwt', jwt); - try { - final jwtBody = JWTBody.fromJson(payload); - state = AsyncValue.data(jwtBody); - } catch (e) { - state = AsyncError(e, StackTrace.current); - } + state = AsyncValue.data(jwt); + } +} + +@riverpod +JWTBody? jwtBody(Ref ref) { + final jwtString = ref.watch(jwtNotifierProvider).valueOrNull; + if (jwtString == null) { + return null; + } + + final payload = JwtDecoder.tryDecode(jwtString); + if (payload == null) { + logger.fine('Failed to decode JWT, removing key.'); + SharedPreferencesAsync().remove('jwt'); + ref.invalidate(jwtNotifierProvider); + return null; + } + try { + final body = JWTBody.fromJson(payload); + return body; + } catch (e) { + logger.shout( + 'Failed to parse JWT payload to JWTBody, something is wrong.\nPayload: $payload', e, StackTrace.current); + return null; } } diff --git a/frontend/lib/providers/dio.dart b/frontend/lib/providers/dio.dart index edd4dc4..9cc0dda 100644 --- a/frontend/lib/providers/dio.dart +++ b/frontend/lib/providers/dio.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/providers/auth.dart'; import 'package:logging/logging.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -15,11 +16,30 @@ Dio dio(Ref ref) { receiveTimeout: const Duration(seconds: 3), )); - dio.interceptors.add(LogInterceptor(responseBody: true)); + final jwt = ref.watch(jwtNotifierProvider).valueOrNull; + + dio.interceptors.addAll([JwtInterceptor(jwt: jwt), CustomLogInterceptor()]); + + logger.fine('Created new Dio object'); return dio; } +// Adds the jwt to +class JwtInterceptor extends Interceptor { + final String? jwt; + + JwtInterceptor({required this.jwt}); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + if (jwt != null) { + options.headers['Authorization'] = 'Bearer $jwt'; + } + handler.next(options); + } +} + // Create a custom LogInterceptor using the logger object class CustomLogInterceptor extends Interceptor { @override diff --git a/frontend/lib/providers/game_messages.dart b/frontend/lib/providers/game_messages.dart new file mode 100644 index 0000000..29c6adf --- /dev/null +++ b/frontend/lib/providers/game_messages.dart @@ -0,0 +1,43 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:frontend/providers/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_models/room.dart'; + +part 'game_messages.g.dart'; + +@riverpod +class GameMessageNotifier extends _$GameMessageNotifier { + @override + Stream<GameRoomMessage> build() { + return Stream.empty(); + } + + Future<void> connect(String gameRoomUuid) async { + final dio = ref.read(dioProvider); + Uri.parse('ws://localhost:8080/room/$gameRoomUuid/ws'); + + // connect to websocket and then set stream of websocket to state + final wsUrl = Uri.parse('ws://localhost:8080/room/$gameRoomUuid/ws'); + + final connection = await WebSocket.connect(wsUrl.toString()); + + final Stream<GameRoomMessage> gameRoomStream = connection.map((message) { + try { + if (message is String) { + GameRoomMessage.fromJson(jsonDecode(message) as Map<String, dynamic>); + } else { + logger.info('Recieved non-string message in socket: $message'); + } + } catch (e) { + print('Error parsing message: $e'); + rethrow; + } + }); + gameRoomStream.listen( + (event) => state = AsyncValue.data(event), + ); + } +} diff --git a/shared_models/lib/room.dart b/shared_models/lib/room.dart index 9fbb011..aade6ae 100644 --- a/shared_models/lib/room.dart +++ b/shared_models/lib/room.dart @@ -23,3 +23,51 @@ class CreateRoomResponse { Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this); } + +sealed class GameRoomMessage { + const GameRoomMessage(); + + factory GameRoomMessage.fromJson(Map<String, dynamic> json) { + return switch (json['type']) { + 'ping' => PingMessage.fromJson(json), + 'playerVote' => PlayerVoteMessage.fromJson(json), + _ => throw Exception('Unknown message type'), + }; + } + + Map<String, dynamic> toJson(); +} + +class PingMessage extends GameRoomMessage { + final DateTime timestamp; + + const PingMessage({required this.timestamp}); + + factory PingMessage.fromJson(Map<String, dynamic> json) => + PingMessage(timestamp: DateTime.parse(json['timestamp'] as String)); + + @override + Map<String, dynamic> toJson() => { + 'type': 'ping', + 'timestamp': timestamp.toIso8601String(), + }; +} + +class PlayerVoteMessage extends GameRoomMessage { + final String playerUuid; + final int vote; + + const PlayerVoteMessage({required this.playerUuid, required this.vote}); + + factory PlayerVoteMessage.fromJson(Map<String, dynamic> json) => PlayerVoteMessage( + playerUuid: json['playerUuid'] as String, + vote: json['vote'] as int, + ); + + @override + Map<String, dynamic> toJson() => { + 'type': 'playerVote', + 'playerUuid': playerUuid, + 'vote': vote, + }; +}