Merge pull request #21 from flixcoo/feature/18-json-validation

Feature/18 json validation
This commit is contained in:
2025-05-03 17:42:13 +02:00
committed by GitHub
6 changed files with 235 additions and 72 deletions

94
assets/schema.json Normal file
View File

@@ -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"
]
}
}

View File

@@ -63,7 +63,7 @@ class GameSession {
players = List<String>.from(json['players']), players = List<String>.from(json['players']),
pointLimit = json['pointLimit'], pointLimit = json['pointLimit'],
caboPenalty = json['caboPenalty'], caboPenalty = json['caboPenalty'],
isPointsLimitEnabled = json['gameHasPointLimit'], isPointsLimitEnabled = json['isPointsLimitEnabled'],
isGameFinished = json['isGameFinished'], isGameFinished = json['isGameFinished'],
winner = json['winner'], winner = json['winner'],
roundNumber = json['roundNumber'], roundNumber = json['roundNumber'],
@@ -91,7 +91,7 @@ class GameSession {
} }
} }
addRoundScoresToList( addRoundScoresToList(
roundNum, roundScores, scoreUpdates, kamikazePlayerIndex); roundNum, roundScores, scoreUpdates, 0, kamikazePlayerIndex);
} }
/// Checks the scores of the current round and assigns points to the players. /// 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 ' print('${players[caboPlayerIndex]} hat CABO gesagt '
'und bekommt 0 Punkte'); 'und bekommt 0 Punkte');
print('Alle anderen Spieler bekommen ihre Punkte'); print('Alle anderen Spieler bekommen ihre Punkte');
_assignPoints(roundNum, roundScores, [caboPlayerIndex]); _assignPoints(roundNum, roundScores, caboPlayerIndex, [caboPlayerIndex]);
} else { } else {
// A player other than the one who said CABO has the fewest points. // A player other than the one who said CABO has the fewest points.
print('${players[caboPlayerIndex]} hat CABO gesagt, ' print('${players[caboPlayerIndex]} hat CABO gesagt, '
@@ -134,7 +134,8 @@ class GameSession {
for (int i in lowestScoreIndex) { for (int i in lowestScoreIndex) {
print('${players[i]}: ${roundScores[i]} Punkte'); 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. /// [roundNum] is the number of the current round.
/// [roundScores] is the raw list of the scores of all players in 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 /// [winnerIndex] is the index of the player who receives 5 extra points
void _assignPoints(int roundNum, List<int> roundScores, List<int> winnerIndex, void _assignPoints(int roundNum, List<int> roundScores, int caboPlayerIndex,
[int loserIndex = -1]) { List<int> winnerIndex,
[int? loserIndex]) {
/// List of the updates for every player score /// List of the updates for every player score
List<int> scoreUpdates = [...roundScores]; List<int> scoreUpdates = [...roundScores];
print('Folgende Punkte wurden aus der Runde übernommen:'); print('Folgende Punkte wurden aus der Runde übernommen:');
@@ -172,7 +174,7 @@ class GameSession {
print('${players[i]} hat gewonnen und bekommt 0 Punkte'); print('${players[i]} hat gewonnen und bekommt 0 Punkte');
scoreUpdates[i] = 0; scoreUpdates[i] = 0;
} }
if (loserIndex != -1) { if (loserIndex != null) {
print('${players[loserIndex]} bekommt 5 Fehlerpunkte'); print('${players[loserIndex]} bekommt 5 Fehlerpunkte');
scoreUpdates[loserIndex] += 5; scoreUpdates[loserIndex] += 5;
} }
@@ -181,7 +183,7 @@ class GameSession {
print('${players[i]}: ${scoreUpdates[i]}'); print('${players[i]}: ${scoreUpdates[i]}');
} }
print('scoreUpdates: $scoreUpdates, roundScores: $roundScores'); print('scoreUpdates: $scoreUpdates, roundScores: $roundScores');
addRoundScoresToList(roundNum, roundScores, scoreUpdates); addRoundScoresToList(roundNum, roundScores, scoreUpdates, caboPlayerIndex);
} }
/// Sets the scores of the players for a specific round. /// 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 /// playerScores. Its important that each index of the [roundScores] list
/// corresponds to the index of the player in the [playerScores] list. /// corresponds to the index of the player in the [playerScores] list.
void addRoundScoresToList( void addRoundScoresToList(
int roundNum, List<int> roundScores, List<int> scoreUpdates, int roundNum,
[int? kamikazePlayerIndex]) { List<int> roundScores,
List<int> scoreUpdates,
int caboPlayerIndex, [
int? kamikazePlayerIndex,
]) {
Round newRound = Round( Round newRound = Round(
roundNum: roundNum, roundNum: roundNum,
caboPlayerIndex: caboPlayerIndex,
kamikazePlayerIndex: kamikazePlayerIndex,
scores: roundScores, scores: roundScores,
scoreUpdates: scoreUpdates, scoreUpdates: scoreUpdates,
kamikazePlayerIndex: kamikazePlayerIndex,
); );
if (roundNum > roundList.length) { if (roundNum > roundList.length) {
roundList.add(newRound); roundList.add(newRound);

View File

@@ -9,34 +9,40 @@ import 'package:cabo_counter/data/game_session.dart';
/// kamikaze, this value is null. /// kamikaze, this value is null.
class Round { class Round {
final int roundNum; final int roundNum;
final int caboPlayerIndex;
final int? kamikazePlayerIndex;
final List<int> scores; final List<int> scores;
final List<int> scoreUpdates; final List<int> scoreUpdates;
final int? kamikazePlayerIndex;
Round( Round({
{required this.roundNum, required this.roundNum,
required this.scores, required this.caboPlayerIndex,
required this.scoreUpdates, this.kamikazePlayerIndex,
this.kamikazePlayerIndex}); required this.scores,
required this.scoreUpdates,
});
@override @override
toString() { toString() {
return 'Round $roundNum: scores: $scores, scoreUpdates: $scoreUpdates, ' return 'Round $roundNum, caboPlayerIndex: $caboPlayerIndex, '
'kamikazePlayerIndex: $kamikazePlayerIndex'; 'kamikazePlayerIndex: $kamikazePlayerIndex, scores: $scores, '
'scoreUpdates: $scoreUpdates, ';
} }
/// Converts the Round object to a JSON map. /// Converts the Round object to a JSON map.
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'roundNum': roundNum, 'roundNum': roundNum,
'caboPlayerIndex': caboPlayerIndex,
'kamikazePlayerIndex': kamikazePlayerIndex,
'scores': scores, 'scores': scores,
'scoreUpdates': scoreUpdates, 'scoreUpdates': scoreUpdates,
'kamikazePlayerIndex': kamikazePlayerIndex,
}; };
/// Creates a Round object from a JSON map. /// Creates a Round object from a JSON map.
Round.fromJson(Map<String, dynamic> json) Round.fromJson(Map<String, dynamic> json)
: roundNum = json['roundNum'], : roundNum = json['roundNum'],
caboPlayerIndex = json['caboPlayerIndex'],
kamikazePlayerIndex = json['kamikazePlayerIndex'],
scores = List<int>.from(json['scores']), scores = List<int>.from(json['scores']),
scoreUpdates = List<int>.from(json['scoreUpdates']), scoreUpdates = List<int>.from(json['scoreUpdates']);
kamikazePlayerIndex = json['kamikazePlayerIndex'];
} }

View File

@@ -1,15 +1,20 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:cabo_counter/data/game_session.dart'; import 'package:cabo_counter/data/game_session.dart';
import 'package:cabo_counter/utility/globals.dart'; import 'package:cabo_counter/utility/globals.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:file_saver/file_saver.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'; import 'package:path_provider/path_provider.dart';
class LocalStorageService { class LocalStorageService {
static const String _fileName = 'game_data.json'; 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. /// Writes the game session list to a JSON file and returns it as string.
static String getJsonFile() { static String getJsonFile() {
@@ -31,38 +36,52 @@ class LocalStorageService {
final file = await _getFilePath(); final file = await _getFilePath();
final jsonFile = getJsonFile(); final jsonFile = getJsonFile();
await file.writeAsString(jsonFile); await file.writeAsString(jsonFile);
print('Daten gespeichert'); logger.i('Die Spieldaten wurden zwischengespeichert.');
} catch (e) { } 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. /// Loads the game data from a local JSON file.
static Future<void> loadGameSessions() async { static Future<bool> loadGameSessions() async {
print('Versuche, Daten zu laden...'); logger.d('Versuche, Daten zu laden...');
try { try {
final file = await _getFilePath(); final file = await _getFilePath();
if (await file.exists()) {
print('Es existiert bereits eine Datei mit Spieldaten'); if (!await file.exists()) {
final jsonString = await file.readAsString(); logger.w('Es existiert noch keine Datei mit Spieldaten');
if (jsonString.isNotEmpty) { return false;
print('Die gefundene Datei ist nicht leer');
final jsonList = json.decode(jsonString) as List<dynamic>;
Globals.gameList = jsonList
.map((jsonItem) =>
GameSession.fromJson(jsonItem as Map<String, dynamic>))
.toList()
.cast<GameSession>();
print('Die Daten wurden erfolgreich geladen');
} else {
print('Die Datei ist leer');
}
} else {
print('Es existiert bisher noch keine Datei mit Spieldaten');
} }
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<dynamic>;
Globals.gameList = jsonList
.map((jsonItem) =>
GameSession.fromJson(jsonItem as Map<String, dynamic>))
.toList();
logger.i('Die Spieldaten wurden erfolgreich geladen und verarbeitet');
return true;
} catch (e) { } 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 = []; Globals.gameList = [];
return false;
} }
} }
@@ -77,44 +96,76 @@ class LocalStorageService {
ext: 'json', ext: 'json',
mimeType: MimeType.json, mimeType: MimeType.json,
); );
print('Datei gespeichert: $result'); logger.i('Die Spieldaten wurden exportiert. Dateipfad: $result');
return true; return true;
} catch (e) { } catch (e) {
print('Fehler beim Speichern: $e'); logger.w('Fehler beim Exportieren der Spieldaten. Exception: $e',
error: 'JSON nicht exportiert');
return false; return false;
} }
} }
/// Opens the file picker to import a JSON file and loads the game data from it. /// Opens the file picker to import a JSON file and loads the game data from it.
static Future<bool> importJsonFile() async { static Future<bool> 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 { try {
final result = await FilePicker.platform.pickFiles( final jsonString = await _readFileContent(result.files.single);
dialogTitle: 'Wähle eine Datei mit Spieldaten aus',
type: FileType.custom, if (!await validateJsonSchema(jsonString)) {
allowedExtensions: ['json'], return false;
); }
String jsonString = ''; final jsonData = json.decode(jsonString) as List<dynamic>;
if (result != null) { Globals.gameList = jsonData
if (result.files.single.bytes != null) { .map((jsonItem) =>
final Uint8List fileBytes = result.files.single.bytes!; GameSession.fromJson(jsonItem as Map<String, dynamic>))
jsonString = utf8.decode(fileBytes); .toList();
} else if (result.files.single.path != null) { logger.i('Die Datei wurde erfolgreich Importiertn');
final file = File(result.files.single.path!); return true;
jsonString = await file.readAsString(); } on FormatException catch (e) {
} logger.e('Ungültiges JSON-Format. Exception: $e', error: 'Formatfehler');
final jsonList = json.decode(jsonString) as List<dynamic>; return false;
print('JSON Inhalt: $jsonList'); } on Exception catch (e) {
Globals.gameList = jsonList logger.e('Fehler beim Dateizugriff. Exception: $e',
.map((jsonItem) => error: 'Dateizugriffsfehler');
GameSession.fromJson(jsonItem as Map<String, dynamic>)) return false;
.toList(); }
return true; }
} else {
print('Der Dialog wurde abgebrochen'); /// Helper method to read file content from either bytes or path
static Future<String> _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<bool> 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; return true;
} }
logger.w('JSON ist nicht gültig.\nFehler: ${result.errors}');
return false;
} catch (e) { } catch (e) {
print('Fehler beim Importieren: $e'); logger.e('Fehler beim Validieren des JSON-Schemas: $e',
error: 'Validierung fehlgeschlagen');
return false; return false;
} }
} }

View File

@@ -53,6 +53,8 @@ class _RoundViewState extends State<RoundView> {
_scoreControllerList[i].text = _scoreControllerList[i].text =
gameSession.roundList[widget.roundNumber - 1].scores[i].toString(); gameSession.roundList[widget.roundNumber - 1].scores[i].toString();
} }
_caboPlayerIndex =
gameSession.roundList[widget.roundNumber - 1].caboPlayerIndex;
_kamikazePlayerIndex = _kamikazePlayerIndex =
gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex; gameSession.roundList[widget.roundNumber - 1].kamikazePlayerIndex;
} }

View File

@@ -2,7 +2,7 @@ name: cabo_counter
description: "Mobile app for the card game Cabo" description: "Mobile app for the card game Cabo"
publish_to: 'none' publish_to: 'none'
version: 0.1.6-alpha+135 version: 0.1.6-alpha+145
environment: environment:
sdk: ^3.5.4 sdk: ^3.5.4
@@ -19,7 +19,9 @@ dependencies:
path_provider: ^2.1.1 path_provider: ^2.1.1
typed_data: ^1.3.2 typed_data: ^1.3.2
url_launcher: any url_launcher: any
json_schema: ^5.2.1
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
logger: ^2.5.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -32,3 +34,4 @@ flutter:
uses-material-design: false uses-material-design: false
assets: assets:
- assets/cabo-counter-logo_rounded.png - assets/cabo-counter-logo_rounded.png
- assets/schema.json