chore: init repository with project sources
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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 '../../../core/status.dart';
|
||||
import '../../movies/presentation/widgets/status_chip.dart';
|
||||
import '../data/series_repository.dart';
|
||||
import 'widgets/episode_status_strip.dart';
|
||||
|
||||
class SeriesListScreen extends ConsumerWidget {
|
||||
const SeriesListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final filter = ref.watch(episodeFilterProvider);
|
||||
final groupedAsync = ref.watch(seriesGroupedProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusChip(
|
||||
selected: filter,
|
||||
onChanged: (f) =>
|
||||
ref.read(episodeFilterProvider.notifier).state = f,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
child: groupedAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(child: Text('Fehler: $e')),
|
||||
data: (data) {
|
||||
final shows = data.data.keys.toList()..sort();
|
||||
if (shows.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Keine Episoden gefunden.'));
|
||||
}
|
||||
// Collect all season columns across all shows
|
||||
final allSeasons = <int>{};
|
||||
for (final m in data.data.values) {
|
||||
allSeasons.addAll(m.keys);
|
||||
}
|
||||
final seasonCols = allSeasons.toList()..sort();
|
||||
|
||||
List<DataColumn> buildColumns() => [
|
||||
const DataColumn(label: Text('Serie')),
|
||||
for (final s in seasonCols) DataColumn(label: Text('Staffel $s')),
|
||||
];
|
||||
|
||||
List<DataRow> buildRows() {
|
||||
final rows = <DataRow>[];
|
||||
for (final showName in shows) {
|
||||
final bySeason = data.data[showName]!;
|
||||
int? year;
|
||||
String? resolution;
|
||||
String? showJson;
|
||||
String? showStatus;
|
||||
bool? showCliffhanger;
|
||||
String? posterPath;
|
||||
final sortedSeasons = bySeason.keys.toList()..sort();
|
||||
for (final s in sortedSeasons) {
|
||||
final eps = bySeason[s]!;
|
||||
for (final e in eps) {
|
||||
year ??= e.firstAirYear;
|
||||
resolution ??= e.resolution;
|
||||
showJson ??= e.showJson;
|
||||
showStatus ??= e.showStatus;
|
||||
showCliffhanger ??= e.showCliffhanger;
|
||||
posterPath ??= e.posterPath;
|
||||
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) {
|
||||
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 (m is Map && showStatus == null && m['status'] is String) {
|
||||
showStatus = m['status'] as String;
|
||||
}
|
||||
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) {
|
||||
final s = v.toLowerCase();
|
||||
showCliffhanger = (s == '1' || s == 'true' || s == 'yes');
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Determine background for left cell based on episodes status
|
||||
bool anyProgress = false;
|
||||
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.Init) anyInit = true;
|
||||
}
|
||||
}
|
||||
Color? bg;
|
||||
if (anyProgress) bg = Colors.blue;
|
||||
else if (anyInit) bg = Colors.grey.shade200;
|
||||
else bg = Colors.green;
|
||||
|
||||
final cells = <DataCell>[
|
||||
DataCell(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 (posterPath != null) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_seriesTitle(showName, year, showStatus, showCliffhanger, context),
|
||||
const SizedBox(height: 4),
|
||||
_resolutionInline(resolution, context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)),
|
||||
];
|
||||
|
||||
for (final s in seasonCols) {
|
||||
final eps = bySeason[s];
|
||||
if (eps == null || eps.isEmpty) {
|
||||
cells.add(const DataCell(Text('-', textAlign: TextAlign.center)));
|
||||
} else {
|
||||
cells.add(DataCell(EpisodeStatusStrip(
|
||||
episodes: eps,
|
||||
barWidth: 7,
|
||||
barHeight: 25,
|
||||
spacing: 0,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
rows.add(DataRow(cells: cells));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
final table = DataTable(
|
||||
columns: buildColumns(),
|
||||
rows: buildRows(),
|
||||
headingRowHeight: 40,
|
||||
dataRowMinHeight: 88,
|
||||
dataRowMaxHeight: 96,
|
||||
columnSpacing: 16,
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SingleChildScrollView(child: table),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resolutionInline(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 Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) Icon(icon, size: 16, color: color),
|
||||
if (icon != null) const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
final fw = endedOrCanceled ? FontWeight.normal : FontWeight.w600;
|
||||
final fs = (cliff == true) ? FontStyle.italic : FontStyle.normal;
|
||||
final style = base?.copyWith(fontWeight: fw, fontStyle: fs);
|
||||
final text = year != null ? '$name ($year)' : name;
|
||||
return Text(text, overflow: TextOverflow.ellipsis, style: style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../../core/status.dart';
|
||||
import '../../data/episode_model.dart';
|
||||
|
||||
class EpisodeStatusStrip extends StatelessWidget {
|
||||
final List<EpisodeItem> episodes;
|
||||
final double barWidth; // Breite eines Quadrats
|
||||
final double barHeight; // Höhe eines Quadrats
|
||||
final double spacing; // Abstand zwischen Quadraten
|
||||
|
||||
const EpisodeStatusStrip({
|
||||
super.key,
|
||||
required this.episodes,
|
||||
this.barWidth = 7,
|
||||
this.barHeight = 25,
|
||||
this.spacing = 0,
|
||||
});
|
||||
|
||||
Color _fill(ItemStatus s) {
|
||||
switch (s) {
|
||||
case ItemStatus.Init:
|
||||
return Colors.grey.shade200; // wie Filme (Init)
|
||||
case ItemStatus.Progress:
|
||||
return Colors.blue; // wie Filme (Progress)
|
||||
case ItemStatus.Done:
|
||||
return Colors.green; // wie Filme (Done)
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (episodes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < episodes.length; i++) ...[
|
||||
Container(
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: _fill(episodes[i].status),
|
||||
border: Border(
|
||||
left: BorderSide(color: Colors.black, width: i == 0 ? 1 : 0),
|
||||
top: const BorderSide(color: Colors.black, width: 1),
|
||||
right: const BorderSide(color: Colors.black, width: 1),
|
||||
bottom: const BorderSide(color: Colors.black, width: 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i != episodes.length - 1 && spacing != 0) SizedBox(width: spacing),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user