File Details
This commit is contained in:
@@ -136,6 +136,14 @@ class BackendApi {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setMovieResolution({required int movieId, required String resolution}) async {
|
||||
await _post({
|
||||
'action': 'set_movie_resolution',
|
||||
'movie_id': movieId,
|
||||
'resolution': resolution,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEpisodes({
|
||||
String? status,
|
||||
String? q,
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
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';
|
||||
import '../data/movie_model.dart';
|
||||
|
||||
class MovieDetailScreen extends ConsumerStatefulWidget {
|
||||
final Movie movie;
|
||||
const MovieDetailScreen({super.key, required this.movie});
|
||||
|
||||
@override
|
||||
ConsumerState<MovieDetailScreen> createState() => _MovieDetailScreenState();
|
||||
}
|
||||
|
||||
class _MovieDetailScreenState extends ConsumerState<MovieDetailScreen> {
|
||||
late ItemStatus _status;
|
||||
Map<String, dynamic>? _tmdb;
|
||||
bool _loading = false;
|
||||
String? _resolution;
|
||||
late ItemStatus _origStatus;
|
||||
late String? _origResolution;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_status = widget.movie.status;
|
||||
_resolution = widget.movie.resolution;
|
||||
_origStatus = widget.movie.status;
|
||||
_origResolution = widget.movie.resolution;
|
||||
_loadTmdb();
|
||||
}
|
||||
|
||||
Future<void> _loadTmdb() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final tmdb = ref.read(tmdbApiProvider);
|
||||
final data = await tmdb.getMovie(widget.movie.tmdbId);
|
||||
setState(() => _tmdb = data);
|
||||
} catch (e) {
|
||||
// ignore error, keep UI functional
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _setStatus(ItemStatus s) => setState(() => _status = s);
|
||||
|
||||
void _setResolution(String res) => setState(() => _resolution = res);
|
||||
|
||||
bool get _dirty => _status != _origStatus || _resolution != _origResolution;
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
final api = ref.read(backendApiProvider);
|
||||
if (_status != _origStatus) {
|
||||
await api.setStatus(type: 'movie', refId: widget.movie.id, status: _status.name);
|
||||
}
|
||||
if (_resolution != _origResolution && _resolution != null && _resolution!.isNotEmpty) {
|
||||
await api.setMovieResolution(movieId: widget.movie.id, resolution: _resolution!);
|
||||
}
|
||||
if (mounted) {
|
||||
// Refresh movie list so changes are visible when navigating back
|
||||
// ignore: unused_result
|
||||
ref.invalidate(moviesProvider);
|
||||
// Update originals to hide FAB
|
||||
setState(() {
|
||||
_origStatus = _status;
|
||||
_origResolution = _resolution;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Gespeichert')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Speichern fehlgeschlagen: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final m = widget.movie;
|
||||
final backdrop = _tmdb?['backdrop_path'] as String?;
|
||||
final poster = m.posterPath;
|
||||
return Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(m.title),
|
||||
),
|
||||
floatingActionButton: _dirty
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: _save,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Speichern'),
|
||||
)
|
||||
: null,
|
||||
body: Container(
|
||||
decoration: (backdrop != null || poster != null)
|
||||
? BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: NetworkImage(backdrop != null
|
||||
? 'https://image.tmdb.org/t/p/w780$backdrop'
|
||||
: 'https://image.tmdb.org/t/p/w342$poster'),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
foregroundDecoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withOpacity(0.2),
|
||||
Colors.black.withOpacity(0.6),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + kToolbarHeight + 12,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 24,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header box
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (m.posterPath != null)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: 'https://image.tmdb.org/t/p/w342${m.posterPath}',
|
||||
width: 120,
|
||||
height: 180,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
if (m.posterPath != null) const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
m.releaseYear != null ? '${m.title} (${m.releaseYear})' : m.title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _chooseResolution,
|
||||
child: _resolutionBadge(_resolution, context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_statusSelector(_status),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if ((m.overview ?? '').isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Beschreibung', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
|
||||
const SizedBox(height: 6),
|
||||
Text(m.overview!),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_tmdb != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.35),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: DefaultTextStyle(
|
||||
style: const TextStyle(color: Colors.white),
|
||||
child: _castAndCrew(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statusSelector(ItemStatus current) {
|
||||
final items = ItemStatus.values;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final s in items)
|
||||
ChoiceChip(
|
||||
label: Text(s.name),
|
||||
selected: current == s,
|
||||
onSelected: (sel) {
|
||||
if (sel) _setStatus(s);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resolutionBadge(String? res, BuildContext context) {
|
||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
||||
final m = RegExp(r"\d+").firstMatch(res);
|
||||
final v = m != null ? int.tryParse(m.group(0)!) : null;
|
||||
IconData? icon;
|
||||
Color color = Theme.of(context).colorScheme.primary;
|
||||
String label = res;
|
||||
if (v != null) {
|
||||
if (v >= 2000) {
|
||||
icon = Icons.four_k;
|
||||
color = Colors.deepOrange;
|
||||
label = '${v}p';
|
||||
} else if (v >= 1000) {
|
||||
icon = Icons.hd;
|
||||
color = Colors.blue;
|
||||
label = '${v}p';
|
||||
} else {
|
||||
icon = Icons.sd_card;
|
||||
color = Colors.grey;
|
||||
label = '${v}p';
|
||||
}
|
||||
}
|
||||
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: [
|
||||
if (icon != null) Icon(icon, size: 16, color: color),
|
||||
if (icon != null) const SizedBox(width: 6),
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: Colors.white)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _chooseResolution() async {
|
||||
const options = ['320p', '576p', '720p', '1080p', '2160p'];
|
||||
final selected = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
backgroundColor: Colors.black.withOpacity(0.85),
|
||||
title: const Text('Resolution wählen', style: TextStyle(color: Colors.white)),
|
||||
children: [
|
||||
for (final opt in options)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(ctx).pop(opt),
|
||||
child: Text(
|
||||
opt,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (selected != null) {
|
||||
_setResolution(selected);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _castAndCrew() {
|
||||
final credits = _tmdb?['credits'] as Map<String, dynamic>?;
|
||||
if (credits == null) return const SizedBox.shrink();
|
||||
final cast = (credits['cast'] as List? ?? const [])
|
||||
.cast<Map>()
|
||||
.take(12)
|
||||
.toList();
|
||||
final crew = (credits['crew'] as List? ?? const [])
|
||||
.cast<Map>()
|
||||
.where((m) => (m['job'] == 'Director') || (m['job'] == 'Writer'))
|
||||
.toList();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Cast', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: cast.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final c = cast[i] as Map<String, dynamic>;
|
||||
final name = c['name']?.toString() ?? '';
|
||||
final character = c['character']?.toString() ?? '';
|
||||
final profile = c['profile_path']?.toString();
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: profile != null
|
||||
? Image.network('https://image.tmdb.org/t/p/w185$profile',
|
||||
width: 100, height: 96, fit: BoxFit.cover)
|
||||
: Container(
|
||||
width: 100,
|
||||
height: 96,
|
||||
color: Colors.black12,
|
||||
child: const Icon(Icons.person, size: 40),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
Text(character,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Crew', style: Theme.of(context).textTheme.titleMedium?.copyWith(color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: crew.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final cr = crew[i] as Map<String, dynamic>;
|
||||
final name = cr['name']?.toString() ?? '';
|
||||
final job = cr['job']?.toString() ?? '';
|
||||
final profile = cr['profile_path']?.toString();
|
||||
return SizedBox(
|
||||
width: 100,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: profile != null
|
||||
? Image.network('https://image.tmdb.org/t/p/w185$profile',
|
||||
width: 100, height: 96, fit: BoxFit.cover)
|
||||
: Container(
|
||||
width: 100,
|
||||
height: 96,
|
||||
color: Colors.black12,
|
||||
child: const Icon(Icons.person, size: 40),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
Text(job,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/status.dart';
|
||||
import '../data/movie_repository.dart';
|
||||
import 'widgets/status_chip.dart';
|
||||
import 'movie_detail_screen.dart';
|
||||
|
||||
class MovieListScreen extends ConsumerWidget {
|
||||
const MovieListScreen({super.key});
|
||||
@@ -81,7 +82,11 @@ class MovieListScreen extends ConsumerWidget {
|
||||
),
|
||||
isThreeLine: true,
|
||||
onTap: () {
|
||||
// TODO: Detailseite / Status ändern
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MovieDetailScreen(movie: m),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
@@ -215,6 +215,15 @@ try {
|
||||
resp(['ok'=>true]);
|
||||
}
|
||||
|
||||
case 'set_movie_resolution': {
|
||||
$movieId = (int)($in['movie_id'] ?? 0);
|
||||
$res = $in['resolution'] ?? null;
|
||||
if (!$movieId || !$res) fail('bad params');
|
||||
$stmt = $pdo->prepare('UPDATE movies SET resolution = ? WHERE id = ?');
|
||||
$stmt->execute([$res, $movieId]);
|
||||
resp(['ok' => true]);
|
||||
}
|
||||
|
||||
case 'get_list': {
|
||||
$type=$in['type'] ?? 'movie';
|
||||
$status=$in['status'] ?? null;
|
||||
|
||||
Reference in New Issue
Block a user