diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index 28767d3..ec1ff6d 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -296,6 +296,7 @@ class BackendApi { String? coverUrl, int? releaseYear, bool? locked, + Map? json, }) async { final map = await _post({ 'action': 'set_game_status', @@ -311,6 +312,7 @@ class BackendApi { if (coverUrl != null) 'cover_url': coverUrl, if (releaseYear != null) 'release_year': releaseYear, if (locked != null) 'locked': locked, + if (json != null) 'json': json, }); return (map['game'] as Map).cast(); } diff --git a/lib/features/games/data/game_model.dart b/lib/features/games/data/game_model.dart index 02e7095..9f4fd9f 100644 --- a/lib/features/games/data/game_model.dart +++ b/lib/features/games/data/game_model.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import '../../../core/status.dart'; class Game { @@ -12,6 +13,7 @@ class Game { final String? coverUrl; final int? releaseYear; final bool locked; + final Map? rawJson; Game({ required this.id, @@ -25,9 +27,18 @@ class Game { this.coverUrl, this.releaseYear, this.locked = false, + this.rawJson, }); factory Game.fromJson(Map j) { + Map? parsedJson; + final jsonString = j['json'] as String?; + if (jsonString != null && jsonString.isNotEmpty) { + try { + parsedJson = Map.from( + jsonDecode(jsonString) as Map); + } catch (_) {} + } return Game( id: (j['id'] as num).toInt(), igdbId: (j['igdb_id'] as num).toInt(), @@ -40,6 +51,7 @@ class Game { coverUrl: j['cover_url'] as String?, releaseYear: (j['release_year'] as num?)?.toInt(), locked: ((j['loc_locked'] ?? j['locked'] ?? 0) as num?)?.toInt() == 1, + rawJson: parsedJson, ); } diff --git a/lib/features/games/presentation/games_detail_screen.dart b/lib/features/games/presentation/games_detail_screen.dart index cf3232b..7ac4b11 100644 --- a/lib/features/games/presentation/games_detail_screen.dart +++ b/lib/features/games/presentation/games_detail_screen.dart @@ -23,6 +23,7 @@ class _GamesDetailScreenState extends ConsumerState { late TextEditingController _summaryCtrl; String _title = ''; String _summary = ''; + Map? _rawJson; late String _origTitle; late String _origSummary; bool _lockTexts = false; @@ -36,6 +37,7 @@ class _GamesDetailScreenState extends ConsumerState { _origStatus = widget.game.status; _title = (widget.game.localizedTitle?.isNotEmpty ?? false) ? widget.game.localizedTitle! : widget.game.name; _summary = widget.game.summary ?? ''; + _rawJson = widget.game.rawJson; _lockTexts = widget.game.locked; _origTitle = _title; _origSummary = _summary; @@ -92,6 +94,7 @@ class _GamesDetailScreenState extends ConsumerState { coverUrl: _game.coverUrl, releaseYear: _game.releaseYear, locked: _lockTexts, + json: _rawJson, ); if (!mounted) return; setState(() { @@ -107,6 +110,7 @@ class _GamesDetailScreenState extends ConsumerState { coverUrl: _game.coverUrl, releaseYear: _game.releaseYear, locked: _lockTexts, + rawJson: _rawJson, ); _title = title; _summary = summary; @@ -193,6 +197,10 @@ class _GamesDetailScreenState extends ConsumerState { children: [ _header(context, g), const SizedBox(height: 16), + if (_detailsData().isNotEmpty) ...[ + _detailsSection(context), + const SizedBox(height: 16), + ], _summaryCard(context), const SizedBox(height: 8), Row( @@ -343,6 +351,81 @@ class _GamesDetailScreenState extends ConsumerState { ); } + Map> _detailsData() { + final root = _rawJson; + if (root == null) return {}; + List namesOf(String key) { + final list = (root[key] as List?) ?? const []; + return list + .whereType() + .map((e) => (e['name'] ?? '').toString()) + .where((e) => e.isNotEmpty) + .toSet() + .toList(); + } + + List involvedWhere(String flagKey) { + final list = (root['involved_companies'] as List?) ?? const []; + return list + .whereType() + .where((e) => (e[flagKey] ?? false) == true) + .map((e) => ((e['company'] as Map?)?['name'] ?? '').toString()) + .where((e) => e.isNotEmpty) + .toSet() + .toList(); + } + + return { + 'Main Developers': involvedWhere('developer'), + 'Publishers': involvedWhere('publisher'), + 'Genres': namesOf('genres'), + 'Game Modes': namesOf('game_modes'), + 'Themes': namesOf('themes'), + 'Player Perspectives': namesOf('player_perspectives'), + 'Game Engine': namesOf('game_engines'), + }..removeWhere((_, v) => v.isEmpty); + } + + Widget _detailsSection(BuildContext context) { + final data = _detailsData(); + if (data.isEmpty) return const SizedBox.shrink(); + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.6), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.all(12), + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Details', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + const SizedBox(height: 8), + for (final entry in data.entries) ...[ + Text(entry.key, style: const TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Wrap( + spacing: 6, + runSpacing: -6, + children: entry.value + .map((v) => Chip( + backgroundColor: Colors.black54, + shape: StadiumBorder(side: BorderSide(color: Colors.white.withOpacity(0.2))), + label: Text(v, style: const TextStyle(color: Colors.white)), + padding: const EdgeInsets.symmetric(horizontal: 4), + )) + .toList(), + ), + const SizedBox(height: 10), + ], + ], + ), + ), + ); + } + Future _refreshFromIgdb() async { final messenger = ScaffoldMessenger.of(context); try { @@ -356,6 +439,7 @@ class _GamesDetailScreenState extends ConsumerState { // If not locked, always replace with fresh IGDB texts final summary = _lockTexts ? _summary : igdbSummary; final title = _lockTexts ? _title : (igdbTitle.isNotEmpty ? igdbTitle : _title); + _rawJson = data; final firstRelease = data['first_release_date']; int? year; if (firstRelease is num) { @@ -369,12 +453,13 @@ class _GamesDetailScreenState extends ConsumerState { status: _status.index, lang: 'de', // Wenn nicht gesperrt, die aktuellen Texte (aus IGDB oder lokal) zurückschreiben - title: title, - summary: summary, - coverUrl: cover, - releaseYear: year, - locked: _lockTexts, - ); + title: title, + summary: summary, + coverUrl: cover, + releaseYear: year, + locked: _lockTexts, + json: _rawJson, + ); if (!mounted) return; setState(() { @@ -390,6 +475,7 @@ class _GamesDetailScreenState extends ConsumerState { coverUrl: cover, releaseYear: year ?? _game.releaseYear, locked: _lockTexts, + rawJson: _rawJson, ); _title = title; _summary = summary; diff --git a/lib/features/games/presentation/games_screen.dart b/lib/features/games/presentation/games_screen.dart index 9f81e45..caac581 100644 --- a/lib/features/games/presentation/games_screen.dart +++ b/lib/features/games/presentation/games_screen.dart @@ -23,6 +23,21 @@ class GamesScreen extends ConsumerWidget { } } + String? _engine(Game g) { + final root = g.rawJson; + if (root == null) return null; + final engines = root['game_engines']; + if (engines is List && engines.isNotEmpty) { + final first = engines.first; + if (first is Map && (first['name'] ?? '').toString().isNotEmpty) { + return first['name'].toString(); + } + final s = engines.first.toString(); + if (s.isNotEmpty) return s; + } + return null; + } + @override Widget build(BuildContext context, WidgetRef ref) { final filter = ref.watch(gameFilterProvider); @@ -109,12 +124,37 @@ class GamesScreen extends ConsumerWidget { title: Text( g.releaseYear != null ? '${g.displayName} (${g.releaseYear})' : g.displayName, ), - subtitle: Text( - (g.summary?.isNotEmpty ?? false) - ? g.summary! - : (g.note ?? ''), - maxLines: 3, - overflow: TextOverflow.ellipsis, + subtitle: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: _engine(g) != null + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + _engine(g)!, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ) + : const SizedBox.shrink(), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + (g.summary?.isNotEmpty ?? false) ? g.summary! : (g.note ?? ''), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), trailing: Text(g.status.name), ), @@ -160,6 +200,7 @@ class GamesScreen extends ConsumerWidget { coverUrl: cover, releaseYear: year, locked: g.locked, + json: data, ); // ignore: unused_result ref.invalidate(gamesStreamProvider); @@ -216,6 +257,7 @@ class GamesScreen extends ConsumerWidget { coverUrl: cover, releaseYear: year, locked: g.locked, + json: data, ); ok++; } catch (_) { diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index dbe72f1..c336f75 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -84,11 +84,14 @@ function saveGameStatus(int $igdbId, string $name, ?string $originalName, int $s $pdo = getDb(); $hasCover = gameHasColumn('cover_url'); $hasReleaseYear = gameHasColumn('release_year'); + $hasJson = gameHasColumn('json'); $releaseYear = $GLOBALS['__release_year'] ?? null; + $gameJson = $GLOBALS['__game_json'] ?? null; $fields = ['igdb_id', 'name', 'original_name', 'status', 'note']; if ($hasCover) $fields[] = 'cover_url'; if ($hasReleaseYear) $fields[] = 'release_year'; + if ($hasJson) $fields[] = 'json'; $placeholders = array_map(fn($f) => ':'.$f, $fields); $updates = []; @@ -115,6 +118,7 @@ function saveGameStatus(int $igdbId, string $name, ?string $originalName, int $s case 'note': $params[':note'] = $note; break; case 'cover_url': $params[':cover_url'] = $coverUrl; break; case 'release_year': $params[':release_year'] = $releaseYear; break; + case 'json': $params[':json'] = $gameJson; break; } } $stmt->execute($params); @@ -483,7 +487,10 @@ if (isset($_GET['action'])) { $id = (int)$idParam; if ($id <= 0) error_response('Invalid \"id\"', 400); $body = sprintf( - 'fields id,name,summary,storyline,first_release_date,genres.name,platforms.name,cover.image_id,screenshots.image_id,involved_companies.company.name,websites.url,websites.category,age_ratings.rating,age_ratings.category,language_supports.language; where id = %d;', + 'fields id,name,summary,storyline,first_release_date,genres.name,platforms.name,cover.image_id,screenshots.image_id,' + .'involved_companies.company.name,involved_companies.developer,involved_companies.publisher,' + .'websites.url,websites.category,age_ratings.rating,age_ratings.category,language_supports.language,' + .'game_modes.name,themes.name,player_perspectives.name,game_engines.name; where id = %d;', $id ); $results = igdbRequest('/games', $body); @@ -1081,7 +1088,8 @@ try { loc.title AS loc_title, loc.summary AS loc_summary, loc.storyline AS loc_storyline, - loc.locked AS loc_locked + loc.locked AS loc_locked, + g.json AS json FROM game g LEFT JOIN igdb_localizations loc ON loc.igdb_id = g.igdb_id AND loc.lang = ? @@ -1350,7 +1358,9 @@ try { $lockFlag = array_key_exists('locked', $in) ? (int)!!$in['locked'] : null; $coverUrl = isset($in['cover_url']) ? trim($in['cover_url']) : null; $releaseYear = isset($in['release_year']) ? (int)$in['release_year'] : null; + $gameJson = isset($in['json']) ? $in['json'] : null; $GLOBALS['__release_year'] = $releaseYear; + $GLOBALS['__game_json'] = $gameJson !== null ? json_encode($gameJson, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) : null; if ($igdbId <= 0) fail('Invalid "igdb_id"'); if ($name === '') fail('Missing "name"'); if ($status < 0 || $status > 2) fail('Invalid "status" (0,1,2)');