From 12dfa821ea077712e9568e1973fd79117fc2a831 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sat, 3 May 2025 13:44:25 +0200 Subject: [PATCH 1/4] Added JSON Schema validation --- assets/schema.json | 81 ++++++++++++++++ lib/utility/local_storage_service.dart | 129 ++++++++++++++++--------- pubspec.yaml | 3 +- 3 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 assets/schema.json diff --git a/assets/schema.json b/assets/schema.json new file mode 100644 index 0000000..429c926 --- /dev/null +++ b/assets/schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Schema for cabo game data", + "type": "array", + "items": { + "type": "object", + "properties": { + "gameTitle": { + "type": "string" + }, + "gameHasPointLimit": { + "type": "boolean" + }, + "players": { + "type": "array", + "items": { + "type": "string" + } + }, + "playerScores": { + "type": "array", + "items": { + "type": "number" + } + }, + "roundNumber": { + "type": "number" + }, + "isGameFinished": { + "type": "boolean" + }, + "winner": { + "type": "string" + }, + "roundList": { + "type": "array", + "items": { + "type": "object", + "properties": { + "roundNum": { + "type": "number" + }, + "caboPlayerIndex": { + "type": "number" + }, + "kamikazePlayerIndex": {}, + "scores": { + "type": "array", + "items": { + "type": "number" + } + }, + "scoreUpdates": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "roundNum", + "caboPlayerIndex", + "kamikazePlayerIndex", + "scores", + "scoreUpdates" + ] + } + } + }, + "required": [ + "gameTitle", + "gameHasPointLimit", + "players", + "playerScores", + "roundNumber", + "isGameFinished", + "winner", + "roundList" + ] + } +} \ No newline at end of file diff --git a/lib/utility/local_storage_service.dart b/lib/utility/local_storage_service.dart index 661b139..f88e8e0 100644 --- a/lib/utility/local_storage_service.dart +++ b/lib/utility/local_storage_service.dart @@ -1,11 +1,12 @@ 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:path_provider/path_provider.dart'; class LocalStorageService { @@ -38,31 +39,44 @@ class LocalStorageService { } /// Loads the game data from a local JSON file. - static Future loadGameSessions() async { + static Future loadGameSessions() async { print('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()) { + print('Es existiert noch keine Datei mit Spieldaten'); + return false; } + + print('Es existiert bereits eine Datei mit Spieldaten'); + final jsonString = await file.readAsString(); + + if (jsonString.isEmpty) { + print('Die gefundene Datei ist leer'); + return false; + } + + if (!await validateJsonSchema(jsonString)) { + print('Die Datei konnte nicht validiert werden'); + Globals.gameList = []; + return false; + } + + print('Die gefundene Datei ist nicht leer und validiert'); + final jsonList = json.decode(jsonString) as List; + + Globals.gameList = jsonList + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + + print('Die Daten wurden erfolgreich geladen und verarbeitet'); + return true; } catch (e) { print('Fehler beim Laden der Spieldaten:\n$e'); Globals.gameList = []; + return false; } } @@ -87,34 +101,63 @@ class LocalStorageService { /// 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) { + print('Der 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; + print('JSON Inhalt: $jsonData'); + Globals.gameList = jsonData + .map((jsonItem) => + GameSession.fromJson(jsonItem as Map)) + .toList(); + return true; + } on FormatException catch (e) { + print('Ungültiges JSON-Format: $e'); + return false; + } on Exception catch (e) { + print('Fehler beim Dateizugriff: $e'); + 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) { + print('JSON ist erfolgreich validiert.'); return true; } + print('JSON ist nicht gültig: ${result.errors}'); + return false; } catch (e) { - print('Fehler beim Importieren: $e'); + print('Fehler beim Validieren des JSON-Schemas: $e'); return false; } } diff --git a/pubspec.yaml b/pubspec.yaml index ecbbc81..6141b6e 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.5+113 +version: 0.1.5+116 environment: sdk: ^3.5.4 @@ -19,6 +19,7 @@ dependencies: path_provider: ^2.1.1 typed_data: ^1.3.2 url_launcher: any + json_schema: ^5.2.1 dev_dependencies: flutter_test: From 96c5df0bcc5b5db17e0f990419be75b91a980c71 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sat, 3 May 2025 13:44:44 +0200 Subject: [PATCH 2/4] Added caboPlayerIndex in Round-Class --- lib/data/game_session.dart | 27 +++++++++++++++++---------- lib/data/round.dart | 28 +++++++++++++++++----------- lib/views/round_view.dart | 2 ++ 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index 36629f8..1523469 100644 --- a/lib/data/game_session.dart +++ b/lib/data/game_session.dart @@ -82,7 +82,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. @@ -116,7 +116,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, ' @@ -125,7 +125,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); } } @@ -151,8 +152,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:'); @@ -163,7 +165,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; } @@ -172,7 +174,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. @@ -181,13 +183,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/views/round_view.dart b/lib/views/round_view.dart index 7e5ea96..f22b8c1 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; } From 794e5fdca602237fd478c1099a4c213ed5820235 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sat, 3 May 2025 17:22:00 +0200 Subject: [PATCH 3/4] Fixed Json import bug --- lib/data/game_session.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/data/game_session.dart b/lib/data/game_session.dart index 850a9f8..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'], From a8008ae3a15b1e8cc0beb433fce586ac0b8a99a1 Mon Sep 17 00:00:00 2001 From: Felix Kirchner Date: Sat, 3 May 2025 17:22:18 +0200 Subject: [PATCH 4/4] Updated prints and json schema --- assets/schema.json | 45 ++++++++++++++--------- lib/services/local_storage_service.dart | 48 ++++++++++++++----------- pubspec.yaml | 4 ++- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/assets/schema.json b/assets/schema.json index 429c926..17d7faa 100644 --- a/assets/schema.json +++ b/assets/schema.json @@ -1,15 +1,15 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Schema for cabo game data", + "title": "Generated schema for cabo game data", "type": "array", "items": { "type": "object", "properties": { - "gameTitle": { + "createdAt": { "type": "string" }, - "gameHasPointLimit": { - "type": "boolean" + "gameTitle": { + "type": "string" }, "players": { "type": "array", @@ -17,21 +17,30 @@ "type": "string" } }, - "playerScores": { - "type": "array", - "items": { - "type": "number" - } - }, - "roundNumber": { + "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": { @@ -43,7 +52,9 @@ "caboPlayerIndex": { "type": "number" }, - "kamikazePlayerIndex": {}, + "kamikazePlayerIndex": { + "type": ["number", "null"] + }, "scores": { "type": "array", "items": { @@ -60,7 +71,6 @@ "required": [ "roundNum", "caboPlayerIndex", - "kamikazePlayerIndex", "scores", "scoreUpdates" ] @@ -68,13 +78,16 @@ } }, "required": [ + "createdAt", "gameTitle", - "gameHasPointLimit", "players", - "playerScores", - "roundNumber", + "pointLimit", + "caboPenalty", + "isPointsLimitEnabled", "isGameFinished", "winner", + "roundNumber", + "playerScores", "roundList" ] } diff --git a/lib/services/local_storage_service.dart b/lib/services/local_storage_service.dart index f88e8e0..5f4b90a 100644 --- a/lib/services/local_storage_service.dart +++ b/lib/services/local_storage_service.dart @@ -7,10 +7,14 @@ 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() { @@ -32,38 +36,38 @@ 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...'); + logger.d('Versuche, Daten zu laden...'); try { final file = await _getFilePath(); if (!await file.exists()) { - print('Es existiert noch keine Datei mit Spieldaten'); + logger.w('Es existiert noch keine Datei mit Spieldaten'); return false; } - print('Es existiert bereits eine Datei mit Spieldaten'); + logger.d('Es existiert bereits eine Datei mit Spieldaten'); final jsonString = await file.readAsString(); if (jsonString.isEmpty) { - print('Die gefundene Datei ist leer'); + logger.w('Die gefundene Datei ist leer'); return false; } if (!await validateJsonSchema(jsonString)) { - print('Die Datei konnte nicht validiert werden'); + logger.w('Die Datei konnte nicht validiert werden'); Globals.gameList = []; return false; } - - print('Die gefundene Datei ist nicht leer und validiert'); + 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 @@ -71,10 +75,11 @@ class LocalStorageService { GameSession.fromJson(jsonItem as Map)) .toList(); - print('Die Daten wurden erfolgreich geladen und verarbeitet'); + 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; } @@ -91,10 +96,11 @@ 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; } } @@ -108,7 +114,7 @@ class LocalStorageService { ); if (result == null) { - print('Der Dialog wurde abgebrochen'); + logger.d('Der Filepicker-Dialog wurde abgebrochen'); return false; } @@ -119,17 +125,18 @@ class LocalStorageService { return false; } final jsonData = json.decode(jsonString) as List; - print('JSON Inhalt: $jsonData'); Globals.gameList = jsonData .map((jsonItem) => GameSession.fromJson(jsonItem as Map)) .toList(); + logger.i('Die Datei wurde erfolgreich Importiertn'); return true; } on FormatException catch (e) { - print('Ungültiges JSON-Format: $e'); + logger.e('Ungültiges JSON-Format. Exception: $e', error: 'Formatfehler'); return false; } on Exception catch (e) { - print('Fehler beim Dateizugriff: $e'); + logger.e('Fehler beim Dateizugriff. Exception: $e', + error: 'Dateizugriffsfehler'); return false; } } @@ -151,13 +158,14 @@ class LocalStorageService { final result = schema.validate(jsonData); if (result.isValid) { - print('JSON ist erfolgreich validiert.'); + logger.d('JSON ist erfolgreich validiert.'); return true; } - print('JSON ist nicht gültig: ${result.errors}'); + logger.w('JSON ist nicht gültig.\nFehler: ${result.errors}'); return false; } catch (e) { - print('Fehler beim Validieren des JSON-Schemas: $e'); + logger.e('Fehler beim Validieren des JSON-Schemas: $e', + error: 'Validierung fehlgeschlagen'); return false; } } diff --git a/pubspec.yaml b/pubspec.yaml index 2941c3d..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+138 +version: 0.1.6-alpha+145 environment: sdk: ^3.5.4 @@ -21,6 +21,7 @@ dependencies: url_launcher: any json_schema: ^5.2.1 shared_preferences: ^2.5.3 + logger: ^2.5.0 dev_dependencies: flutter_test: @@ -33,3 +34,4 @@ flutter: uses-material-design: false assets: - assets/cabo-counter-logo_rounded.png + - assets/schema.json