Initial web socket client support

This commit is contained in:
Nathan Anderson 2025-02-10 09:27:50 -07:00
parent e7641f6aec
commit 623474e0c6
5 changed files with 154 additions and 41 deletions
frontend/lib
shared_models/lib

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:frontend/providers/auth.dart'; import 'package:frontend/providers/auth.dart';
import 'package:frontend/providers/game_messages.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -18,25 +19,19 @@ class GameRoomHome extends ConsumerStatefulWidget {
class _GameRoomHomeState extends ConsumerState<GameRoomHome> { class _GameRoomHomeState extends ConsumerState<GameRoomHome> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final jwtAsync = ref.watch(jwtNotifierProvider); final jwt = ref.watch(jwtBodyProvider);
return Scaffold( if (jwt == null || jwt.roomUuid != widget.roomUuid) {
body: jwtAsync.when( logger.fine('Tried to open room, but not authenticated / wrong room');
data: (jwt) { // return home
if (jwt == null || jwt.roomUuid != widget.roomUuid) { context.go('/');
logger.fine('Tried to open room, but not authenticated / wrong room'); }
// return home // enstablish ws connection at /room/roomCode/ws and save to gameMessageProvider
context.go('/'); ref.read(gameMessageNotifierProvider.notifier).connect(jwt!.roomUuid);
} return Column(
return Column( children: [
children: [ Text('Authenticated.'),
Text('Authenticated.'), Text('Welcome to room ${widget.roomUuid}'),
Text('Welcome to room ${widget.roomUuid}'), ],
],
);
},
loading: () => CircularProgressIndicator(),
error: (e, st) => Text('$e, $st'),
),
); );
} }
} }

View File

@ -1,3 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -11,7 +12,7 @@ final logger = Logger('provider/auth');
@riverpod @riverpod
class JwtNotifier extends _$JwtNotifier { class JwtNotifier extends _$JwtNotifier {
@override @override
Future<JWTBody?> build() async { Future<String?> build() async {
if (!await SharedPreferencesAsync().containsKey('jwt')) { if (!await SharedPreferencesAsync().containsKey('jwt')) {
logger.fine('No JWT saved to client'); logger.fine('No JWT saved to client');
return null; return null;
@ -23,20 +24,7 @@ class JwtNotifier extends _$JwtNotifier {
return null; return null;
} }
final payload = JwtDecoder.tryDecode(jwtString); return 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;
}
} }
Future<void> setJwt(String jwt) async { Future<void> setJwt(String jwt) async {
@ -50,11 +38,30 @@ class JwtNotifier extends _$JwtNotifier {
logger.fine('Saving jwt token to shared prefs'); logger.fine('Saving jwt token to shared prefs');
await SharedPreferencesAsync().setString('jwt', jwt); await SharedPreferencesAsync().setString('jwt', jwt);
try { state = AsyncValue.data(jwt);
final jwtBody = JWTBody.fromJson(payload); }
state = AsyncValue.data(jwtBody); }
} catch (e) {
state = AsyncError(e, StackTrace.current); @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;
} }
} }

View File

@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:frontend/providers/auth.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -15,11 +16,30 @@ Dio dio(Ref ref) {
receiveTimeout: const Duration(seconds: 3), 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; 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 // Create a custom LogInterceptor using the logger object
class CustomLogInterceptor extends Interceptor { class CustomLogInterceptor extends Interceptor {
@override @override

View File

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

View File

@ -23,3 +23,51 @@ class CreateRoomResponse {
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this); 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,
};
}