diff --git a/assets/schema.json b/assets/schema.json new file mode 100644 index 0000000..17d7faa --- /dev/null +++ b/assets/schema.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Generated schema for cabo game data", + "type": "array", + "items": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "gameTitle": { + "type": "string" + }, + "players": { + "type": "array", + "items": { + "type": "string" + } + }, + "pointLimit": { + "type": "number" + }, + "caboPenalty": { + "type": "number" + }, + "isPointsLimitEnabled": { + "type": "boolean" + }, + "isGameFinished": { + "type": "boolean" + }, + "winner": { + "type": "string" + }, + "roundNumber": { + "type": "number" + }, + "playerScores": { + "type": "array", + "items": { + "type": "number" + } + }, + "roundList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roundNum": { + "type": "number" + }, + "caboPlayerIndex": { + "type": "number" + }, + "kamikazePlayerIndex": { + "type": ["number", "null"] + }, + "scores": { + "type": "array", + "items": { + "type": "number" + } + }, + "scoreUpdates": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "roundNum", + "caboPlayerIndex", + "scores", + "scoreUpdates" + ] + } + } + }, + "required": [ + "createdAt", + "gameTitle", + "players", + "pointLimit", + "caboPenalty", + "isPointsLimitEnabled", + "isGameFinished", + "winner", + "roundNumber", + "playerScores", + "roundList" + ] + } +} \ No newline at end of file diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index 09c31a1..b20d6d0 100644 --- a/lib/data/game_session.dart +++ b/lib/data/game_session.dart @@ -63,7 +63,7 @@ class GameSession { players = List.from(json['players']), pointLimit = json['pointLimit'], caboPenalty = json['caboPenalty'], - isPointsLimitEnabled = json['gameHasPointLimit'], + isPointsLimitEnabled = json['isPointsLimitEnabled'], isGameFinished = json['isGameFinished'], winner = json['winner'], roundNumber = json['roundNumber'], @@ -91,7 +91,7 @@ class GameSession { } } addRoundScoresToList( - roundNum, roundScores, scoreUpdates, kamikazePlayerIndex); + roundNum, roundScores, scoreUpdates, 0, kamikazePlayerIndex); } /// Checks the scores of the current round and assigns points to the players. @@ -125,7 +125,7 @@ class GameSession { print('${players[caboPlayerIndex]} hat CABO gesagt ' 'und bekommt 0 Punkte'); print('Alle anderen Spieler bekommen ihre Punkte'); - _assignPoints(roundNum, roundScores, [caboPlayerIndex]); + _assignPoints(roundNum, roundScores, caboPlayerIndex, [caboPlayerIndex]); } else { // A player other than the one who said CABO has the fewest points. print('${players[caboPlayerIndex]} hat CABO gesagt, ' @@ -134,7 +134,8 @@ class GameSession { for (int i in lowestScoreIndex) { print('${players[i]}: ${roundScores[i]} Punkte'); } - _assignPoints(roundNum, roundScores, lowestScoreIndex, caboPlayerIndex); + _assignPoints(roundNum, roundScores, caboPlayerIndex, lowestScoreIndex, + caboPlayerIndex); } } @@ -160,8 +161,9 @@ class GameSession { /// [roundNum] is the number of the current round. /// [roundScores] is the raw list of the scores of all players in the current round. /// [winnerIndex] is the index of the player who receives 5 extra points - void _assignPoints(int roundNum, List roundScores, List winnerIndex, - [int loserIndex = -1]) { + void _assignPoints(int roundNum, List roundScores, int caboPlayerIndex, + List winnerIndex, + [int? loserIndex]) { /// List of the updates for every player score List scoreUpdates = [...roundScores]; print('Folgende Punkte wurden aus der Runde übernommen:'); @@ -172,7 +174,7 @@ class GameSession { print('${players[i]} hat gewonnen und bekommt 0 Punkte'); scoreUpdates[i] = 0; } - if (loserIndex != -1) { + if (loserIndex != null) { print('${players[loserIndex]} bekommt 5 Fehlerpunkte'); scoreUpdates[loserIndex] += 5; } @@ -181,7 +183,7 @@ class GameSession { print('${players[i]}: ${scoreUpdates[i]}'); } print('scoreUpdates: $scoreUpdates, roundScores: $roundScores'); - addRoundScoresToList(roundNum, roundScores, scoreUpdates); + addRoundScoresToList(roundNum, roundScores, scoreUpdates, caboPlayerIndex); } /// Sets the scores of the players for a specific round. @@ -190,13 +192,18 @@ class GameSession { /// playerScores. Its important that each index of the [roundScores] list /// corresponds to the index of the player in the [playerScores] list. void addRoundScoresToList( - int roundNum, List roundScores, List scoreUpdates, - [int? kamikazePlayerIndex]) { + int roundNum, + List roundScores, + List scoreUpdates, + int caboPlayerIndex, [ + int? kamikazePlayerIndex, + ]) { Round newRound = Round( roundNum: roundNum, + caboPlayerIndex: caboPlayerIndex, + kamikazePlayerIndex: kamikazePlayerIndex, scores: roundScores, scoreUpdates: scoreUpdates, - kamikazePlayerIndex: kamikazePlayerIndex, ); if (roundNum > roundList.length) { roundList.add(newRound); diff --git a/lib/data/round.dart b/lib/data/round.dart index 0c5b456..dcc5d9f 100644 --- a/lib/data/round.dart +++ b/lib/data/round.dart @@ -9,34 +9,40 @@ import 'package:cabo_counter/data/game_session.dart'; /// kamikaze, this value is null. class Round { final int roundNum; + final int caboPlayerIndex; + final int? kamikazePlayerIndex; final List scores; final List scoreUpdates; - final int? kamikazePlayerIndex; - Round( - {required this.roundNum, - required this.scores, - required this.scoreUpdates, - this.kamikazePlayerIndex}); + Round({ + required this.roundNum, + required this.caboPlayerIndex, + this.kamikazePlayerIndex, + required this.scores, + required this.scoreUpdates, + }); @override toString() { - return 'Round $roundNum: scores: $scores, scoreUpdates: $scoreUpdates, ' - 'kamikazePlayerIndex: $kamikazePlayerIndex'; + return 'Round $roundNum, caboPlayerIndex: $caboPlayerIndex, ' + 'kamikazePlayerIndex: $kamikazePlayerIndex, scores: $scores, ' + 'scoreUpdates: $scoreUpdates, '; } /// Converts the Round object to a JSON map. Map toJson() => { 'roundNum': roundNum, + 'caboPlayerIndex': caboPlayerIndex, + 'kamikazePlayerIndex': kamikazePlayerIndex, 'scores': scores, 'scoreUpdates': scoreUpdates, - 'kamikazePlayerIndex': kamikazePlayerIndex, }; /// Creates a Round object from a JSON map. Round.fromJson(Map json) : roundNum = json['roundNum'], + caboPlayerIndex = json['caboPlayerIndex'], + kamikazePlayerIndex = json['kamikazePlayerIndex'], scores = List.from(json['scores']), - scoreUpdates = List.from(json['scoreUpdates']), - kamikazePlayerIndex = json['kamikazePlayerIndex']; + scoreUpdates = List.from(json['scoreUpdates']); } diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index 661b139..5f4b90a 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -1,15 +1,20 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/utility/globals.dart'; import 'package:file_picker/file_picker.dart'; import 'package:file_saver/file_saver.dart'; +import 'package:flutter/services.dart'; +import 'package:json_schema/json_schema.dart'; +import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; class LocalStorageService { static const String _fileName = 'game_data.json'; + static var logger = Logger( + printer: PrettyPrinter(), + ); /// Writes the game session list to a JSON file and returns it as string. static String getJsonFile() { @@ -31,38 +36,52 @@ class LocalStorageService { final file = await _getFilePath(); final jsonFile = getJsonFile(); await file.writeAsString(jsonFile); - print('Daten gespeichert'); + logger.i('Die Spieldaten wurden zwischengespeichert.'); } catch (e) { - print('Fehler beim Speichern: $e'); + logger.w('Fehler beim Speichern der Spieldaten. Exception: $e'); } } /// Loads the game data from a local JSON file. - static Future loadGameSessions() async { - print('Versuche, Daten zu laden...'); + static Future loadGameSessions() async { + logger.d('Versuche, Daten zu laden...'); try { final file = await _getFilePath(); - if (await file.exists()) { - print('Es existiert bereits eine Datei mit Spieldaten'); - final jsonString = await file.readAsString(); - if (jsonString.isNotEmpty) { - print('Die gefundene Datei ist nicht leer'); - final jsonList = json.decode(jsonString) as List; - Globals.gameList = jsonList - .map((jsonItem) => - GameSession.fromJson(jsonItem as Map)) - .toList() - .cast(); - print('Die Daten wurden erfolgreich geladen'); - } else { - print('Die Datei ist leer'); - } - } else { - print('Es existiert bisher noch keine Datei mit Spieldaten'); + + if (!await file.exists()) { + logger.w('Es existiert noch keine Datei mit Spieldaten'); + return false; } + + logger.d('Es existiert bereits eine Datei mit Spieldaten'); + final jsonString = await file.readAsString(); + + if (jsonString.isEmpty) { + logger.w('Die gefundene Datei ist leer'); + return false; + } + + if (!await validateJsonSchema(jsonString)) { + logger.w('Die Datei konnte nicht validiert werden'); + Globals.gameList = []; + return false; + } + logger.d('Die gefundene Datei hat Inhalt'); + logger.d('Die gefundene Datei wurde erfolgreich validiert'); + final jsonList = json.decode(jsonString) as List; + + Globals.gameList = jsonList + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + + logger.i('Die Spieldaten wurden erfolgreich geladen und verarbeitet'); + return true; } catch (e) { - print('Fehler beim Laden der Spieldaten:\n$e'); + logger.e('Fehler beim Laden der Spieldaten:\n$e', + error: 'JSON nicht geladen'); Globals.gameList = []; + return false; } } @@ -77,44 +96,76 @@ class LocalStorageService { ext: 'json', mimeType: MimeType.json, ); - print('Datei gespeichert: $result'); + logger.i('Die Spieldaten wurden exportiert. Dateipfad: $result'); return true; } catch (e) { - print('Fehler beim Speichern: $e'); + logger.w('Fehler beim Exportieren der Spieldaten. Exception: $e', + error: 'JSON nicht exportiert'); return false; } } /// Opens the file picker to import a JSON file and loads the game data from it. static Future importJsonFile() async { + final result = await FilePicker.platform.pickFiles( + dialogTitle: 'Wähle eine Datei mit Spieldaten aus', + type: FileType.custom, + allowedExtensions: ['json'], + ); + + if (result == null) { + logger.d('Der Filepicker-Dialog wurde abgebrochen'); + return false; + } + try { - final result = await FilePicker.platform.pickFiles( - dialogTitle: 'Wähle eine Datei mit Spieldaten aus', - type: FileType.custom, - allowedExtensions: ['json'], - ); - String jsonString = ''; - if (result != null) { - if (result.files.single.bytes != null) { - final Uint8List fileBytes = result.files.single.bytes!; - jsonString = utf8.decode(fileBytes); - } else if (result.files.single.path != null) { - final file = File(result.files.single.path!); - jsonString = await file.readAsString(); - } - final jsonList = json.decode(jsonString) as List; - print('JSON Inhalt: $jsonList'); - Globals.gameList = jsonList - .map((jsonItem) => - GameSession.fromJson(jsonItem as Map)) - .toList(); - return true; - } else { - print('Der Dialog wurde abgebrochen'); + final jsonString = await _readFileContent(result.files.single); + + if (!await validateJsonSchema(jsonString)) { + return false; + } + final jsonData = json.decode(jsonString) as List; + Globals.gameList = jsonData + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + logger.i('Die Datei wurde erfolgreich Importiertn'); + return true; + } on FormatException catch (e) { + logger.e('Ungültiges JSON-Format. Exception: $e', error: 'Formatfehler'); + return false; + } on Exception catch (e) { + logger.e('Fehler beim Dateizugriff. Exception: $e', + error: 'Dateizugriffsfehler'); + return false; + } + } + + /// Helper method to read file content from either bytes or path + static Future _readFileContent(PlatformFile file) async { + if (file.bytes != null) return utf8.decode(file.bytes!); + if (file.path != null) return await File(file.path!).readAsString(); + + throw Exception('Die Datei hat keinen lesbaren Inhalt'); + } + + /// Validates the JSON data against the schema. + static Future validateJsonSchema(String jsonString) async { + try { + final schemaString = await rootBundle.loadString('assets/schema.json'); + final schema = JsonSchema.create(json.decode(schemaString)); + final jsonData = json.decode(jsonString); + final result = schema.validate(jsonData); + + if (result.isValid) { + logger.d('JSON ist erfolgreich validiert.'); return true; } + logger.w('JSON ist nicht gültig.\nFehler: ${result.errors}'); + return false; } catch (e) { - print('Fehler beim Importieren: $e'); + logger.e('Fehler beim Validieren des JSON-Schemas: $e', + error: 'Validierung fehlgeschlagen'); return false; } } diff --git a/lib/views/round_view.dart b/lib/views/round_view.dart index fe68ce7..48bc4b9 100644 --- a/lib/views/round_view.dart +++ b/lib/views/round_view.dart @@ -53,6 +53,8 @@ class _RoundViewState extends State { _scoreControllerList[i].text = gameSession.roundList[widget.roundNumber - 1].scores[i].toString(); } + _caboPlayerIndex = + gameSession.roundList[widget.roundNumber - 1].caboPlayerIndex; _kamikazePlayerIndex = gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex; } diff --git a/pubspec.yaml b/pubspec.yaml index bcfd55a..1a829fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: cabo_counter description: "Mobile app for the card game Cabo" publish_to: 'none' -version: 0.1.6-alpha+135 +version: 0.1.6-alpha+145 environment: sdk: ^3.5.4 @@ -19,7 +19,9 @@ dependencies: path_provider: ^2.1.1 typed_data: ^1.3.2 url_launcher: any + json_schema: ^5.2.1 shared_preferences: ^2.5.3 + logger: ^2.5.0 dev_dependencies: flutter_test: @@ -32,3 +34,4 @@ flutter: uses-material-design: false assets: - assets/cabo-counter-logo_rounded.png + - assets/schema.json