From 1ce512b1c4874af676fd13e3970b81f4ea700963 Mon Sep 17 00:00:00 2001 From: Herwig Birke Date: Fri, 31 Oct 2025 08:54:13 +0100 Subject: [PATCH] Add Movies --- lib/core/api/tmdb_api.dart | 20 ++ .../movies/presentation/movie_add_screen.dart | 184 ++++++++++++++++++ .../presentation/movie_detail_screen.dart | 20 +- .../presentation/movie_list_screen.dart | 53 +++-- 4 files changed, 262 insertions(+), 15 deletions(-) create mode 100644 lib/features/movies/presentation/movie_add_screen.dart diff --git a/lib/core/api/tmdb_api.dart b/lib/core/api/tmdb_api.dart index c4ca3a9..7d0f262 100644 --- a/lib/core/api/tmdb_api.dart +++ b/lib/core/api/tmdb_api.dart @@ -36,6 +36,26 @@ class TmdbApi { return Map.from(res.data); } + Future>> searchMovies(String query, {int page = 1}) async { + final res = await _dio.get( + '/search/movie', + queryParameters: { + ..._auth, + 'query': query, + 'page': page, + 'include_adult': false, + }, + ); + if (res.statusCode != 200) { + throw Exception('TMDB searchMovies 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/movies/presentation/movie_add_screen.dart b/lib/features/movies/presentation/movie_add_screen.dart new file mode 100644 index 0000000..35b1222 --- /dev/null +++ b/lib/features/movies/presentation/movie_add_screen.dart @@ -0,0 +1,184 @@ +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'; + +class MovieAddScreen extends ConsumerStatefulWidget { + const MovieAddScreen({super.key}); + + @override + ConsumerState createState() => _MovieAddScreenState(); +} + +class _MovieAddScreenState extends ConsumerState { + final _queryCtrl = TextEditingController(); + bool _loading = false; + List> _results = const []; + final Set _selected = {}; + Set _existing = {}; + + Future _loadExisting() async { + // Fetch a larger set directly from backend to avoid pagination gaps + final backend = ref.read(backendApiProvider); + final list = await backend.getMovies(limit: 5000); + setState(() { + _existing = list + .map((m) => (m['tmdb_id'] as num)) + .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.searchMovies(q); + // filter out existing + 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; + for (final id in _selected) { + try { + final json = await tmdb.getMovie(id); + await backend.upsertMovie(json); + ok++; + } catch (_) {} + } + // Refresh list provider + // ignore: unused_result + ref.invalidate(moviesProvider); + messenger.showSnackBar(SnackBar(content: Text('$ok Film(e) 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 Filme 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: 'Titel 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['title'] ?? m['name'] ?? '') as String; + final release = (m['release_date'] as String?) ?? ''; + final year = release.length >= 4 ? release.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/movies/presentation/movie_detail_screen.dart b/lib/features/movies/presentation/movie_detail_screen.dart index a39a415..41d7467 100644 --- a/lib/features/movies/presentation/movie_detail_screen.dart +++ b/lib/features/movies/presentation/movie_detail_screen.dart @@ -269,7 +269,25 @@ class _MovieDetailScreenState extends ConsumerState { } Widget _resolutionBadge(String? res, BuildContext context) { - if (res == null || res.isEmpty) return const SizedBox.shrink(); + if (res == null || res.isEmpty) { + // Show a tappable placeholder so new movies can set resolution + 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/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index bef8cfe..7686ac1 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -6,6 +6,7 @@ import '../data/movie_repository.dart'; import 'widgets/status_chip.dart'; import 'movie_detail_screen.dart'; import '../../shared/providers.dart'; +import 'movie_add_screen.dart'; class MovieListScreen extends ConsumerWidget { const MovieListScreen({super.key}); @@ -27,10 +28,25 @@ class MovieListScreen extends ConsumerWidget { const SizedBox(height: 8), Align( alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(Icons.sync), - label: const Text('TMDB: alle Filme updaten'), - onPressed: () async { + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Neue Filme hinzufügen'), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const MovieAddScreen(), + ), + ); + }, + ), + const SizedBox(width: 8), + TextButton.icon( + icon: const Icon(Icons.sync), + label: const Text('TMDB: alle Filme updaten'), + onPressed: () async { final messenger = ScaffoldMessenger.of(context); String current = ''; int idx = 0; @@ -86,7 +102,9 @@ class MovieListScreen extends ConsumerWidget { }); }, ); - }, + }, + ), + ], ), ), const SizedBox(height: 12), @@ -125,17 +143,24 @@ class MovieListScreen extends ConsumerWidget { subtitle: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 56, - child: Align( - alignment: Alignment.topLeft, - child: FittedBox( + (() { + final hasRes = (m.resolution != null && m.resolution!.isNotEmpty); + if (!hasRes) { + // Keep horizontal layout stable but avoid FittedBox with zero-size child + return const SizedBox(width: 56); + } + return SizedBox( + width: 56, + child: Align( alignment: Alignment.topLeft, - fit: BoxFit.scaleDown, - child: _resolutionBadge(m.resolution, context), + child: FittedBox( + alignment: Alignment.topLeft, + fit: BoxFit.scaleDown, + child: _resolutionBadge(m.resolution, context), + ), ), - ), - ), + ); + })(), const SizedBox(width: 6), Expanded( child: Text(