diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index dac6f02..28767d3 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -295,6 +295,7 @@ class BackendApi { String? storyline, String? coverUrl, int? releaseYear, + bool? locked, }) async { final map = await _post({ 'action': 'set_game_status', @@ -309,6 +310,7 @@ class BackendApi { if (storyline != null) 'storyline': storyline, if (coverUrl != null) 'cover_url': coverUrl, if (releaseYear != null) 'release_year': releaseYear, + if (locked != null) 'locked': locked, }); return (map['game'] as Map).cast(); } diff --git a/lib/features/games/data/dummy.tmp b/lib/features/games/data/dummy.tmp new file mode 100644 index 0000000..9daeafb --- /dev/null +++ b/lib/features/games/data/dummy.tmp @@ -0,0 +1 @@ +test diff --git a/lib/features/games/data/game_model.dart b/lib/features/games/data/game_model.dart index 3e6daa4..02e7095 100644 --- a/lib/features/games/data/game_model.dart +++ b/lib/features/games/data/game_model.dart @@ -11,6 +11,7 @@ class Game { final String? summary; final String? coverUrl; final int? releaseYear; + final bool locked; Game({ required this.id, @@ -23,6 +24,7 @@ class Game { this.summary, this.coverUrl, this.releaseYear, + this.locked = false, }); factory Game.fromJson(Map j) { @@ -37,6 +39,7 @@ class Game { summary: j['loc_summary'] as String?, coverUrl: j['cover_url'] as String?, releaseYear: (j['release_year'] as num?)?.toInt(), + locked: ((j['loc_locked'] ?? j['locked'] ?? 0) as num?)?.toInt() == 1, ); } diff --git a/lib/features/games/presentation/games_detail_screen.dart b/lib/features/games/presentation/games_detail_screen.dart index ee1bbf4..cf3232b 100644 --- a/lib/features/games/presentation/games_detail_screen.dart +++ b/lib/features/games/presentation/games_detail_screen.dart @@ -19,6 +19,14 @@ class _GamesDetailScreenState extends ConsumerState { late Game _game; late ItemStatus _status; late ItemStatus _origStatus; + late TextEditingController _titleCtrl; + late TextEditingController _summaryCtrl; + String _title = ''; + String _summary = ''; + late String _origTitle; + late String _origSummary; + bool _lockTexts = false; + late bool _origLocked; @override void initState() { @@ -26,13 +34,52 @@ class _GamesDetailScreenState extends ConsumerState { _game = widget.game; _status = widget.game.status; _origStatus = widget.game.status; + _title = (widget.game.localizedTitle?.isNotEmpty ?? false) ? widget.game.localizedTitle! : widget.game.name; + _summary = widget.game.summary ?? ''; + _lockTexts = widget.game.locked; + _origTitle = _title; + _origSummary = _summary; + _origLocked = _lockTexts; + _titleCtrl = TextEditingController(text: _title); + _summaryCtrl = TextEditingController(text: _summary); + _titleCtrl.addListener(() { + final v = _titleCtrl.text; + if (v != _title) { + setState(() { + _title = v; + }); + } + }); + _summaryCtrl.addListener(() { + final v = _summaryCtrl.text; + if (v != _summary) { + setState(() { + _summary = v; + }); + } + }); } - bool get _dirty => _status != _origStatus; + @override + void dispose() { + _titleCtrl.dispose(); + _summaryCtrl.dispose(); + super.dispose(); + } + + bool get _dirty => _status != _origStatus || _title != _origTitle || _summary != _origSummary; + bool get _lockedDirty => _lockTexts != _origLocked; + bool get _isDirty => _dirty || _lockedDirty; + + bool _hasCustomTexts(String title, String summary) { + return title.trim().isNotEmpty || summary.trim().isNotEmpty; + } Future _save() async { final messenger = ScaffoldMessenger.of(context); try { + final title = _title.trim(); + final summary = _summary.trim(); final backend = ref.read(backendApiProvider); await backend.setGameStatus( igdbId: _game.igdbId, @@ -40,13 +87,36 @@ class _GamesDetailScreenState extends ConsumerState { originalName: _game.originalName, status: _status.index, lang: 'de', - title: _game.localizedTitle ?? _game.name, - summary: _game.summary, + title: title.isNotEmpty ? title : _game.name, + summary: summary, coverUrl: _game.coverUrl, releaseYear: _game.releaseYear, + locked: _lockTexts, ); if (!mounted) return; - setState(() => _origStatus = _status); + setState(() { + _game = Game( + id: _game.id, + igdbId: _game.igdbId, + name: _game.name, + localizedTitle: title.isNotEmpty ? title : null, + originalName: _game.originalName, + status: _status, + note: _game.note, + summary: summary.isNotEmpty ? summary : null, + coverUrl: _game.coverUrl, + releaseYear: _game.releaseYear, + locked: _lockTexts, + ); + _title = title; + _summary = summary; + _titleCtrl.text = title; + _summaryCtrl.text = summary; + _origStatus = _status; + _origTitle = title; + _origSummary = summary; + _origLocked = _lockTexts; + }); // ignore: unused_result ref.invalidate(gamesStreamProvider); // ignore: unused_result @@ -75,7 +145,7 @@ class _GamesDetailScreenState extends ConsumerState { ), ], ), - floatingActionButton: _dirty + floatingActionButton: _isDirty ? FloatingActionButton.extended( onPressed: _save, icon: const Icon(Icons.save), @@ -123,7 +193,24 @@ class _GamesDetailScreenState extends ConsumerState { children: [ _header(context, g), const SizedBox(height: 16), - if ((g.summary ?? '').isNotEmpty) _summaryCard(context, g.summary!), + _summaryCard(context), + const SizedBox(height: 8), + Row( + children: [ + Checkbox( + value: _lockTexts, + onChanged: (v) => setState(() => _lockTexts = v ?? false), + checkColor: Colors.white, + side: const BorderSide(color: Colors.white70), + ), + const Expanded( + child: Text( + 'Eigene Texte vor Updates schützen', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), ], ), ), @@ -160,10 +247,26 @@ class _GamesDetailScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - g.releaseYear != null ? '${g.displayName} (${g.releaseYear})' : g.displayName, + TextField( + controller: _titleCtrl, + maxLines: 2, style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white), + decoration: InputDecoration( + labelText: 'Titel (DE)', + labelStyle: const TextStyle(color: Colors.white70), + hintText: g.displayName, + hintStyle: const TextStyle(color: Colors.white54), + enabledBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.white70)), + focusedBorder: const UnderlineInputBorder(borderSide: BorderSide(color: Colors.white)), + ), ), + if (g.releaseYear != null) ...[ + const SizedBox(height: 6), + Text( + 'Erscheinungsjahr: ${g.releaseYear}', + style: const TextStyle(color: Colors.white70), + ), + ], if (g.originalName != null && g.originalName!.isNotEmpty) ...[ const SizedBox(height: 4), Text( @@ -182,7 +285,7 @@ class _GamesDetailScreenState extends ConsumerState { ); } - Widget _summaryCard(BuildContext context, String summary) { + Widget _summaryCard(BuildContext context) { return Container( width: double.infinity, decoration: BoxDecoration( @@ -195,9 +298,29 @@ class _GamesDetailScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Beschreibung', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), + Text('Beschreibung (DE)', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)), const SizedBox(height: 6), - Text(summary), + TextField( + controller: _summaryCtrl, + maxLines: null, + minLines: 4, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Beschreibung bearbeiten', + hintStyle: const TextStyle(color: Colors.white54), + filled: true, + fillColor: Colors.black12, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.white30), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + ), + contentPadding: const EdgeInsets.all(12), + ), + ), ], ), ), @@ -228,8 +351,11 @@ class _GamesDetailScreenState extends ConsumerState { final cover = (data['cover'] is Map && data['cover']['image_id'] != null) ? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg' : _game.coverUrl; - final summary = data['summary'] as String? ?? _game.summary; - final title = data['name'] as String? ?? _game.name; + final igdbSummary = data['summary'] as String? ?? ''; + final igdbTitle = data['name'] as String? ?? ''; + // If not locked, always replace with fresh IGDB texts + final summary = _lockTexts ? _summary : igdbSummary; + final title = _lockTexts ? _title : (igdbTitle.isNotEmpty ? igdbTitle : _title); final firstRelease = data['first_release_date']; int? year; if (firstRelease is num) { @@ -237,16 +363,18 @@ class _GamesDetailScreenState extends ConsumerState { } await ref.read(backendApiProvider).setGameStatus( - igdbId: _game.igdbId, - name: title, - originalName: _game.originalName, - status: _status.index, - lang: 'de', - title: title, - summary: summary, - coverUrl: cover, - releaseYear: year, - ); + igdbId: _game.igdbId, + name: title, + originalName: _game.originalName, + 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, + ); if (!mounted) return; setState(() { @@ -261,7 +389,16 @@ class _GamesDetailScreenState extends ConsumerState { summary: summary, coverUrl: cover, releaseYear: year ?? _game.releaseYear, + locked: _lockTexts, ); + _title = title; + _summary = summary; + _origTitle = _title; + _origSummary = _summary; + _titleCtrl.text = _title; + _summaryCtrl.text = _summary; + _origStatus = _status; + _origLocked = _lockTexts; }); // ignore: unused_result ref.invalidate(gamesStreamProvider); diff --git a/lib/features/games/presentation/games_screen.dart b/lib/features/games/presentation/games_screen.dart index c7d0533..9f81e45 100644 --- a/lib/features/games/presentation/games_screen.dart +++ b/lib/features/games/presentation/games_screen.dart @@ -38,17 +38,27 @@ class GamesScreen extends ConsumerWidget { onChanged: (f) => ref.read(gameFilterProvider.notifier).state = f, ), const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(Icons.add), - label: const Text('Neue Spiele hinzufügen'), - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const GamesAddScreen()), - ); - }, - ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + icon: const Icon(Icons.update), + label: const Text('Alle Spiele updaten'), + onPressed: gamesAsync is AsyncData> && (gamesAsync.value?.isNotEmpty ?? false) + ? () => _updateAllGames(context, ref, gamesAsync.value ?? const []) + : null, + ), + const SizedBox(width: 8), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Neue Spiele hinzufügen'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const GamesAddScreen()), + ); + }, + ), + ], ), const SizedBox(height: 12), Expanded( @@ -130,8 +140,10 @@ class GamesScreen extends ConsumerWidget { final cover = (data['cover'] is Map && data['cover']['image_id'] != null) ? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg' : g.coverUrl; - final summary = data['summary'] as String? ?? g.summary; - final title = data['name'] as String? ?? g.name; + final summary = g.locked ? (g.summary ?? '') : (data['summary'] as String? ?? g.summary ?? ''); + final title = g.locked + ? (g.localizedTitle?.isNotEmpty == true ? g.localizedTitle! : g.name) + : (data['name'] as String? ?? g.name); final fr = data['first_release_date']; int? year; if (fr is num) { @@ -147,6 +159,7 @@ class GamesScreen extends ConsumerWidget { summary: summary, coverUrl: cover, releaseYear: year, + locked: g.locked, ); // ignore: unused_result ref.invalidate(gamesStreamProvider); @@ -158,6 +171,98 @@ class GamesScreen extends ConsumerWidget { } } + Future _updateAllGames(BuildContext context, WidgetRef ref, List games) async { + if (games.isEmpty) return; + final messenger = ScaffoldMessenger.of(context); + int idx = 0; + int ok = 0; + int fail = 0; + String current = ''; + final total = games.length; + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + return StatefulBuilder(builder: (ctx, setState) { + Future run() async { + try { + for (final g in games) { + idx++; + current = g.displayName; + setState(() {}); + try { + final igdb = ref.read(igdbApiProvider); + final data = await igdb.getGameDetails(g.igdbId, lang: 'de'); + final cover = (data['cover'] is Map && data['cover']['image_id'] != null) + ? 'https://images.igdb.com/igdb/image/upload/t_cover_big/${data['cover']['image_id']}.jpg' + : g.coverUrl; + final summary = g.locked ? (g.summary ?? '') : (data['summary'] as String? ?? g.summary ?? ''); + final title = g.locked + ? (g.localizedTitle?.isNotEmpty == true ? g.localizedTitle! : g.name) + : (data['name'] as String? ?? g.name); + final fr = data['first_release_date']; + int? year; + if (fr is num) { + year = DateTime.fromMillisecondsSinceEpoch(fr.toInt() * 1000).year; + } + await ref.read(backendApiProvider).setGameStatus( + igdbId: g.igdbId, + name: title, + originalName: g.originalName, + status: g.status.index, + lang: 'de', + title: title, + summary: summary, + coverUrl: cover, + releaseYear: year, + locked: g.locked, + ); + ok++; + } catch (_) { + fail++; + } + } + // ignore: unused_result + ref.invalidate(gamesStreamProvider); + // ignore: unused_result + ref.invalidate(gamesProvider); + messenger.showSnackBar(SnackBar(content: Text('Alle Spiele aktualisiert: $ok/$total (Fehler: $fail)'))); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); + } + } + + if (idx == 0 && ok == 0 && fail == 0) { + // ignore: discarded_futures + run(); + } + + final progress = total > 0 ? ((idx / total).clamp(0, 1)).toDouble() : null; + return AlertDialog( + title: const Text('Alle Spiele updaten'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktualisiere: $idx / $total'), + if (current.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + current, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 12), + LinearProgressIndicator(value: progress), + ], + ), + ); + }); + }, + ); + } + Future _deleteGame(BuildContext context, WidgetRef ref, Game g) async { final ok = await showDialog( context: context, diff --git a/lib/php/multimedia.php b/lib/php/multimedia.php index 4cb4d3b..dbe72f1 100644 --- a/lib/php/multimedia.php +++ b/lib/php/multimedia.php @@ -161,26 +161,33 @@ function getLocalization(int $igdbId, string $lang = 'de'): ?array { return $row ?: null; } -function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId): array { +function saveLocalization(int $igdbId, string $lang, ?string $title, ?string $summary, ?string $storyline, ?int $userId, ?int $locked = null): array { $pdo = getDb(); + $existing = getLocalization($igdbId, $lang); + $lockedVal = $locked === null ? (int)($existing['locked'] ?? 0) : (int)!!$locked; + $titleToSave = $title !== null ? $title : ($existing['title'] ?? null); + $summaryToSave = $summary !== null ? $summary : ($existing['summary'] ?? null); + $storyToSave = $storyline !== null ? $storyline : ($existing['storyline'] ?? null); $sql = ' - INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id) - VALUES (:id, :lang, :title, :summary, :storyline, :user_id) + INSERT INTO igdb_localizations (igdb_id, lang, title, summary, storyline, user_id, locked) + VALUES (:id, :lang, :title, :summary, :storyline, :user_id, :locked) ON DUPLICATE KEY UPDATE title = VALUES(title), summary = VALUES(summary), storyline = VALUES(storyline), user_id = VALUES(user_id), + locked = VALUES(locked), updated_at = CURRENT_TIMESTAMP '; $stmt = $pdo->prepare($sql); $stmt->execute([ ':id' => $igdbId, ':lang' => $lang, - ':title' => $title, - ':summary' => $summary, - ':storyline' => $storyline, + ':title' => $titleToSave, + ':summary' => $summaryToSave, + ':storyline' => $storyToSave, ':user_id' => $userId, + ':locked' => $lockedVal, ]); return getLocalization($igdbId, $lang); } @@ -498,10 +505,11 @@ if (isset($_GET['action'])) { $game['my_note'] = null; } $hasLoc = false; + $locLocked = 0; if ($lang !== 'en') { $loc = getLocalization($id, $lang); if ($loc !== null) { - $hasLoc = true; + $locLocked = (int)($loc['locked'] ?? 0); if (!empty($loc['title'])) $game['name'] = $loc['title']; if (!empty($loc['summary'])) $game['summary'] = $loc['summary']; if (!empty($loc['storyline'])) $game['storyline'] = $loc['storyline']; @@ -509,15 +517,20 @@ if (isset($_GET['action'])) { 'lang' => $lang, 'source' => 'custom', 'id' => $loc['id'], + 'locked' => $locLocked, ]; + if ($locLocked === 1) { + $hasLoc = true; // locked -> treat as authoritative, skip external fallback + } } } $game['has_localization'] = $hasLoc; + $game['loc_locked'] = $locLocked; $game['has_external_de'] = false; $game['external_de_source']= null; $game['external_de_summary']= null; $game['external_de_url'] = null; - if ($checkExternal && $lang === 'de' && !$hasLoc) { + if ($checkExternal && $lang === 'de' && $locLocked === 0) { $wiki = wikiFetchGermanSummaryForTitle($game['name']); if ($wiki !== null && !empty($wiki['extract'])) { $game['has_external_de'] = true; @@ -1067,7 +1080,8 @@ try { CASE g.status WHEN 1 THEN 'Progress' WHEN 2 THEN 'Done' ELSE 'Init' END AS status, loc.title AS loc_title, loc.summary AS loc_summary, - loc.storyline AS loc_storyline + loc.storyline AS loc_storyline, + loc.locked AS loc_locked FROM game g LEFT JOIN igdb_localizations loc ON loc.igdb_id = g.igdb_id AND loc.lang = ? @@ -1333,6 +1347,7 @@ try { $locTitle = isset($in['title']) ? trim($in['title']) : ''; $locSummary = isset($in['summary']) ? trim($in['summary']) : ''; $locStory = isset($in['storyline']) ? trim($in['storyline']) : ''; + $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; $GLOBALS['__release_year'] = $releaseYear; @@ -1340,8 +1355,8 @@ try { if ($name === '') fail('Missing "name"'); if ($status < 0 || $status > 2) fail('Invalid "status" (0,1,2)'); $game = saveGameStatus($igdbId, $name, $orig !== '' ? $orig : null, $status, $note !== '' ? $note : null, $coverUrl ?: null); - if ($locTitle !== '' || $locSummary !== '' || $locStory !== '') { - saveLocalization($igdbId, $lang, $locTitle ?: null, $locSummary ?: null, $locStory ?: null, null); + if ($locTitle !== '' || $locSummary !== '' || $locStory !== '' || $lockFlag !== null) { + saveLocalization($igdbId, $lang, $locTitle ?: null, $locSummary ?: null, $locStory ?: null, null, $lockFlag); } resp(['ok' => true, 'game' => $game]); break;