Add Movies
This commit is contained in:
@@ -36,6 +36,26 @@ class TmdbApi {
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> 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<String, dynamic>;
|
||||
final results = (data['results'] as List? ?? const [])
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getShow(int id) async {
|
||||
final res = await _dio.get(
|
||||
'/tv/$id',
|
||||
|
||||
@@ -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<MovieAddScreen> createState() => _MovieAddScreenState();
|
||||
}
|
||||
|
||||
class _MovieAddScreenState extends ConsumerState<MovieAddScreen> {
|
||||
final _queryCtrl = TextEditingController();
|
||||
bool _loading = false;
|
||||
List<Map<String, dynamic>> _results = const [];
|
||||
final Set<int> _selected = {};
|
||||
Set<int> _existing = {};
|
||||
|
||||
Future<void> _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<void> _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<void> _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);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -269,7 +269,25 @@ class _MovieDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user