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
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 }
|