more game details

This commit is contained in:
2025-12-04 15:54:43 +01:00
parent e04e57d2d2
commit 31b31d0e17
5 changed files with 166 additions and 14 deletions
+2
View File
@@ -296,6 +296,7 @@ class BackendApi {
String? coverUrl,
int? releaseYear,
bool? locked,
Map<String, dynamic>? 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<String, dynamic>();
}
+12
View File
@@ -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<String, dynamic>? rawJson;
Game({
required this.id,
@@ -25,9 +27,18 @@ class Game {
this.coverUrl,
this.releaseYear,
this.locked = false,
this.rawJson,
});
factory Game.fromJson(Map<String, dynamic> j) {
Map<String, dynamic>? parsedJson;
final jsonString = j['json'] as String?;
if (jsonString != null && jsonString.isNotEmpty) {
try {
parsedJson = Map<String, dynamic>.from(
jsonDecode(jsonString) as Map<dynamic, dynamic>);
} 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,
);
}
@@ -23,6 +23,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
late TextEditingController _summaryCtrl;
String _title = '';
String _summary = '';
Map<String, dynamic>? _rawJson;
late String _origTitle;
late String _origSummary;
bool _lockTexts = false;
@@ -36,6 +37,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
_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<GamesDetailScreen> {
coverUrl: _game.coverUrl,
releaseYear: _game.releaseYear,
locked: _lockTexts,
json: _rawJson,
);
if (!mounted) return;
setState(() {
@@ -107,6 +110,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
coverUrl: _game.coverUrl,
releaseYear: _game.releaseYear,
locked: _lockTexts,
rawJson: _rawJson,
);
_title = title;
_summary = summary;
@@ -193,6 +197,10 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
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<GamesDetailScreen> {
);
}
Map<String, List<String>> _detailsData() {
final root = _rawJson;
if (root == null) return {};
List<String> namesOf(String key) {
final list = (root[key] as List?) ?? const [];
return list
.whereType<Map>()
.map((e) => (e['name'] ?? '').toString())
.where((e) => e.isNotEmpty)
.toSet()
.toList();
}
List<String> involvedWhere(String flagKey) {
final list = (root['involved_companies'] as List?) ?? const [];
return list
.whereType<Map>()
.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<void> _refreshFromIgdb() async {
final messenger = ScaffoldMessenger.of(context);
try {
@@ -356,6 +439,7 @@ class _GamesDetailScreenState extends ConsumerState<GamesDetailScreen> {
// 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<GamesDetailScreen> {
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<GamesDetailScreen> {
coverUrl: cover,
releaseYear: year ?? _game.releaseYear,
locked: _lockTexts,
rawJson: _rawJson,
);
_title = title;
_summary = summary;
@@ -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 (_) {
+12 -2
View File
@@ -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)');