You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

407 lines
16 KiB
Dart

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../../shared/providers.dart';
import '../data/game_repository.dart';
import '../data/game_model.dart';
import 'games_add_screen.dart';
import '../../movies/presentation/widgets/status_chip.dart';
import 'games_detail_screen.dart';
class GamesScreen extends ConsumerWidget {
const GamesScreen({super.key});
Color _statusBg(ItemStatus status, BuildContext context) {
switch (status) {
case ItemStatus.Init:
return Colors.grey.shade200;
case ItemStatus.Progress:
return Colors.blue;
case ItemStatus.Done:
return Colors.green;
}
}
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);
final gamesAsync = ref.watch(gamesStreamProvider);
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusChip(
selected: filter,
onChanged: (f) => ref.read(gameFilterProvider.notifier).state = f,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
icon: const Icon(Icons.update),
label: const Text('Alle Spiele updaten'),
onPressed: gamesAsync is AsyncData<List<Game>> && (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(
child: gamesAsync.when(
data: (items) {
final sorted = [...items]
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
if (sorted.isEmpty) {
return const Center(child: Text('Keine Spiele in der Sammlung'));
}
return RefreshIndicator(
onRefresh: () async {
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
ref.invalidate(gamesProvider);
},
child: ListView.separated(
itemCount: sorted.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final g = sorted[i];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => GamesDetailScreen(game: g)),
);
},
onLongPressStart: (d) => _showContextMenu(context, ref, g, d.globalPosition),
onSecondaryTapDown: (d) => _showContextMenu(context, ref, g, d.globalPosition),
child: ListTile(
tileColor: _statusBg(g.status, context),
leading: g.coverUrl != null && g.coverUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: g.coverUrl!,
width: 50,
height: 70,
fit: BoxFit.cover,
),
)
: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
child: const Icon(Icons.sports_esports),
),
title: Text(
g.releaseYear != null ? '${g.displayName} (${g.releaseYear})' : g.displayName,
),
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),
),
);
},
),
);
},
error: (e, st) => Center(child: Text('Fehler: $e')),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
],
),
);
}
Future<void> _updateGame(BuildContext context, WidgetRef ref, Game g) async {
final messenger = ScaffoldMessenger.of(context);
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,
json: data,
);
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
ref.invalidate(gamesProvider);
messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e')));
}
}
Future<void> _updateAllGames(BuildContext context, WidgetRef ref, List<Game> 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<void> 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,
json: data,
);
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<void> _deleteGame(BuildContext context, WidgetRef ref, Game g) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Spiel löschen?'),
content: Text('\"${g.displayName}\" wirklich löschen?'),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')),
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen')),
],
),
);
if (ok != true) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(backendApiProvider).deleteGame(g.igdbId);
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
ref.invalidate(gamesProvider);
messenger.showSnackBar(const SnackBar(content: Text('Spiel gelöscht')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));
}
}
Future<void> _setStatus(BuildContext context, WidgetRef ref, Game g, ItemStatus status) async {
final messenger = ScaffoldMessenger.of(context);
try {
await ref.read(backendApiProvider).setGameStatus(
igdbId: g.igdbId,
name: g.name,
originalName: g.originalName,
status: status.index,
lang: 'de',
title: g.localizedTitle ?? g.name,
summary: g.summary,
coverUrl: g.coverUrl,
releaseYear: g.releaseYear,
);
// ignore: unused_result
ref.invalidate(gamesStreamProvider);
// ignore: unused_result
ref.invalidate(gamesProvider);
messenger.showSnackBar(SnackBar(content: Text('Status auf ${status.name} gesetzt')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Status-Update fehlgeschlagen: $e')));
}
}
Future<void> _showContextMenu(BuildContext context, WidgetRef ref, Game g, Offset pos) async {
final selected = await showMenu<_GameAction>(
context: context,
position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx, pos.dy),
items: const [
PopupMenuItem(
value: _GameAction.setInit,
child: Text('Set to Init'),
),
PopupMenuItem(
value: _GameAction.setProgress,
child: Text('Set to Progress'),
),
PopupMenuItem(
value: _GameAction.setDone,
child: Text('Set to Done'),
),
PopupMenuItem(
value: _GameAction.update,
child: Text('Update'),
),
PopupMenuItem(
value: _GameAction.delete,
child: Text('Delete'),
),
],
);
if (selected == null) return;
switch (selected) {
case _GameAction.setInit:
await _setStatus(context, ref, g, ItemStatus.Init);
break;
case _GameAction.setProgress:
await _setStatus(context, ref, g, ItemStatus.Progress);
break;
case _GameAction.setDone:
await _setStatus(context, ref, g, ItemStatus.Done);
break;
case _GameAction.update:
await _updateGame(context, ref, g);
break;
case _GameAction.delete:
await _deleteGame(context, ref, g);
break;
}
}
}
enum _GameAction { setInit, setProgress, setDone, update, delete }