From 3086a751ec6b419d15c52075c6d3d7d2020f53e9 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Tue, 4 Nov 2025 17:33:12 +0100 Subject: [PATCH] Serie Context Menu --- lib/app.dart | 8 +- lib/core/api/backend_api.dart | 10 +- lib/core/config.dart | 1 + lib/core/status.dart | 1 + lib/features/import/import_screen.dart | 9 +- .../movies/presentation/movie_add_screen.dart | 1 - .../presentation/movie_detail_screen.dart | 28 +- .../presentation/movie_list_screen.dart | 10 - .../series/data/series_repository.dart | 6 +- .../presentation/series_detail_screen.dart | 59 +- .../presentation/series_list_screen.dart | 1219 ++++++++++++----- 11 files changed, 929 insertions(+), 423 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 1ebe47e..5e824ff 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -58,11 +58,11 @@ class _HomeTabsState extends State<_HomeTabs> ), body: TabBarView( controller: _controller, - children: const [ - MovieListScreen(), + children: [ + const MovieListScreen(), SeriesListScreen(), - ImportScreen(), - PingTestScreen(), + const ImportScreen(), + const PingTestScreen(), ], ), ); diff --git a/lib/core/api/backend_api.dart b/lib/core/api/backend_api.dart index c4f7ad1..a80ad43 100644 --- a/lib/core/api/backend_api.dart +++ b/lib/core/api/backend_api.dart @@ -86,11 +86,10 @@ class BackendApi { // ignore: avoid_print final sc = res?.statusCode; final data = res?.data; - print('Backend HTTP ${sc}. URL: ${_dio.options.baseUrl}, payload: $payload, data: ${data}'); if (map != null && map.containsKey('error')) { - throw Exception('Backend HTTP ${sc}: ${map['error']}'); + throw Exception('Backend HTTP $sc: ${map['error']}'); } - throw Exception('Backend HTTP ${sc}: ${data}'); + throw Exception('Backend HTTP $sc: $data'); } if (map == null) { throw Exception('Backend: Unexpected response format'); @@ -224,5 +223,8 @@ class BackendApi { final map = await _post({'action': 'get_show_episodes', 'show_id': showId}); return (map['items'] as List).cast>(); } -} + Future deleteShow(int showId) async { + await _post({'action': 'delete_show', 'show_id': showId}); + } +} diff --git a/lib/core/config.dart b/lib/core/config.dart index 33a9f55..6a21366 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,5 +1,6 @@ /// Globale Konfiguration. Standard: von `--dart-define` lesen. /// Fallbacks sind hilfreich für lokale Tests. +library; class AppConfig { static const backendBaseUrl = String.fromEnvironment( diff --git a/lib/core/status.dart b/lib/core/status.dart index ff0c391..8c637fd 100644 --- a/lib/core/status.dart +++ b/lib/core/status.dart @@ -1,3 +1,4 @@ +// ignore: constant_identifier_names enum ItemStatus { Init, Progress, Done } ItemStatus statusFromString(String? s) { diff --git a/lib/features/import/import_screen.dart b/lib/features/import/import_screen.dart index 0d8a26f..99bc1bd 100644 --- a/lib/features/import/import_screen.dart +++ b/lib/features/import/import_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; -import '../../core/api/tmdb_api.dart'; import '../../core/api/backend_api.dart'; import '../shared/providers.dart'; @@ -20,7 +19,7 @@ class _ImportScreenState extends ConsumerState { String _log = ''; bool _busy = false; - void _append(String msg) => setState(() => _log += msg + '\n'); + void _append(String msg) => setState(() => _log += '$msg\n'); Future _importMovies() async { setState(() => _busy = true); @@ -58,7 +57,7 @@ class _ImportScreenState extends ConsumerState { try { _append('Serie $showId: TMDB laden …'); final showJson = await tmdb.getShow(showId); - print('SHOW JSON: $showJson'); // Debug-Ausgabe + // Debug-Ausgabe await backend.upsertShow(showJson); _append('Serie $showId: Show OK ✓'); @@ -88,10 +87,6 @@ class _ImportScreenState extends ConsumerState { } catch (e) { // 👇 Hier kommt der erweiterte Catch hin! if (e is DioException) { - print('❗ TMDB DioException für $showId'); - print('➡️ Request: ${e.requestOptions.uri}'); - print('➡️ Response: ${e.response?.data}'); - print('➡️ Status: ${e.response?.statusCode}'); } _append('Serie $showId: Fehler → $e'); } diff --git a/lib/features/movies/presentation/movie_add_screen.dart b/lib/features/movies/presentation/movie_add_screen.dart index 81ab03f..6b65398 100644 --- a/lib/features/movies/presentation/movie_add_screen.dart +++ b/lib/features/movies/presentation/movie_add_screen.dart @@ -2,7 +2,6 @@ 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/movie_repository.dart'; diff --git a/lib/features/movies/presentation/movie_detail_screen.dart b/lib/features/movies/presentation/movie_detail_screen.dart index 41d7467..c95cc96 100644 --- a/lib/features/movies/presentation/movie_detail_screen.dart +++ b/lib/features/movies/presentation/movie_detail_screen.dart @@ -18,7 +18,6 @@ class MovieDetailScreen extends ConsumerStatefulWidget { class _MovieDetailScreenState extends ConsumerState { late ItemStatus _status; Map? _tmdb; - bool _loading = false; String? _resolution; late ItemStatus _origStatus; late String? _origResolution; @@ -34,7 +33,6 @@ class _MovieDetailScreenState extends ConsumerState { } Future _loadTmdb() async { - setState(() => _loading = true); try { final tmdb = ref.read(tmdbApiProvider); final data = await tmdb.getMovie(widget.movie.tmdbId); @@ -42,7 +40,9 @@ class _MovieDetailScreenState extends ConsumerState { } catch (e) { // ignore error, keep UI functional } finally { - if (mounted) setState(() => _loading = false); + if (mounted){ + + } } } @@ -95,26 +95,26 @@ class _MovieDetailScreenState extends ConsumerState { tooltip: 'TMDB aktualisieren', icon: const Icon(Icons.sync), onPressed: () async { + // Capture messenger before async gaps to avoid context use across awaits + final messenger = ScaffoldMessenger.of(context); try { final tmdb = ref.read(tmdbApiProvider); final json = await tmdb.getMovie(m.tmdbId); await ref.read(backendApiProvider).upsertMovie(json); + if (!mounted) return; // Update local TMDB data for immediate UI refresh - if (mounted) setState(() => _tmdb = json); + setState(() => _tmdb = json); // Refresh movie list so the list reflects new metadata // ignore: unused_result ref.invalidate(moviesProvider); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TMDB Daten aktualisiert')), - ); - } + messenger.showSnackBar( + const SnackBar(content: Text('TMDB Daten aktualisiert')), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), - ); - } + // Even if the widget unmounted, showing a SnackBar via captured messenger is safe + messenger.showSnackBar( + SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), + ); } }, ) diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index 317c067..678c2a0 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -202,16 +202,6 @@ class MovieListScreen extends ConsumerWidget { ); } - IconData _statusIcon(ItemStatus s) { - switch (s) { - case ItemStatus.Init: - return Icons.hourglass_empty; - case ItemStatus.Progress: - return Icons.downloading; - case ItemStatus.Done: - return Icons.check_circle; - } - } Color? _statusBg(ItemStatus s, BuildContext context) { switch (s) { diff --git a/lib/features/series/data/series_repository.dart b/lib/features/series/data/series_repository.dart index e01e57e..aac2db1 100644 --- a/lib/features/series/data/series_repository.dart +++ b/lib/features/series/data/series_repository.dart @@ -31,7 +31,11 @@ final seriesGroupedProvider = continue; // retry same offset with smaller page } // If we already have some data, return partial results to unblock UI - if (items.isNotEmpty) break; else rethrow; + if (items.isNotEmpty) { + break; + } else { + rethrow; + } } consecutiveFailures = 0; if (page.isEmpty) break; diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart index 01bafb0..6272639 100644 --- a/lib/features/series/presentation/series_detail_screen.dart +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -214,35 +214,6 @@ class _SeriesDetailScreenState extends ConsumerState { bool _saved = false; - void _setEpisodeStatus(EpisodeItem ep, ItemStatus s) { - setState(() { - _pending[ep.id] = s; - // reflect change in local list so radios don't jump back after save - _seasons = { - for (final e in _seasons!.entries) - e.key: e.value - .map((it) => it.id == ep.id - ? EpisodeItem( - id: it.id, - episodeNumber: it.episodeNumber, - seasonNumber: it.seasonNumber, - showName: it.showName, - name: it.name, - status: s, - resolution: it.resolution, - firstAirYear: it.firstAirYear, - showJson: it.showJson, - showStatus: it.showStatus, - showCliffhanger: it.showCliffhanger, - posterPath: it.posterPath, - showId: it.showId, - downloadPath: it.downloadPath, - ) - : it) - .toList(), - }; - }); - } Future _setSeasonAll(int season, ItemStatus s) async { final eps = _seasons?[season] ?? const []; @@ -285,10 +256,13 @@ class _SeriesDetailScreenState extends ConsumerState { _resolution != _origResolution || _downloadPath != _origDownloadPath || _cliffhanger != _origCliffhanger; - return WillPopScope( - onWillPop: () async { - Navigator.of(context).pop(_saved ? 'updated' : null); - return false; + return PopScope( + canPop: true, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + if (context.mounted) { + Navigator.of(context).pop(_saved ? 'updated' : null); + } }, child: Scaffold( extendBodyBehindAppBar: true, @@ -303,6 +277,7 @@ class _SeriesDetailScreenState extends ConsumerState { onPressed: _tmdbId == null ? null : () async { + final messenger = ScaffoldMessenger.of(context); try { final tmdb = ref.read(tmdbApiProvider); final backend = ref.read(backendApiProvider); @@ -353,17 +328,13 @@ class _SeriesDetailScreenState extends ConsumerState { // ignore: unused_result ref.invalidate(seriesGroupedProvider); await _load(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('TMDB Daten aktualisiert')), - ); - } + messenger.showSnackBar( + const SnackBar(content: Text('TMDB Daten aktualisiert')), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), - ); - } + messenger.showSnackBar( + SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')), + ); } }, ), @@ -573,7 +544,7 @@ class _SeriesDetailScreenState extends ConsumerState { Radio( value: rowStatus, groupValue: (_pending[ep.id] ?? ep.status), - fillColor: const MaterialStatePropertyAll(Colors.white), + fillColor: const WidgetStatePropertyAll(Colors.white), onChanged: (v) { setState(() => _pending[ep.id] = rowStatus); }, diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index ca99bec..113bdab 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -2,6 +2,8 @@ import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart' as widgets; import '../../../core/status.dart'; import '../../movies/presentation/widgets/status_chip.dart'; import '../data/series_repository.dart'; @@ -33,7 +35,7 @@ class SeriesListScreen extends ConsumerWidget { onChanged: (f) => ref.read(episodeFilterProvider.notifier).state = f, ), - const SizedBox(height: 8), + const widgets.SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: Row( @@ -50,110 +52,131 @@ class SeriesListScreen extends ConsumerWidget { ); }, ), - const SizedBox(width: 8), + const widgets.SizedBox(width: 8), TextButton.icon( icon: const Icon(Icons.sync), label: const Text('TMDB: alle Serien updaten'), onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - String current = ''; - int idx = 0; - int total = 0; - await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) { - // Stateful progress dialog - return StatefulBuilder(builder: (ctx, setState) { - Future run() async { - try { - final tmdb = ref.read(tmdbApiProvider); - final backend = ref.read(backendApiProvider); - List> showRows = []; - try { - showRows = await backend.listShows(); - } catch (_) { - // Fallback: aus der aktuellen grouped-Liste tmdbId parsen - final grouped = await ref.read(seriesGroupedProvider.future); - for (final entry in grouped.data.entries) { - final flat = entry.value.values.expand((e) => e); - if (flat.isEmpty) continue; - final ep = flat.first; - if (ep.showJson == null) continue; + final messenger = ScaffoldMessenger.of(context); + String current = ''; + int idx = 0; + int total = 0; + await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + // Stateful progress dialog + return StatefulBuilder(builder: (ctx, setState) { + Future run() async { + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + List> showRows = []; try { - final m = jsonDecode(ep.showJson!); - final t = (m['id'] as num?)?.toInt(); - if (t != null) { - showRows.add({'tmdb_id': t, 'name': entry.key}); + showRows = await backend.listShows(); + } catch (_) { + // Fallback: aus der aktuellen grouped-Liste tmdbId parsen + final grouped = await ref + .read(seriesGroupedProvider.future); + for (final entry in grouped.data.entries) { + final flat = + entry.value.values.expand((e) => e); + if (flat.isEmpty) continue; + final ep = flat.first; + if (ep.showJson == null) continue; + try { + final m = jsonDecode(ep.showJson!); + final t = (m['id'] as num?)?.toInt(); + if (t != null) { + showRows.add( + {'tmdb_id': t, 'name': entry.key}); + } + } catch (_) {} } - } catch (_) {} + } + total = showRows.length; + int success = 0; + for (final row in showRows) { + idx++; + final tmdbId = + (row['tmdb_id'] as num?)?.toInt(); + current = (row['name'] as String?) ?? + 'tmdb:$tmdbId'; + setState(() {}); + if (tmdbId == null) continue; + try { + final showJson = await tmdb.getShow(tmdbId); + final dbShowId = + await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] + as List? ?? + const []) + .where((s) => + (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)[ + 'season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = + await tmdb.getSeason(tmdbId, sNo); + final seasonId = await backend + .upsertSeason(dbShowId, seasonJson); + final eps = + (seasonJson['episodes'] as List? ?? + const []) + .cast>(); + for (final e in eps) { + await backend.upsertEpisode( + seasonId, e); + } + } + success++; + } catch (_) {} + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + messenger.showSnackBar( + SnackBar( + content: Text( + 'TMDB Update fertig: $success/$total Serien')), + ); + } finally { + if (ctx.mounted) Navigator.of(ctx).pop(); } } - total = showRows.length; - int success = 0; - for (final row in showRows) { - idx++; - final tmdbId = (row['tmdb_id'] as num?)?.toInt(); - current = (row['name'] as String?) ?? 'tmdb:$tmdbId'; - setState(() {}); - if (tmdbId == null) continue; - try { - final showJson = await tmdb.getShow(tmdbId); - final dbShowId = await backend.upsertShow(showJson); - final seasons = (showJson['seasons'] as List? ?? const []) - .where((s) => (s['season_number'] ?? -1) is num) - .map((s) => (s as Map)['season_number'] as int) - .toList(); - for (final sNo in seasons) { - if (sNo < 0) continue; - final seasonJson = await tmdb.getSeason(tmdbId, sNo); - final seasonId = await backend.upsertSeason(dbShowId, seasonJson); - final eps = (seasonJson['episodes'] as List? ?? const []) - .cast>(); - for (final e in eps) { - await backend.upsertEpisode(seasonId, e); - } - } - success++; - } catch (_) {} + + // kick off once when dialog builds first time + if (idx == 0 && total == 0) { + // ignore: discarded_futures + run(); } - // ignore: unused_result - ref.invalidate(seriesGroupedProvider); - messenger.showSnackBar( - SnackBar(content: Text('TMDB Update fertig: $success/$total Serien')), + return AlertDialog( + title: const Text('TMDB Update'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aktualisiere: $idx/$total'), + const widgets.SizedBox(height: 8), + Text(current, + maxLines: 2, + overflow: TextOverflow.ellipsis), + const widgets.SizedBox(height: 12), + LinearProgressIndicator( + value: total > 0 ? idx / total : null), + ], + ), ); - } finally { - if (ctx.mounted) Navigator.of(ctx).pop(); - } - } - // kick off once when dialog builds first time - if (idx == 0 && total == 0) { - // ignore: discarded_futures - run(); - } - return AlertDialog( - title: const Text('TMDB Update'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Aktualisiere: $idx/$total'), - const SizedBox(height: 8), - Text(current, maxLines: 2, overflow: TextOverflow.ellipsis), - const SizedBox(height: 12), - LinearProgressIndicator(value: total > 0 ? idx / total : null), - ], - ), - ); - }); - }, - ); + }); + }, + ); }, ), ], ), ), - const SizedBox(height: 6), + const widgets.SizedBox(height: 6), Align( alignment: Alignment.centerRight, child: TextButton.icon( @@ -164,14 +187,18 @@ class SeriesListScreen extends ConsumerWidget { final ids = await showDialog>( context: context, builder: (ctx) => AlertDialog( - title: const Text('TMDB IDs (Komma/Leerzeichen getrennt)'), + title: + const Text('TMDB IDs (Komma/Leerzeichen getrennt)'), content: TextField( controller: ctrl, - decoration: const InputDecoration(hintText: 'z.B. 1396, 1399'), + decoration: + const InputDecoration(hintText: 'z.B. 1396, 1399'), autofocus: true, ), actions: [ - TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('Abbrechen')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Abbrechen')), FilledButton( onPressed: () { final parts = ctrl.text.split(RegExp(r'[\s,;]+')); @@ -188,18 +215,19 @@ class SeriesListScreen extends ConsumerWidget { ), ); if (ids == null || ids.isEmpty) return; + if (!context.mounted) return; await showDialog( context: context, barrierDismissible: false, builder: (ctx) { String current = ''; int idx = 0; + final int total = ids.length; final tmdb = ref.read(tmdbApiProvider); final backend = ref.read(backendApiProvider); return StatefulBuilder(builder: (ctx, setState) { Future run() async { try { - int ok = 0; for (final tmdbId in ids) { idx++; current = 'tmdb:$tmdbId'; @@ -207,22 +235,29 @@ class SeriesListScreen extends ConsumerWidget { try { final showJson = await tmdb.getShow(tmdbId); await backend.upsertShow(showJson); - final seasons = (showJson['seasons'] as List? ?? const []) - .where((s) => (s['season_number'] ?? -1) is num) - .map((s) => (s as Map)['season_number'] as int) - .toList(); - final dbShowId = await backend.getShowDbIdByTmdbId(tmdbId); + final seasons = + (showJson['seasons'] as List? ?? const []) + .where((s) => + (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)[ + 'season_number'] as int) + .toList(); + final dbShowId = + await backend.getShowDbIdByTmdbId(tmdbId); if (dbShowId == null) continue; for (final sNo in seasons) { - final seasonJson = await tmdb.getSeason(tmdbId, sNo); - final seasonId = await backend.upsertSeason(dbShowId, seasonJson); - final eps = (seasonJson['episodes'] as List? ?? const []) - .cast>(); + final seasonJson = + await tmdb.getSeason(tmdbId, sNo); + final seasonId = await backend.upsertSeason( + dbShowId, seasonJson); + final eps = + (seasonJson['episodes'] as List? ?? + const []) + .cast>(); for (final e in eps) { await backend.upsertEpisode(seasonId, e); } } - ok++; } catch (_) {} } // ignore: unused_result @@ -231,6 +266,7 @@ class SeriesListScreen extends ConsumerWidget { if (ctx.mounted) Navigator.of(ctx).pop(); } } + if (idx == 0) { // ignore: discarded_futures run(); @@ -241,11 +277,13 @@ class SeriesListScreen extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Aktualisiere: $idx/${ids.length}'), - const SizedBox(height: 8), - Text(current, maxLines: 2, overflow: TextOverflow.ellipsis), - const SizedBox(height: 12), - LinearProgressIndicator(value: idx / ids.length), + Text('Aktualisiere: $idx/$total'), + const widgets.SizedBox(height: 8), + Text(current, + maxLines: 2, overflow: TextOverflow.ellipsis), + const widgets.SizedBox(height: 12), + LinearProgressIndicator( + value: total == 0 ? null : idx / total), ], ), ); @@ -255,45 +293,53 @@ class SeriesListScreen extends ConsumerWidget { }, ), ), - const SizedBox(height: 12), + const widgets.SizedBox(height: 12), Expanded( - child: (summaryAsync.hasValue && (summaryAsync.value?.isNotEmpty ?? false)) - ? _buildFromSummary(context, ref, summaryAsync.value!) - : groupedAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), + child: Builder(builder: (_) { + if (summaryAsync.hasValue && + (summaryAsync.value?.isNotEmpty ?? false)) { + return _buildFromSummary(context, ref, summaryAsync.value!); + } + return groupedAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Fehler: $e')), data: (data) { - final keys = data.data.keys.toList(); - String displayOf(String key) { - final bySeason = data.data[key]; - if (bySeason == null || bySeason.isEmpty) return key; - // take the first available episode to read display name - for (final list in bySeason.values) { - if (list.isNotEmpty) return list.first.showName; + final keys = data.data.keys.toList(); + String displayOf(String showKey) { + final bySeason = data.data[showKey]; + if (bySeason == null || bySeason.isEmpty) return showKey; + // take the first available episode to read display name + for (final list in bySeason.values) { + if (list.isNotEmpty) return list.first.showName; + } + return showKey; } - return key; - } - keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase())); - if (keys.isEmpty) { - return const Center( - child: Text('Keine Episoden gefunden.')); - } - // Collect all season columns across all shows - final allSeasons = {}; - for (final m in data.data.values) { - allSeasons.addAll(m.keys); - } - final seasonCols = allSeasons.toList()..sort(); - List buildColumns() => [ - const DataColumn(label: Text('Serie')), - for (final s in seasonCols) DataColumn(label: Text('Staffel $s')), - ]; + keys.sort((a, b) => displayOf(a) + .toLowerCase() + .compareTo(displayOf(b).toLowerCase())); + if (keys.isEmpty) { + return const Center( + child: Text('Keine Episoden gefunden.')); + } + // Collect all season columns across all shows + final allSeasons = {}; + for (final m in data.data.values) { + allSeasons.addAll(m.keys); + } + final seasonCols = allSeasons.toList()..sort(); - List buildRows() { + List buildColumns() => [ + const DataColumn(label: Text('Serie')), + for (final s in seasonCols) + DataColumn(label: Text('Staffel $s')), + ]; + + // Build all rows inline to avoid analyzer false positives final rows = []; - for (final key in keys) { - final bySeason = data.data[key]!; + for (final showKey in keys) { + final bySeason = data.data[showKey]!; int? year; String? resolution; String? showJson; @@ -301,7 +347,7 @@ class SeriesListScreen extends ConsumerWidget { bool? showCliffhanger; String? posterPath; String? downloadPath; - String displayName = displayOf(key); + String displayName = displayOf(showKey); final sortedSeasons = bySeason.keys.toList()..sort(); for (final s in sortedSeasons) { final eps = bySeason[s]!; @@ -313,27 +359,43 @@ class SeriesListScreen extends ConsumerWidget { showCliffhanger ??= e.showCliffhanger; posterPath ??= e.posterPath; downloadPath ??= e.downloadPath; - if (year != null && resolution != null && showStatus != null && showCliffhanger != null && posterPath != null) break; + if (year != null && + resolution != null && + showStatus != null && + showCliffhanger != null && + posterPath != null) break; } if (year != null && resolution != null) break; } - if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) { + if ((year == null || + showStatus == null || + showCliffhanger == null) && + showJson != null) { try { final m = jsonDecode(showJson); if (m is Map && m['first_air_date'] is String) { final s = (m['first_air_date'] as String); - if (s.length >= 4) year = int.tryParse(s.substring(0, 4)); + if (s.length >= 4) { + year = int.tryParse(s.substring(0, 4)); + } } - if (m is Map && showStatus == null && m['status'] is String) { + if (m is Map && + showStatus == null && + m['status'] is String) { showStatus = m['status'] as String; } - if (m is Map && showCliffhanger == null && m['cliffhanger'] != null) { + if (m is Map && + showCliffhanger == null && + m['cliffhanger'] != null) { final v = m['cliffhanger']; - if (v is bool) showCliffhanger = v; - else if (v is num) showCliffhanger = v != 0; - else if (v is String) { + if (v is bool) { + showCliffhanger = v; + } else if (v is num) { + showCliffhanger = v != 0; + } else if (v is String) { final s = v.toLowerCase(); - showCliffhanger = (s == '1' || s == 'true' || s == 'yes'); + showCliffhanger = + (s == '1' || s == 'true' || s == 'yes'); } } } catch (_) {} @@ -344,104 +406,183 @@ class SeriesListScreen extends ConsumerWidget { bool anyInit = false; for (final epsList in bySeason.values) { for (final ep in epsList) { - if (ep.status == ItemStatus.Progress) anyProgress = true; + if (ep.status == ItemStatus.Progress) { + anyProgress = true; + } if (ep.status == ItemStatus.Init) anyInit = true; } } Color? bg; - if (anyProgress) bg = Colors.blue; - else if (anyInit) bg = Colors.grey.shade200; - else bg = Colors.green; + if (anyProgress) { + bg = Colors.blue; + } else if (anyInit) { + bg = Colors.grey.shade200; + } else { + bg = Colors.green; + } + // Context menu handled by MenuAnchor (no manual showMenu) final cells = [ DataCell( - GestureDetector( - behavior: HitTestBehavior.opaque, - onSecondaryTapDown: (details) async { - final pos = details.globalPosition; - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx, pos.dy), - items: const [ - PopupMenuItem( - value: 'open_download', - child: Text('Open Download Path'), - ), - ], - ); - if (selected == 'open_download') { - if (downloadPath != null && downloadPath!.isNotEmpty) { - final ok = await openFolder(downloadPath!); - if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Pfad kann nicht geöffnet werden')), - ); + MenuAnchor( + menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + MenuItemButton( + child: const Text('set all progress to done'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); + return; } - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Kein Download Path vorhanden')), - ); - } - } - }, - onLongPressStart: (details) async { - final pos = details.globalPosition; - final selected = await showMenu( - context: context, - position: RelativeRect.fromLTRB(pos.dx, pos.dy, pos.dx, pos.dy), - items: const [ - PopupMenuItem( - value: 'open_download', - child: Text('Open Download Path'), - ), - ], - ); - if (selected == 'open_download') { - if (downloadPath != null && downloadPath!.isNotEmpty) { - final ok = await openFolder(downloadPath!); - if (!ok && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Pfad kann nicht geöffnet werden')), - ); + try { + final backend = ref.read(backendApiProvider); + final eps = await backend.getShowEpisodes(sid); + int changed = 0; + for (final e in eps) { + final st = e['status'] as String?; + final id = (e['id'] as num?)?.toInt(); + if (id != null && st == 'Progress') { + await backend.setStatus(type: 'episode', refId: id, status: 'Done'); + changed++; + } + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(SnackBar(content: Text('Erledigt: $changed Episoden'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); } - } else if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Kein Download Path vorhanden')), + }, + ), + MenuItemButton( + child: const Text('update'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); + return; + } + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + final tid = await backend.getTmdbIdByShowId(sid); + if (tid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); + return; + } + final showJson = await tmdb.getShow(tid); + final dbShowId = await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = await tmdb.getSeason(tid, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + for (final e in eps) { + await backend.upsertEpisode(seasonId, e); + } + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); + } + }, + ), + MenuItemButton( + child: const Text('delete'), + onPressed: () async { + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Serie löschen?'), + content: Text(displayName), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen')), + ], + ), ); - } - } - }, - child: Container( - color: bg, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - child: SizedBox( - width: 360, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterPath != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: CachedNetworkImage( - imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', - width: 50, - height: 75, - fit: BoxFit.cover, + if (confirm != true) return; + final messenger = ScaffoldMessenger.of(context); + try { + final backend = ref.read(backendApiProvider); + await backend.deleteShow(sid); + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e'))); + } + }, + ), + MenuItemButton( + child: const Text('open download path'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + if (downloadPath != null && downloadPath.isNotEmpty) { + final ok = await openFolder(downloadPath); + if (!ok) { + messenger.showSnackBar(const SnackBar(content: Text('Pfad kann nicht geöffnet werden'))); + } + } else { + messenger.showSnackBar(const SnackBar(content: Text('Kein Download Path vorhanden'))); + } + }, + ), + ], + builder: (ctx, controller, child) => GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: Container( + color: bg, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + child: widgets.SizedBox( + width: 360, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ), + if (posterPath != null) const widgets.SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _seriesTitle(displayName, year, showStatus, showCliffhanger, context), + const widgets.SizedBox(height: 4), + _resolutionInline(resolution, context), + ], ), ), - if (posterPath != null) const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _seriesTitle(displayName, year, showStatus, showCliffhanger, context), - const SizedBox(height: 4), - _resolutionInline(resolution, context), - ], - ), - ), - ], + // context menu handles actions; no extra icon button + ], + ), ), ), ), @@ -452,14 +593,159 @@ class SeriesListScreen extends ConsumerWidget { for (final s in seasonCols) { final eps = bySeason[s]; if (eps == null || eps.isEmpty) { - cells.add(const DataCell(Text('-', textAlign: TextAlign.center))); + cells.add(DataCell( + MenuAnchor( + menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + MenuItemButton( + child: const Text('set all progress to done'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); return; } + try { + final backend = ref.read(backendApiProvider); + final eps = await backend.getShowEpisodes(sid); + int changed = 0; + for (final e in eps) { + final st = e['status'] as String?; + final id = (e['id'] as num?)?.toInt(); + if (id != null && st == 'Progress') { await backend.setStatus(type: 'episode', refId: id, status: 'Done'); changed++; } + } + ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(SnackBar(content: Text('Erledigt: $changed Episoden'))); + } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); } + }, + ), + MenuItemButton( + child: const Text('update'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); return; } + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + final tid = await backend.getTmdbIdByShowId(sid); + if (tid == null) { messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); return; } + final showJson = await tmdb.getShow(tid); + final dbShowId = await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = await tmdb.getSeason(tid, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + for (final e in eps) { await backend.upsertEpisode(seasonId, e); } + } + ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); + } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); } + }, + ), + MenuItemButton( + child: const Text('delete'), + onPressed: () async { + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Serie löschen?'), + content: Text(displayName), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen')), + ], + ), + ); + if (confirm != true) return; + final messenger = ScaffoldMessenger.of(context); + try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht')));} catch (e) { messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));} + }, + ), + MenuItemButton( + child: const Text('open download path'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + if (downloadPath != null && downloadPath.isNotEmpty) { final ok = await openFolder(downloadPath); if (!ok) { messenger.showSnackBar(const SnackBar(content: Text('Pfad kann nicht geöffnet werden')));} } else { messenger.showSnackBar(const SnackBar(content: Text('Kein Download Path vorhanden')));} + }, + ), + ], + builder: (ctx, controller, child) => GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: const Text('-', textAlign: TextAlign.center), + ), + ), + )); } else { - cells.add(DataCell(EpisodeStatusStrip( - episodes: eps, - barWidth: 7, - barHeight: 25, - spacing: 0, - ))); + cells.add(DataCell( + MenuAnchor( + menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath), + MenuItemButton( + child: const Text('set all progress to done'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); return; } + try { + final backend = ref.read(backendApiProvider); + final eps = await backend.getShowEpisodes(sid); + int changed = 0; + for (final e in eps) { final st = e['status'] as String?; final id = (e['id'] as num?)?.toInt(); if (id != null && st == 'Progress') { await backend.setStatus(type: 'episode', refId: id, status: 'Done'); changed++; } } + ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(SnackBar(content: Text('Erledigt: $changed Episoden'))); + } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); } + }, + ), + MenuItemButton( + child: const Text('update'), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(showKey.split('##').last); + if (sid == null) { messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); return; } + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + final tid = await backend.getTmdbIdByShowId(sid); + if (tid == null) { messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); return; } + final showJson = await tmdb.getShow(tid); + final dbShowId = await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []).where((s) => (s['season_number'] ?? -1) is num).map((s) => (s as Map)['season_number'] as int).toList(); + for (final sNo in seasons) { if (sNo < 0) continue; final seasonJson = await tmdb.getSeason(tid, sNo); final seasonId = await backend.upsertSeason(dbShowId, seasonJson); final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); for (final e in eps) { await backend.upsertEpisode(seasonId, e); } } + ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); + } catch (e) { messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); } + }, + ), + MenuItemButton( + child: const Text('delete'), + onPressed: () async { + final sid = int.tryParse(showKey.split('##').last); if (sid == null) return; final confirm = await showDialog(context: context, builder: (ctx) => AlertDialog(title: const Text('Serie löschen?'), content: Text(displayName), actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen'))])); if (confirm != true) return; final messenger = ScaffoldMessenger.of(context); try { final backend = ref.read(backendApiProvider); await backend.deleteShow(sid); ref.invalidate(seriesGroupedProvider); ref.invalidate(seriesSummaryProvider); messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht')));} catch (e) { messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e')));} + }, + ), + MenuItemButton( + child: const Text('open download path'), + onPressed: () async { final messenger = ScaffoldMessenger.of(context); if (downloadPath != null && downloadPath.isNotEmpty) { final ok = await openFolder(downloadPath); if (!ok) { messenger.showSnackBar(const SnackBar(content: Text('Pfad kann nicht geöffnet werden')));} } else { messenger.showSnackBar(const SnackBar(content: Text('Kein Download Path vorhanden')));} }, + ), + ], + builder: (ctx, controller, child) => GestureDetector( + behavior: HitTestBehavior.opaque, + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: EpisodeStatusStrip( + episodes: eps, + barWidth: 7, + barHeight: 25, + spacing: 0, + ), + ), + ), + )); } } @@ -469,9 +755,9 @@ class SeriesListScreen extends ConsumerWidget { Navigator.of(context).push( MaterialPageRoute( builder: (_) => SeriesDetailScreen( - showName: key, + showName: showKey, displayName: displayName, - showId: int.tryParse(key.split('##').last), + showId: int.tryParse(showKey.split('##').last), year: year, resolution: resolution, posterPath: posterPath, @@ -481,25 +767,24 @@ class SeriesListScreen extends ConsumerWidget { }, )); } - return rows; - } - final table = DataTable( - columns: buildColumns(), - rows: buildRows(), - headingRowHeight: 40, - dataRowMinHeight: 88, - dataRowMaxHeight: 96, - columnSpacing: 16, - showCheckboxColumn: false, - ); + final table = DataTable( + columns: buildColumns(), + rows: rows, + headingRowHeight: 40, + dataRowMinHeight: 88, + dataRowMaxHeight: 96, + columnSpacing: 16, + showCheckboxColumn: false, + ); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SingleChildScrollView(child: table), - ); - }, - ), + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SingleChildScrollView(child: table), + ); + }, + ); + }), ), ], ), @@ -507,19 +792,21 @@ class SeriesListScreen extends ConsumerWidget { ); } - Widget _buildFromSummary(BuildContext context, WidgetRef ref, List> items) { + Widget _buildFromSummary( + BuildContext context, WidgetRef ref, List> items) { // Build and sort keys final keys = []; final byKey = >{}; for (final m in items) { final showId = (m['show_id'] as num).toInt(); final name = (m['name'] as String?) ?? ''; - final key = '${name}##${showId}'; - keys.add(key); - byKey[key] = m; + final rowKey = '$name##$showId'; + keys.add(rowKey); + byKey[rowKey] = m; } - String displayOf(String key) => key.split('##').first; - keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase())); + String displayOf(String rowKey) => rowKey.split('##').first; + keys.sort((a, b) => + displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase())); // Determine all season columns to create a fixed horizontal grid final allSeasons = {}; @@ -578,17 +865,19 @@ class SeriesListScreen extends ConsumerWidget { } } final seasonWidths = { - for (final s in seasonCols) s: ((maxSquares[s] ?? 0) * barW).toDouble().clamp(40.0, 2000.0) + for (final s in seasonCols) + s: ((maxSquares[s] ?? 0) * barW).toDouble().clamp(40.0, 2000.0) }; double tableWidth = 360; for (final s in seasonCols) { tableWidth += 16 + (seasonWidths[s] ?? 74); } - Widget buildRow(String key) { - final data = byKey[key]!; - final name = displayOf(key); + Widget buildRow(String rowKey) { + final data = byKey[rowKey]!; + final name = displayOf(rowKey); final jsonStr = data['json'] as String?; + final String? downloadPath = data['download_path'] as String?; int? year; if (jsonStr != null && jsonStr.isNotEmpty) { try { @@ -601,13 +890,21 @@ class SeriesListScreen extends ConsumerWidget { final resolution = data['resolution'] as String?; final showStatus = () { if (jsonStr != null) { - try { final m = jsonDecode(jsonStr) as Map; return m['status'] as String?; } catch (_) {} + try { + final m = jsonDecode(jsonStr) as Map; + return m['status'] as String?; + } catch (_) {} } return null; }(); final cliff = () { final v = data['cliffhanger']; - if (v is bool) return v; if (v is num) return v != 0; if (v is String) { final s=v.toLowerCase(); return s=='1'||s=='true'||s=='yes'; } + if (v is bool) return v; + if (v is num) return v != 0; + if (v is String) { + final s = v.toLowerCase(); + return s == '1' || s == 'true' || s == 'yes'; + } return null; }(); @@ -624,12 +921,20 @@ class SeriesListScreen extends ConsumerWidget { if (seg.length < 2) continue; final sn = int.tryParse(seg[0]); final counts = seg[1].split(','); - final init = counts.length > 0 ? int.tryParse(counts[0]) ?? 0 : 0; + final init = counts.isNotEmpty ? int.tryParse(counts[0]) ?? 0 : 0; final prog = counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0; final done = counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0; - final tot = counts.length > 3 ? int.tryParse(counts[3]) ?? 0 : (init + prog + done); + final tot = counts.length > 3 + ? int.tryParse(counts[3]) ?? 0 + : (init + prog + done); if (sn != null) { - seasons.add({'season_number': sn, 'init': init, 'progress': prog, 'done': done, 'total': tot}); + seasons.add({ + 'season_number': sn, + 'init': init, + 'progress': prog, + 'done': done, + 'total': tot + }); } } } else { @@ -638,26 +943,41 @@ class SeriesListScreen extends ConsumerWidget { // Use any_progress/any_init flags if provided bool anyProgress = () { final v = data['any_progress']; - if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true'; - for (final s in seasons) { if (((s['progress'] as num?)?.toInt() ?? 0) > 0) return true; } + if (v is num) return v.toInt() != 0; + if (v is bool) return v; + if (v is String) return v == '1' || v.toLowerCase() == 'true'; + for (final s in seasons) { + if (((s['progress'] as num?)?.toInt() ?? 0) > 0) return true; + } return false; }(); bool anyInit = () { final v = data['any_init']; - if (v is num) return v.toInt() != 0; if (v is bool) return v; if (v is String) return v == '1' || v.toLowerCase() == 'true'; - for (final s in seasons) { if (((s['init'] as num?)?.toInt() ?? 0) > 0) return true; } + if (v is num) return v.toInt() != 0; + if (v is bool) return v; + if (v is String) return v == '1' || v.toLowerCase() == 'true'; + for (final s in seasons) { + if (((s['init'] as num?)?.toInt() ?? 0) > 0) return true; + } return false; }(); - Color? bg; if (anyProgress) bg = Colors.blue; else if (anyInit) bg = Colors.grey.shade200; else bg = Colors.green; + Color? bg; + if (anyProgress) { + bg = Colors.blue; + } else if (anyInit) { + bg = Colors.grey.shade200; + } else { + bg = Colors.green; + } return InkWell( onTap: () async { - final res = await Navigator.of(context).push( + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => SeriesDetailScreen( - showName: key, + showName: rowKey, displayName: name, - showId: int.tryParse(key.split('##').last), + showId: int.tryParse(rowKey.split('##').last), seasonsEps: data['seasons_eps'] as String?, year: year, resolution: resolution, @@ -674,50 +994,58 @@ class SeriesListScreen extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left - Container( - color: bg, - padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), - width: 360, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (posterPath != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: CachedNetworkImage( - imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', - width: 50, - height: 75, - fit: BoxFit.cover, - ), - ), - if (posterPath != null) const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, + // Left (with context menu) + MenuAnchor( + menuChildren: _seriesMenuItems(context, ref, rowKey, name, downloadPath), + builder: (ctx, controller, child) => GestureDetector( + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: Container( + color: bg, + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + width: 360, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (posterPath != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ), + if (posterPath != null) const widgets.SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded(child: _seriesTitle(name, year, showStatus, cliff, context)), - const SizedBox(width: 6), - if ((showStatus ?? '').isNotEmpty) _statusBadge(showStatus!, context), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _seriesTitle(name, year, showStatus, cliff, context)), + const widgets.SizedBox(width: 6), + if ((showStatus ?? '').isNotEmpty) _statusBadge(showStatus!, context), + ], + ), + const widgets.SizedBox(height: 4), + _resolutionInline(resolution, context), ], ), - const SizedBox(height: 4), - _resolutionInline(resolution, context), - ], - ), + ), + ], ), - ], + ), ), ), // Seasons columns for (final s in seasonCols) ...[ - const SizedBox(width: 16), - SizedBox( + const widgets.SizedBox(width: 16), + widgets.SizedBox( width: seasonWidths[s] ?? 74, child: Builder(builder: (_) { // Prefer full episode statuses if provided via seasons_eps; fallback to counts @@ -729,7 +1057,10 @@ class SeriesListScreen extends ConsumerWidget { if (part.isEmpty) continue; final seg = part.split(':'); final sn = int.tryParse(seg.first); - if (sn == s) { matchStr = seg.length > 1 ? seg[1] : ''; break; } + if (sn == s) { + matchStr = seg.length > 1 ? seg[1] : ''; + break; + } } if (matchStr != null && matchStr.isNotEmpty) { final eps = []; @@ -737,13 +1068,57 @@ class SeriesListScreen extends ConsumerWidget { for (final eSeg in matchStr.split(',')) { if (eSeg.isEmpty) continue; final kv = eSeg.split('|'); - final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0; - final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; - final st = stCode == 2 ? ItemStatus.Done : (stCode == 1 ? ItemStatus.Progress : ItemStatus.Init); - eps.add(EpisodeItem(id: id--, episodeNumber: epNo, seasonNumber: s, showName: name, status: st)); + final epNo = + kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0; + final stCode = + (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + final st = stCode == 2 + ? ItemStatus.Done + : (stCode == 1 + ? ItemStatus.Progress + : ItemStatus.Init); + eps.add(EpisodeItem( + id: id--, + episodeNumber: epNo, + seasonNumber: s, + showName: name, + status: st)); } if (eps.isNotEmpty) { - return EpisodeStatusStrip(episodes: eps, barWidth: 7, barHeight: 25, spacing: 0); + return MenuAnchor( + menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath), + MenuItemButton( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final dp = downloadPath; + if (dp != null && dp.isNotEmpty) { + final ok = await openFolder(dp); + if (!ok) { + messenger.showSnackBar( + const SnackBar(content: Text('Pfad kann nicht geöffnet werden')), + ); + } + } else { + messenger.showSnackBar( + const SnackBar(content: Text('Kein Download Path vorhanden')), + ); + } + }, + child: const Text('open download path'), + ), + ], + builder: (ctx, controller, child) => GestureDetector( + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: EpisodeStatusStrip( + episodes: eps, + barWidth: 7, + barHeight: 25, + spacing: 0, + ), + ), + ); } } } @@ -753,7 +1128,35 @@ class SeriesListScreen extends ConsumerWidget { orElse: () => const {}, ); if (match.isEmpty) { - return const Text('-', textAlign: TextAlign.center); + return MenuAnchor( + menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath), + MenuItemButton( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final dp = downloadPath; + if (dp != null && dp.isNotEmpty) { + final ok = await openFolder(dp); + if (!ok) { + messenger.showSnackBar( + const SnackBar(content: Text('Pfad kann nicht geöffnet werden')), + ); + } + } else { + messenger.showSnackBar( + const SnackBar(content: Text('Kein Download Path vorhanden')), + ); + } + }, + child: const Text('open download path'), + ), + ], + builder: (ctx, controller, child) => GestureDetector( + onSecondaryTapDown: (_) => controller.open(), + onSecondaryTapUp: (_) => controller.open(), + onLongPress: () => controller.open(), + child: const Text('-', textAlign: TextAlign.center), + ), + ); } final init = (match['init'] as num?)?.toInt() ?? 0; final progress = (match['progress'] as num?)?.toInt() ?? 0; @@ -783,13 +1186,20 @@ class SeriesListScreen extends ConsumerWidget { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - SizedBox(width: 360, child: Text('Serie', style: Theme.of(context).textTheme.labelMedium)), + widgets.SizedBox( + width: 360, + child: Text('Serie', + style: Theme.of(context).textTheme.labelMedium)), for (final s in seasonCols) ...[ - const SizedBox(width: 16), - SizedBox( + const widgets.SizedBox(width: 16), + widgets.SizedBox( width: seasonWidths[s] ?? 74, child: Center( - child: Text('Season $s', style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w600)), + child: Text('Season $s', + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(fontWeight: FontWeight.w600)), ), ), ] @@ -805,7 +1215,7 @@ class SeriesListScreen extends ConsumerWidget { builder: (context, constraints) { return SingleChildScrollView( scrollDirection: Axis.horizontal, - child: SizedBox( + child: widgets.SizedBox( width: tableWidth, height: constraints.maxHeight, child: Stack( @@ -815,7 +1225,8 @@ class SeriesListScreen extends ConsumerWidget { top: 0, left: 0, right: 0, - child: SizedBox(height: headerHeight, child: buildHeader()), + child: widgets.SizedBox( + height: headerHeight, child: buildHeader()), ), // Vertical list below the header Positioned.fill( @@ -861,7 +1272,7 @@ class SeriesListScreen extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (icon != null) Icon(icon, size: 16, color: color), - if (icon != null) const SizedBox(width: 4), + if (icon != null) const widgets.SizedBox(width: 4), Text( label, style: const TextStyle(fontSize: 12), @@ -870,7 +1281,8 @@ class SeriesListScreen extends ConsumerWidget { ); } - Widget _seriesTitle(String name, int? year, String? status, bool? cliff, BuildContext context) { + Widget _seriesTitle(String name, int? year, String? status, bool? cliff, + BuildContext context) { final base = Theme.of(context).textTheme.titleMedium; final s = (status ?? '').toLowerCase(); final endedOrCanceled = s == 'ended' || s == 'canceled' || s == 'cancelled'; @@ -900,8 +1312,139 @@ class SeriesListScreen extends ConsumerWidget { ), child: Text( status, - style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurface), + style: TextStyle( + fontSize: 11, color: Theme.of(context).colorScheme.onSurface), ), ); } } + +extension _SeriesContextMenu on SeriesListScreen { + List _seriesMenuItems( + BuildContext context, + WidgetRef ref, + String seriesKey, + String title, + String? downloadPath, + ) { + Future setAllProgressToDone() async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(seriesKey.split('##').last); + if (sid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); + return; + } + try { + final backend = ref.read(backendApiProvider); + final eps = await backend.getShowEpisodes(sid); + int changed = 0; + for (final e in eps) { + final st = e['status'] as String?; + final id = (e['id'] as num?)?.toInt(); + if (id != null && st == 'Progress') { + await backend.setStatus(type: 'episode', refId: id, status: 'Done'); + changed++; + } + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(SnackBar(content: Text('Erledigt: $changed Episoden'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Fehler: $e'))); + } + } + + Future performUpdate() async { + final messenger = ScaffoldMessenger.of(context); + final sid = int.tryParse(seriesKey.split('##').last); + if (sid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID'))); + return; + } + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + final tid = await backend.getTmdbIdByShowId(sid); + if (tid == null) { + messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); + return; + } + final showJson = await tmdb.getShow(tid); + final dbShowId = await backend.upsertShow(showJson); + final seasons = (showJson['seasons'] as List? ?? const []) + .where((s) => (s['season_number'] ?? -1) is num) + .map((s) => (s as Map)['season_number'] as int) + .toList(); + for (final sNo in seasons) { + if (sNo < 0) continue; + final seasonJson = await tmdb.getSeason(tid, sNo); + final seasonId = await backend.upsertSeason(dbShowId, seasonJson); + final eps = (seasonJson['episodes'] as List? ?? const []).cast>(); + for (final e in eps) { + await backend.upsertEpisode(seasonId, e); + } + } + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e'))); + } + } + + Future performDelete() async { + final sid = int.tryParse(seriesKey.split('##').last); + if (sid == null) return; + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Serie löschen?'), + content: Text(title), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Löschen')), + ], + ), + ); + if (confirm != true) return; + final messenger = ScaffoldMessenger.of(context); + try { + final backend = ref.read(backendApiProvider); + await backend.deleteShow(sid); + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + // ignore: unused_result + ref.invalidate(seriesSummaryProvider); + messenger.showSnackBar(const SnackBar(content: Text('Serie gelöscht'))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Löschen fehlgeschlagen: $e'))); + } + } + + Future openDownload() async { + final messenger = ScaffoldMessenger.of(context); + final dp = downloadPath; + if (dp != null && dp.isNotEmpty) { + final ok = await openFolder(dp); + if (!ok) { + messenger.showSnackBar(const SnackBar(content: Text('Pfad kann nicht geöffnet werden'))); + } + } else { + messenger.showSnackBar(const SnackBar(content: Text('Kein Download Path vorhanden'))); + } + } + + return [ + MenuItemButton(child: const Text('set all progress to done'), onPressed: setAllProgressToDone), + MenuItemButton(child: const Text('update'), onPressed: performUpdate), + MenuItemButton(child: const Text('delete'), onPressed: performDelete), + MenuItemButton(child: const Text('open download path'), onPressed: openDownload), + ]; + } +} + +