From 72b4f4e30c2fc30d3cf236222b6b8adaefc0e1e6 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Fri, 31 Oct 2025 09:50:17 +0100 Subject: [PATCH] Add Serie --- lib/core/api/tmdb_api.dart | 20 ++ .../series/data/series_repository.dart | 8 +- .../presentation/series_add_screen.dart | 205 ++++++++++++++++++ .../presentation/series_detail_screen.dart | 29 ++- .../presentation/series_list_screen.dart | 52 ++++- 5 files changed, 297 insertions(+), 17 deletions(-) create mode 100644 lib/features/series/presentation/series_add_screen.dart diff --git a/lib/core/api/tmdb_api.dart b/lib/core/api/tmdb_api.dart index 7d0f262..88cbce7 100644 --- a/lib/core/api/tmdb_api.dart +++ b/lib/core/api/tmdb_api.dart @@ -56,6 +56,26 @@ class TmdbApi { return results; } + Future>> searchShows(String query, {int page = 1}) async { + final res = await _dio.get( + '/search/tv', + queryParameters: { + ..._auth, + 'query': query, + 'page': page, + 'include_adult': false, + }, + ); + if (res.statusCode != 200) { + throw Exception('TMDB searchShows failed: ${res.statusCode} ${res.data}'); + } + final data = res.data as Map; + final results = (data['results'] as List? ?? const []) + .map((e) => Map.from(e as Map)) + .toList(); + return results; + } + Future> getShow(int id) async { final res = await _dio.get( '/tv/$id', diff --git a/lib/features/series/data/series_repository.dart b/lib/features/series/data/series_repository.dart index 8a58d2a..3a65ead 100644 --- a/lib/features/series/data/series_repository.dart +++ b/lib/features/series/data/series_repository.dart @@ -19,14 +19,16 @@ final seriesGroupedProvider = class SeriesGroupedData { final Map>> - data; // showName -> season -> episodes + data; // groupKey -> season -> episodes (groupKey is unique per show) SeriesGroupedData(this.data); factory SeriesGroupedData.fromEpisodes(List items) { final map = >>{}; for (final e in items) { - final bySeason = - map.putIfAbsent(e.showName, () => >{}); + final key = e.showId != null + ? '${e.showName}##${e.showId}' + : (e.firstAirYear != null ? '${e.showName} (${e.firstAirYear})' : e.showName); + final bySeason = map.putIfAbsent(key, () => >{}); final list = bySeason.putIfAbsent(e.seasonNumber, () => []); list.add(e); } diff --git a/lib/features/series/presentation/series_add_screen.dart b/lib/features/series/presentation/series_add_screen.dart new file mode 100644 index 0000000..b60c1b1 --- /dev/null +++ b/lib/features/series/presentation/series_add_screen.dart @@ -0,0 +1,205 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/providers.dart'; +import '../data/series_repository.dart'; + +class SeriesAddScreen extends ConsumerStatefulWidget { + const SeriesAddScreen({super.key}); + + @override + ConsumerState createState() => _SeriesAddScreenState(); +} + +class _SeriesAddScreenState extends ConsumerState { + final _queryCtrl = TextEditingController(); + bool _loading = false; + List> _results = const []; + final Set _selected = {}; + Set _existing = {}; + + Future _loadExisting() async { + // Query backend for existing shows to filter out (tmdb_id) + final backend = ref.read(backendApiProvider); + final rows = await backend.listShows(); + setState(() { + _existing = rows + .map((m) => (m['tmdb_id'] as num?)) + .whereType() + .map((n) => n.toInt()) + .toSet(); + }); + } + + Future _search() async { + final q = _queryCtrl.text.trim(); + if (q.isEmpty) return; + setState(() { + _loading = true; + _results = const []; + _selected.clear(); + }); + try { + await _loadExisting(); + final tmdb = ref.read(tmdbApiProvider); + final items = await tmdb.searchShows(q); + final filtered = items + .where((m) => !_existing.contains((m['id'] as num).toInt())) + .toList(); + setState(() => _results = filtered); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Suche fehlgeschlagen: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _addSelected() async { + if (_selected.isEmpty) return; + setState(() => _loading = true); + final messenger = ScaffoldMessenger.of(context); + try { + final tmdb = ref.read(tmdbApiProvider); + final backend = ref.read(backendApiProvider); + int ok = 0; + final ids = List.from(_selected); + for (final id in ids) { + try { + final showJson = await tmdb.getShow(id); + 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) { + // Include all seasons; frontend handles hiding S0 + final seasonJson = await tmdb.getSeason(id, 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++; + if (mounted) setState(() {}); + } catch (_) {} + } + // Refresh series list/table + // ignore: unused_result + ref.invalidate(seriesGroupedProvider); + messenger.showSnackBar( + SnackBar(content: Text('$ok Serie(n) hinzugefügt'))); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + messenger + .showSnackBar(SnackBar(content: Text('Hinzufügen fehlgeschlagen: $e'))); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Neue Serien hinzufügen (TMDB)'), + actions: [ + if (_selected.isNotEmpty) + TextButton.icon( + onPressed: _loading ? null : _addSelected, + icon: const Icon(Icons.add), + label: Text('Hinzufügen (${_selected.length})'), + ) + ], + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _queryCtrl, + decoration: const InputDecoration( + hintText: 'Serien suchen…', + prefixIcon: Icon(Icons.search), + ), + onSubmitted: (_) => _search(), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _loading ? null : _search, + icon: const Icon(Icons.search), + label: const Text('Suchen'), + ), + ], + ), + const SizedBox(height: 12), + if (_loading) const LinearProgressIndicator(), + const SizedBox(height: 8), + Expanded( + child: _results.isEmpty + ? const Center(child: Text('Keine Ergebnisse')) + : ListView.separated( + itemCount: _results.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final m = _results[i]; + final id = (m['id'] as num).toInt(); + final title = (m['name'] ?? m['original_name'] ?? '') as String; + final firstAir = (m['first_air_date'] as String?) ?? ''; + final year = firstAir.length >= 4 ? firstAir.substring(0, 4) : ''; + final poster = m['poster_path'] as String?; + final sel = _selected.contains(id); + return ListTile( + onTap: () { + setState(() { + if (sel) { + _selected.remove(id); + } else { + _selected.add(id); + } + }); + }, + leading: poster != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: 'https://image.tmdb.org/t/p/w154$poster', + width: 50, + height: 75, + fit: BoxFit.cover, + ), + ) + : const SizedBox(width: 50, height: 75), + title: + Text(year.isNotEmpty ? '$title ($year)' : title), + trailing: Checkbox( + value: sel, + onChanged: (v) { + setState(() { + if (v == true) { + _selected.add(id); + } else { + _selected.remove(id); + } + }); + }, + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/series/presentation/series_detail_screen.dart b/lib/features/series/presentation/series_detail_screen.dart index 40b2054..430ad8e 100644 --- a/lib/features/series/presentation/series_detail_screen.dart +++ b/lib/features/series/presentation/series_detail_screen.dart @@ -9,13 +9,17 @@ import '../data/series_repository.dart'; import '../data/episode_model.dart'; class SeriesDetailScreen extends ConsumerStatefulWidget { + // showName is the grouping key (unique). final String showName; + // displayName is the human title used in UI. + final String? displayName; final int? year; final String? resolution; final String? posterPath; const SeriesDetailScreen({ super.key, required this.showName, + this.displayName, this.year, this.resolution, this.posterPath, @@ -237,7 +241,7 @@ class _SeriesDetailScreenState extends ConsumerState { appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, - title: Text(widget.showName), + title: Text(widget.displayName ?? widget.showName), actions: [ IconButton( tooltip: 'TMDB aktualisieren', @@ -416,7 +420,9 @@ class _SeriesDetailScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.year != null ? '${widget.showName} (${widget.year})' : widget.showName, + widget.year != null + ? '${(widget.displayName ?? widget.showName)} (${widget.year})' + : (widget.displayName ?? widget.showName), style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white), ), const SizedBox(height: 8), @@ -628,7 +634,24 @@ class _SeriesDetailScreenState extends ConsumerState { } Widget _resolutionBadge(String? res, BuildContext context) { - if (res == null || res.isEmpty) return const SizedBox.shrink(); + if (res == null || res.isEmpty) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.45), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: Colors.white.withOpacity(0.6)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.hd_outlined, size: 16, color: Colors.white70), + SizedBox(width: 6), + Text('Auflösung wählen', style: TextStyle(fontSize: 13, color: Colors.white70)), + ], + ), + ); + } final m = RegExp(r"\d+").firstMatch(res); final v = m != null ? int.tryParse(m.group(0)!) : null; IconData? icon; diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index f186ef0..7f69ec2 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -7,6 +7,7 @@ import '../../movies/presentation/widgets/status_chip.dart'; import '../data/series_repository.dart'; import 'widgets/episode_status_strip.dart'; import 'series_detail_screen.dart'; +import 'series_add_screen.dart'; import '../../shared/providers.dart'; class SeriesListScreen extends ConsumerWidget { @@ -31,10 +32,25 @@ class SeriesListScreen extends ConsumerWidget { const SizedBox(height: 8), Align( alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(Icons.sync), - label: const Text('TMDB: alle Serien updaten'), - onPressed: () async { + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Neue Serien hinzufügen'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SeriesAddScreen(), + ), + ); + }, + ), + const 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; @@ -128,7 +144,9 @@ class SeriesListScreen extends ConsumerWidget { }); }, ); - }, + }, + ), + ], ), ), const SizedBox(height: 6), @@ -239,8 +257,18 @@ class SeriesListScreen extends ConsumerWidget { loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Fehler: $e')), data: (data) { - final shows = data.data.keys.toList()..sort(); - if (shows.isEmpty) { + 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; + } + return key; + } + keys.sort((a, b) => displayOf(a).toLowerCase().compareTo(displayOf(b).toLowerCase())); + if (keys.isEmpty) { return const Center( child: Text('Keine Episoden gefunden.')); } @@ -258,14 +286,15 @@ class SeriesListScreen extends ConsumerWidget { List buildRows() { final rows = []; - for (final showName in shows) { - final bySeason = data.data[showName]!; + for (final key in keys) { + final bySeason = data.data[key]!; int? year; String? resolution; String? showJson; String? showStatus; bool? showCliffhanger; String? posterPath; + String displayName = displayOf(key); final sortedSeasons = bySeason.keys.toList()..sort(); for (final s in sortedSeasons) { final eps = bySeason[s]!; @@ -341,7 +370,7 @@ class SeriesListScreen extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - _seriesTitle(showName, year, showStatus, showCliffhanger, context), + _seriesTitle(displayName, year, showStatus, showCliffhanger, context), const SizedBox(height: 4), _resolutionInline(resolution, context), ], @@ -373,7 +402,8 @@ class SeriesListScreen extends ConsumerWidget { Navigator.of(context).push( MaterialPageRoute( builder: (_) => SeriesDetailScreen( - showName: showName, + showName: key, + displayName: displayName, year: year, resolution: resolution, posterPath: posterPath,