diff --git a/lib/features/series/data/series_exporter.dart b/lib/features/series/data/series_exporter.dart new file mode 100644 index 0000000..5dceeb1 --- /dev/null +++ b/lib/features/series/data/series_exporter.dart @@ -0,0 +1,97 @@ +import '../../../core/status.dart'; +import 'episode_model.dart'; +import 'series_exporter_stub.dart' + if (dart.library.html) 'series_exporter_web.dart' + if (dart.library.io) 'series_exporter_io.dart'; + +SeriesExporter createSeriesExporter() => getSeriesExporter(); + +class SeriesExportRow { + final String title; + final int? year; + final String? seriesStatus; + final bool? cliffhanger; + final String? resolution; + final String? downloadPath; + final int seasons; + final int episodesInit; + final int episodesProgress; + final int episodesDone; + final Map>? episodesBySeason; // optional per-episode status + + int get episodesTotal => episodesInit + episodesProgress + episodesDone; + + SeriesExportRow({ + required this.title, + required this.year, + required this.seriesStatus, + required this.cliffhanger, + required this.resolution, + required this.downloadPath, + required this.seasons, + required this.episodesInit, + required this.episodesProgress, + required this.episodesDone, + this.episodesBySeason, + }); + + factory SeriesExportRow.fromGroup(Map> bySeason, + {bool includeEpisodeStatus = false}) { + String title = ''; + int? year; + String? status; + bool? cliff; + String? resolution; + String? download; + int init = 0, prog = 0, done = 0; + final perSeason = >{}; + for (final eps in bySeason.values) { + for (final e in eps) { + title = e.showName; + year ??= e.firstAirYear; + status ??= e.showStatus; + cliff ??= e.showCliffhanger; + resolution ??= e.resolution; + download ??= e.downloadPath; + if (includeEpisodeStatus) { + perSeason.putIfAbsent(e.seasonNumber, () => {})[e.episodeNumber] = e.status; + } + switch (e.status) { + case ItemStatus.Init: + init++; + break; + case ItemStatus.Progress: + prog++; + break; + case ItemStatus.Done: + done++; + break; + } + } + } + return SeriesExportRow( + title: title, + year: year, + seriesStatus: status, + cliffhanger: cliff, + resolution: resolution, + downloadPath: download, + seasons: bySeason.length, + episodesInit: init, + episodesProgress: prog, + episodesDone: done, + episodesBySeason: includeEpisodeStatus ? perSeason : null, + ); + } +} + +abstract class SeriesExporter { + Future chooseSavePath({required bool asXlsx, ItemStatus? filter}); + + Future exportSeries( + List rows, { + required bool asXlsx, + ItemStatus? filter, + String? path, + }); +} diff --git a/lib/features/series/data/series_exporter_io.dart b/lib/features/series/data/series_exporter_io.dart new file mode 100644 index 0000000..0a85231 --- /dev/null +++ b/lib/features/series/data/series_exporter_io.dart @@ -0,0 +1,270 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio; + +import '../../../core/status.dart'; +import 'series_exporter.dart'; + +SeriesExporter getSeriesExporter() => _SeriesExporterIo(); + +class _SeriesExporterIo implements SeriesExporter { + @override + Future chooseSavePath({required bool asXlsx, ItemStatus? filter}) async { + final name = _defaultFilename(filter, asXlsx); + String? savePath; + try { + savePath = await FilePicker.platform.saveFile( + dialogTitle: 'Export speichern', + fileName: name, + type: FileType.custom, + allowedExtensions: [asXlsx ? 'xlsx' : 'csv'], + ); + } on PlatformException catch (e) { + throw Exception('Speicherdialog fehlgeschlagen: ${e.code}'); + } + return savePath; + } + + @override + Future exportSeries( + List rows, { + required bool asXlsx, + ItemStatus? filter, + String? path, + }) async { + final savePath = path ?? await chooseSavePath(asXlsx: asXlsx, filter: filter); + if (savePath == null) return null; + if (asXlsx) { + await _writeXlsx(savePath, rows); + } else { + await _writeCsv(savePath, rows); + } + return savePath; + } + + Future _writeCsv(String path, List rows) async { + final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList(); + final hasEpisodes = rowsWithEpisodes.isNotEmpty; + final episodeSeasons = {}; + if (hasEpisodes) { + for (final r in rowsWithEpisodes) { + episodeSeasons.addAll(r.episodesBySeason!.keys); + } + } + final seasonList = episodeSeasons.toList()..sort(); + final header = [ + 'Titel', + 'Jahr', + 'Serienstatus', + 'Cliffhanger', + 'Aufloesung', + 'DownloadPfad', + 'Staffeln', + 'Init', + 'Progress', + 'Done', + 'Episoden Gesamt', + for (final s in seasonList) ...[ + 'S$s', + for (int ep = 1; ep <= _maxEpForSeason(rowsWithEpisodes, s); ep++) 'E$ep', + ], + ]; + + final csvRows = >[]; + // First header row: season labels over episode columns + final seasonHeader = []; + seasonHeader.addAll(header.take(11)); + for (final s in seasonList) { + seasonHeader.add('Staffel $s'); + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + for (int ep = 1; ep <= maxEp; ep++) { + seasonHeader.add(''); + } + } + csvRows.add(seasonHeader); + // Second header row: episode numbers (aligned) with blanks for season column + final epHeader = []; + epHeader.addAll(header.take(11)); + for (final s in seasonList) { + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + epHeader.add(''); // season label column already in first header + for (int ep = 1; ep <= maxEp; ep++) { + epHeader.add('E$ep'); + } + } + csvRows.add(epHeader); + + for (final r in rows) { + final row = [ + r.title, + r.year?.toString() ?? '', + r.seriesStatus ?? '', + r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'), + r.resolution ?? '', + r.downloadPath ?? '', + r.seasons.toString(), + r.episodesInit.toString(), + r.episodesProgress.toString(), + r.episodesDone.toString(), + r.episodesTotal.toString(), + ]; + if (hasEpisodes) { + for (final s in seasonList) { + final bySeason = r.episodesBySeason ?? const {}; + final eps = bySeason[s] ?? const {}; + row.add('S$s'); + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + for (int ep = 1; ep <= maxEp; ep++) { + final st = eps[ep]; + row.add(_statusCode(st)); + } + } + } + csvRows.add(row); + } + final content = _toCsv(csvRows); + final file = File(path); + await file.writeAsString(content, flush: true); + } + + Future _writeXlsx(String path, List rows) async { + final wb = xlsio.Workbook(); + final sheet = wb.worksheets[0]; + sheet.name = 'Serien'; + final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList(); + final hasEpisodes = rowsWithEpisodes.isNotEmpty; + final episodeSeasons = {}; + if (hasEpisodes) { + for (final r in rowsWithEpisodes) { + episodeSeasons.addAll(r.episodesBySeason!.keys); + } + } + final seasonList = episodeSeasons.toList()..sort(); + + final headers = [ + 'Titel', + 'Jahr', + 'Serienstatus', + 'Cliffhanger', + 'Aufloesung', + 'DownloadPfad', + 'Staffeln', + 'Init', + 'Progress', + 'Done', + 'Episoden Gesamt', + ]; + var col = 1; + for (final h in headers) { + sheet.getRangeByIndex(1, col).setText(h); + col++; + } + int headerRowSpan = 1; + if (hasEpisodes) { + // Two header rows: season label and episode numbers + int rowSeason = 1; + int rowEpisode = 2; + // Shift data rows by +1 to accommodate extra header row + headerRowSpan = 2; + col = headers.length + 1; + for (final s in seasonList) { + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + sheet.getRangeByIndex(rowSeason, col).setText('Season $s'); + if (maxEp > 1) { + sheet.getRangeByIndex(rowSeason, col, rowSeason, col + maxEp - 1).merge(); + } + for (int ep = 1; ep <= maxEp; ep++) { + sheet.getRangeByIndex(rowEpisode, col + ep - 1).setText('E$ep'); + } + col += maxEp; + } + } + + for (var i = 0; i < rows.length; i++) { + final r = rows[i]; + final row = i + headerRowSpan + 1; + sheet.getRangeByIndex(row, 1).setText(r.title); + if (r.year != null) sheet.getRangeByIndex(row, 2).setNumber(r.year!.toDouble()); + sheet.getRangeByIndex(row, 3).setText(r.seriesStatus ?? ''); + sheet.getRangeByIndex(row, 4).setText(r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein')); + sheet.getRangeByIndex(row, 5).setText(r.resolution ?? ''); + sheet.getRangeByIndex(row, 6).setText(r.downloadPath ?? ''); + sheet.getRangeByIndex(row, 7).setNumber(r.seasons.toDouble()); + sheet.getRangeByIndex(row, 8).setNumber(r.episodesInit.toDouble()); + sheet.getRangeByIndex(row, 9).setNumber(r.episodesProgress.toDouble()); + sheet.getRangeByIndex(row, 10).setNumber(r.episodesDone.toDouble()); + sheet.getRangeByIndex(row, 11).setNumber(r.episodesTotal.toDouble()); + if (hasEpisodes) { + var colOffset = headers.length + 1; + final epsBySeason = r.episodesBySeason ?? const {}; + for (final s in seasonList) { + final eps = epsBySeason[s] ?? const {}; + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + for (int ep = 1; ep <= maxEp; ep++) { + final st = eps[ep]; + sheet.getRangeByIndex(row, colOffset + ep - 1).setText(_statusCode(st)); + } + colOffset += maxEp; + } + } + } + final bytes = wb.saveAsStream(); + wb.dispose(); + final file = File(path); + await file.writeAsBytes(bytes, flush: true); + } + + String _defaultFilename(ItemStatus? filter, bool asXlsx) { + final safeFilter = (filter?.name ?? 'alle').replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + final ts = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final stamp = '${ts.year}${two(ts.month)}${two(ts.day)}_${two(ts.hour)}${two(ts.minute)}${two(ts.second)}'; + return 'series_${safeFilter}_$stamp.${asXlsx ? 'xlsx' : 'csv'}'; + } + + String _toCsv(List> rows) { + final buffer = StringBuffer(); + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + for (var j = 0; j < row.length; j++) { + buffer.write(_escape(row[j])); + if (j < row.length - 1) buffer.write(','); + } + if (i < rows.length - 1) buffer.writeln(); + } + return buffer.toString(); + } + + String _escape(String v) { + final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r'); + final escaped = v.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; + } + + int _maxEpForSeason(List rows, int season) { + var maxEp = 0; + for (final r in rows) { + final eps = r.episodesBySeason?[season]; + if (eps != null && eps.isNotEmpty) { + final m = eps.keys.reduce((a, b) => a > b ? a : b); + if (m > maxEp) maxEp = m; + } + } + return maxEp; + } + + String _statusCode(ItemStatus? st) { + switch (st) { + case ItemStatus.Init: + return 'I'; + case ItemStatus.Progress: + return 'P'; + case ItemStatus.Done: + return 'D'; + default: + return ''; + } + } +} diff --git a/lib/features/series/data/series_exporter_stub.dart b/lib/features/series/data/series_exporter_stub.dart new file mode 100644 index 0000000..6bc786b --- /dev/null +++ b/lib/features/series/data/series_exporter_stub.dart @@ -0,0 +1,17 @@ +import '../../../core/status.dart'; +import 'series_exporter.dart'; + +SeriesExporter getSeriesExporter() => _StubSeriesExporter(); + +class _StubSeriesExporter implements SeriesExporter { + @override + Future chooseSavePath({required bool asXlsx, ItemStatus? filter}) { + return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.')); + } + + @override + Future exportSeries(List rows, + {required bool asXlsx, ItemStatus? filter, String? path}) { + return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.')); + } +} diff --git a/lib/features/series/data/series_exporter_web.dart b/lib/features/series/data/series_exporter_web.dart new file mode 100644 index 0000000..4a3ab5b --- /dev/null +++ b/lib/features/series/data/series_exporter_web.dart @@ -0,0 +1,240 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:html' as html; + +import '../../../core/status.dart'; +import 'series_exporter.dart'; +import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio; + +SeriesExporter getSeriesExporter() => _SeriesExporterWeb(); + +class _SeriesExporterWeb implements SeriesExporter { + @override + Future chooseSavePath({required bool asXlsx, ItemStatus? filter}) async { + // Browser decides; we only provide filename. + return _defaultFilename(filter, asXlsx); + } + + @override + Future exportSeries( + List rows, { + required bool asXlsx, + ItemStatus? filter, + String? path, + }) async { + final filename = path ?? _defaultFilename(filter, asXlsx); + final bytes = asXlsx ? _buildXlsxBytes(rows) : _buildCsvBytes(rows); + _download(bytes, filename); + return filename; + } + + Uint8List _buildCsvBytes(List rows) { + final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList(); + final hasEpisodes = rowsWithEpisodes.isNotEmpty; + final episodeSeasons = {}; + if (hasEpisodes) { + for (final r in rowsWithEpisodes) { + episodeSeasons.addAll(r.episodesBySeason!.keys); + } + } + final seasonList = episodeSeasons.toList()..sort(); + + final csvRows = >[]; + // Header rows + final baseHeader = ['Titel', 'Jahr', 'Serienstatus', 'Cliffhanger', 'Aufloesung', 'DownloadPfad', 'Staffeln', 'Init', 'Progress', 'Done', 'Episoden Gesamt']; + final seasonHeader = []..addAll(baseHeader); + final epHeader = []..addAll(baseHeader); + if (hasEpisodes) { + for (final s in seasonList) { + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + seasonHeader.add('Season $s'); + for (int ep = 1; ep <= maxEp; ep++) { + seasonHeader.add(''); + epHeader.add('E$ep'); + } + } + } + csvRows.add(seasonHeader); + if (hasEpisodes) { + csvRows.add(epHeader); + } + + for (final r in rows) { + final row = [ + r.title, + r.year?.toString() ?? '', + r.seriesStatus ?? '', + r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'), + r.resolution ?? '', + r.downloadPath ?? '', + r.seasons.toString(), + r.episodesInit.toString(), + r.episodesProgress.toString(), + r.episodesDone.toString(), + r.episodesTotal.toString(), + ]; + if (hasEpisodes) { + for (final s in seasonList) { + final eps = r.episodesBySeason?[s] ?? const {}; + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + row.add('S$s'); + for (int ep = 1; ep <= maxEp; ep++) { + row.add(_statusCode(eps[ep])); + } + } + } + csvRows.add(row); + } + final csv = _toCsv(csvRows); + return Uint8List.fromList(utf8.encode(csv)); + } + + Uint8List _buildXlsxBytes(List rows) { + final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList(); + final hasEpisodes = rowsWithEpisodes.isNotEmpty; + final episodeSeasons = {}; + if (hasEpisodes) { + for (final r in rowsWithEpisodes) { + episodeSeasons.addAll(r.episodesBySeason!.keys); + } + } + final seasonList = episodeSeasons.toList()..sort(); + + final wb = xlsio.Workbook(); + final sheet = wb.worksheets[0]; + sheet.name = 'Serien'; + + final headers = [ + 'Titel', + 'Jahr', + 'Serienstatus', + 'Cliffhanger', + 'Aufloesung', + 'DownloadPfad', + 'Staffeln', + 'Init', + 'Progress', + 'Done', + 'Episoden Gesamt', + ]; + var col = 1; + for (final h in headers) { + sheet.getRangeByIndex(1, col).setText(h); + col++; + } + int headerRowSpan = 1; + if (hasEpisodes) { + headerRowSpan = 2; + int rowSeason = 1; + int rowEpisode = 2; + col = headers.length + 1; + for (final s in seasonList) { + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + sheet.getRangeByIndex(rowSeason, col).setText('Season $s'); + if (maxEp > 1) { + sheet.getRangeByIndex(rowSeason, col, rowSeason, col + maxEp - 1).merge(); + } + for (int ep = 1; ep <= maxEp; ep++) { + sheet.getRangeByIndex(rowEpisode, col + ep - 1).setText('E$ep'); + } + col += maxEp; + } + } + + for (var i = 0; i < rows.length; i++) { + final r = rows[i]; + final row = i + headerRowSpan + 1; + sheet.getRangeByIndex(row, 1).setText(r.title); + if (r.year != null) sheet.getRangeByIndex(row, 2).setNumber(r.year!.toDouble()); + sheet.getRangeByIndex(row, 3).setText(r.seriesStatus ?? ''); + sheet.getRangeByIndex(row, 4).setText(r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein')); + sheet.getRangeByIndex(row, 5).setText(r.resolution ?? ''); + sheet.getRangeByIndex(row, 6).setText(r.downloadPath ?? ''); + sheet.getRangeByIndex(row, 7).setNumber(r.seasons.toDouble()); + sheet.getRangeByIndex(row, 8).setNumber(r.episodesInit.toDouble()); + sheet.getRangeByIndex(row, 9).setNumber(r.episodesProgress.toDouble()); + sheet.getRangeByIndex(row, 10).setNumber(r.episodesDone.toDouble()); + sheet.getRangeByIndex(row, 11).setNumber(r.episodesTotal.toDouble()); + if (hasEpisodes) { + var colOffset = headers.length + 1; + final epsBySeason = r.episodesBySeason ?? const {}; + for (final s in seasonList) { + final eps = epsBySeason[s] ?? const {}; + final maxEp = _maxEpForSeason(rowsWithEpisodes, s); + for (int ep = 1; ep <= maxEp; ep++) { + sheet.getRangeByIndex(row, colOffset + ep - 1).setText(_statusCode(eps[ep])); + } + colOffset += maxEp; + } + } + } + + final bytes = wb.saveAsStream(); + wb.dispose(); + return Uint8List.fromList(bytes); + } + + void _download(Uint8List bytes, String filename) { + final blob = html.Blob([bytes]); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..download = filename + ..style.display = 'none'; + html.document.body?.append(anchor); + anchor.click(); + anchor.remove(); + html.Url.revokeObjectUrl(url); + } + + String _defaultFilename(ItemStatus? filter, bool asXlsx) { + final safeFilter = (filter?.name ?? 'alle').replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_'); + final ts = DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final stamp = '${ts.year}${two(ts.month)}${two(ts.day)}_${two(ts.hour)}${two(ts.minute)}${two(ts.second)}'; + return 'series_${safeFilter}_$stamp.${asXlsx ? 'xlsx' : 'csv'}'; + } + + int _maxEpForSeason(List rows, int season) { + var maxEp = 0; + for (final r in rows) { + final eps = r.episodesBySeason?[season]; + if (eps != null && eps.isNotEmpty) { + final m = eps.keys.reduce((a, b) => a > b ? a : b); + if (m > maxEp) maxEp = m; + } + } + return maxEp; + } + + String _statusCode(ItemStatus? st) { + switch (st) { + case ItemStatus.Init: + return 'I'; + case ItemStatus.Progress: + return 'P'; + case ItemStatus.Done: + return 'D'; + default: + return ''; + } + } + + String _toCsv(List> rows) { + final buffer = StringBuffer(); + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + for (var j = 0; j < row.length; j++) { + buffer.write(_escape(row[j])); + if (j < row.length - 1) buffer.write(','); + } + if (i < rows.length - 1) buffer.writeln(); + } + return buffer.toString(); + } + + String _escape(String v) { + final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r'); + final escaped = v.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; + } +} diff --git a/lib/features/series/presentation/series_list_screen.dart b/lib/features/series/presentation/series_list_screen.dart index f7d189c..47eb914 100644 --- a/lib/features/series/presentation/series_list_screen.dart +++ b/lib/features/series/presentation/series_list_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import '../../../core/status.dart'; import '../../movies/presentation/widgets/status_chip.dart'; import '../data/series_repository.dart'; +import '../data/series_exporter.dart'; import 'widgets/episode_status_strip.dart'; import 'series_detail_screen.dart'; import 'series_add_screen.dart'; @@ -18,6 +19,9 @@ import '../../../core/io_open.dart'; import '../../../core/config.dart'; import '../../../core/async_utils.dart'; import 'widgets/overlay_scrollbar.dart'; +import 'widgets/summary_table.dart'; + +enum _SeriesExportFormat { csv, xlsx } Future openDownloadPath(BuildContext context, String path) async { final ok = await openFolder(path); @@ -48,6 +52,7 @@ class _SeriesListScreenState extends ConsumerState { final ScrollController _groupedHController = ScrollController(); final ScrollController _summaryHController = ScrollController(); + @override void dispose() { _groupedVController.dispose(); @@ -80,6 +85,28 @@ class _SeriesListScreenState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ + PopupMenuButton<_SeriesExportFormat>( + onSelected: (fmt) => _exportSeries(context, ref, filter, fmt), + itemBuilder: (ctx) => const [ + PopupMenuItem( + value: _SeriesExportFormat.csv, + child: Text('CSV exportieren'), + ), + PopupMenuItem( + value: _SeriesExportFormat.xlsx, + child: Text('XLSX exportieren'), + ), + ], + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(Icons.download), + SizedBox(width: 6), + Text('Export'), + ], + ), + ), + const widgets.SizedBox(width: 8), TextButton.icon( icon: const Icon(Icons.add), label: const Text('Neue Serien hinzufügen'), @@ -1336,8 +1363,12 @@ class _SeriesListScreenState extends ConsumerState { ); } - Widget buildHeader() { - return Container( + return SummaryTable( + keys: keys, + seasonCols: seasonCols, + seasonWidths: seasonWidths, + buildRow: buildRow, + buildHeader: (seasons, widths) => Container( color: Theme.of(context).colorScheme.surface, padding: const EdgeInsets.only(bottom: 8, top: 4), child: Row( @@ -1347,10 +1378,10 @@ class _SeriesListScreenState extends ConsumerState { width: 360, child: Text('Serie', style: Theme.of(context).textTheme.labelMedium)), - for (final s in seasonCols) ...[ + for (final s in seasons) ...[ const widgets.SizedBox(width: 16), widgets.SizedBox( - width: seasonWidths[s] ?? 74, + width: widths[s] ?? 74, child: Align( alignment: Alignment.centerLeft, child: Text('Season $s', @@ -1363,63 +1394,297 @@ class _SeriesListScreenState extends ConsumerState { ] ], ), + ), + ); + } + + Future _exportSeries( + BuildContext context, + WidgetRef ref, + ItemStatus? filter, + _SeriesExportFormat fmt, + ) async { + final messenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context, rootNavigator: true); + final exporter = createSeriesExporter(); + String? savePath; + try { + savePath = await exporter.chooseSavePath(asXlsx: fmt == _SeriesExportFormat.xlsx, filter: filter); + } on UnsupportedError catch (e) { + messenger.showSnackBar(SnackBar(content: Text(e.message ?? e.toString()))); + return; + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Export fehlgeschlagen: '))); + return; + } + if (savePath == null) { + messenger.showSnackBar(const SnackBar(content: Text('Export abgebrochen'))); + return; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const Center(child: CircularProgressIndicator()), + ); + + try { + final includeEpisodes = await _askIncludeEpisodes(context); + if (includeEpisodes == null) { + if (navigator.canPop()) navigator.pop(); + messenger.showSnackBar(const SnackBar(content: Text('Export abgebrochen'))); + return; + } + final rows = await _collectSeriesRows(ref, filter, includeEpisodes: includeEpisodes); + final path = await exporter.exportSeries( + rows, + asXlsx: fmt == _SeriesExportFormat.xlsx, + filter: filter, + path: savePath, ); + messenger.showSnackBar(SnackBar(content: Text('Export gespeichert: $path'))); + } on UnsupportedError catch (e) { + messenger.showSnackBar(SnackBar(content: Text(e.message ?? e.toString()))); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Export fehlgeschlagen: $e'))); + } finally { + if (navigator.canPop()) navigator.pop(); } + } - const headerHeight = 32.0; - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Scrollbar( - controller: _summaryHController, - child: SingleChildScrollView( - controller: _summaryHController, - scrollDirection: Axis.horizontal, - child: widgets.SizedBox( - width: tableWidth, - height: constraints.maxHeight, - child: Column( - children: [ - widgets.SizedBox(height: headerHeight, child: buildHeader()), - Expanded( - child: Scrollbar( - controller: _summaryVController, - child: ListView.separated( - controller: _summaryVController, - itemCount: keys.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (_, i) => widgets.SizedBox( - height: 88, - child: buildRow(keys[i]), - ), - ), - ), - ), - ], - ), - ), - ), - ), - Positioned( - right: 0, - top: 0, - bottom: 0, - child: SizedBox( - width: 10, - child: AlwaysOnScrollbar( - controller: _summaryVController, - axis: Axis.vertical, - thickness: 10, - ), - ), - ), - ], - ); - }, + Future _askIncludeEpisodes(BuildContext context) async { + bool include = false; + return showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Export-Optionen'), + content: StatefulBuilder( + builder: (ctx, setState) => CheckboxListTile( + value: include, + onChanged: (v) => setState(() => include = v ?? false), + title: const Text('Status je Episode exportieren'), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.of(ctx).pop(null), child: const Text('Abbrechen')), + FilledButton(onPressed: () => Navigator.of(ctx).pop(include), child: const Text('Weiter')), + ], + ), ); } + Future> _collectSeriesRows(WidgetRef ref, ItemStatus? filter, + {required bool includeEpisodes}) async { + try { + final summary = await ref.read(seriesSummaryProvider.future); + if (summary.isNotEmpty) { + return _rowsFromSummary(summary, filter, includeEpisodes: includeEpisodes); + } + } catch (_) {} + final grouped = await ref.read(seriesGroupedProvider.future); + return _rowsFromGrouped(grouped.data, filter, includeEpisodes: includeEpisodes); + } + + List _rowsFromSummary(List> items, ItemStatus? filter, + {required bool includeEpisodes}) { + bool includeByFilter(Map m) { + var initTotal = 0; + var progTotal = 0; + var doneTotal = 0; + if (m['seasons'] is List) { + for (final s in (m['seasons'] as List).cast>()) { + initTotal += (s['init'] as num?)?.toInt() ?? 0; + progTotal += (s['progress'] as num?)?.toInt() ?? 0; + doneTotal += (s['done'] as num?)?.toInt() ?? 0; + } + } else if (m['season_status'] is String) { + final str = m['season_status'] as String; + for (final part in str.split('|')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + if (seg.length < 2) continue; + final counts = seg[1].split(','); + initTotal += counts.isNotEmpty ? int.tryParse(counts[0]) ?? 0 : 0; + progTotal += counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0; + doneTotal += counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0; + } + } else if (m['seasons_eps'] is String) { + final se = (m['seasons_eps'] as String); + for (final part in se.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + if (seg.length < 2) continue; + final list = seg[1]; + for (final eSeg in list.split(',')) { + if (eSeg.isEmpty) continue; + final kv = eSeg.split('|'); + final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + if (stCode == 2) { + doneTotal++; + } else if (stCode == 1) { + progTotal++; + } else { + initTotal++; + } + } + } + } + final anyInit = initTotal > 0; + final anyProgress = progTotal > 0; + final anyDone = doneTotal > 0; + if (filter == null) return true; + switch (filter) { + case ItemStatus.Progress: + return anyProgress; + case ItemStatus.Init: + return anyInit && !anyProgress; + case ItemStatus.Done: + return !anyInit && !anyProgress && anyDone; + } + } + + final rows = []; + for (final m in items) { + if (!includeByFilter(m)) continue; + final name = (m['name'] as String?) ?? ''; + final resolution = m['resolution'] as String?; + final downloadPath = m['download_path'] as String?; + final jsonStr = m['json'] as String?; + int? year; + String? status; + bool? cliff; + if (jsonStr != null && jsonStr.isNotEmpty) { + try { + final js = jsonDecode(jsonStr) as Map; + final s = (js['first_air_date'] as String?) ?? ''; + if (s.length >= 4) year = int.tryParse(s.substring(0, 4)); + status = js['status'] as String?; + if (js['cliffhanger'] is bool) { + cliff = js['cliffhanger'] as bool; + } + } catch (_) {} + } + int init = 0, prog = 0, done = 0, seasons = 0; + if (m['seasons'] is List) { + final list = (m['seasons'] as List).cast>(); + seasons = list.length; + for (final s in list) { + init += (s['init'] as num?)?.toInt() ?? 0; + prog += (s['progress'] as num?)?.toInt() ?? 0; + done += (s['done'] as num?)?.toInt() ?? 0; + } + } else if (m['season_status'] is String) { + final str = m['season_status'] as String; + for (final part in str.split('|')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + final sn = int.tryParse(seg.first); + if (sn != null) seasons = seasons < sn ? sn : seasons; + if (seg.length < 2) continue; + final counts = seg[1].split(','); + init += counts.isNotEmpty ? int.tryParse(counts[0]) ?? 0 : 0; + prog += counts.length > 1 ? int.tryParse(counts[1]) ?? 0 : 0; + done += counts.length > 2 ? int.tryParse(counts[2]) ?? 0 : 0; + } + } else if (m['seasons_eps'] is String) { + final se = (m['seasons_eps'] as String); + for (final part in se.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + final sn = int.tryParse(seg.first); + if (sn != null) seasons = seasons < sn ? sn : seasons; + if (seg.length < 2) continue; + final list = seg[1]; + for (final eSeg in list.split(',')) { + if (eSeg.isEmpty) continue; + final kv = eSeg.split('|'); + final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + if (stCode == 2) { + done++; + } else if (stCode == 1) { + prog++; + } else { + init++; + } + } + } + } + + rows.add(SeriesExportRow( + title: name, + year: year, + seriesStatus: status, + cliffhanger: cliff, + resolution: resolution, + downloadPath: downloadPath, + seasons: seasons, + episodesInit: init, + episodesProgress: prog, + episodesDone: done, + episodesBySeason: includeEpisodes ? _episodesMapFromSummary(m) : null, + )); + } + rows.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + return rows; + } + + List _rowsFromGrouped( + Map>> data, ItemStatus? filter, + {required bool includeEpisodes}) { + final rows = []; + for (final entry in data.entries) { + final bySeason = entry.value; + bool anyProgress = false, anyInit = false; + for (final eps in bySeason.values) { + for (final e in eps) { + if (e.status == ItemStatus.Progress) anyProgress = true; + if (e.status == ItemStatus.Init) anyInit = true; + } + } + final include = () { + if (filter == null) return true; + switch (filter) { + case ItemStatus.Progress: + return anyProgress; + case ItemStatus.Init: + return anyInit && !anyProgress; + case ItemStatus.Done: + return !anyInit && !anyProgress; + } + }(); + if (!include) continue; + rows.add(SeriesExportRow.fromGroup(bySeason, includeEpisodeStatus: includeEpisodes)); + } + rows.sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase())); + return rows; + } + + Map> _episodesMapFromSummary(Map m) { + final map = >{}; + if (m['seasons_eps'] is String) { + final se = (m['seasons_eps'] as String); + for (final part in se.split(';')) { + if (part.isEmpty) continue; + final seg = part.split(':'); + if (seg.length < 2) continue; + final sn = int.tryParse(seg.first) ?? 0; + final list = seg[1]; + for (final eSeg in list.split(',')) { + if (eSeg.isEmpty) continue; + final kv = eSeg.split('|'); + final epNo = kv.isNotEmpty ? int.tryParse(kv[0]) ?? 0 : 0; + final stCode = (kv.length > 1 ? int.tryParse(kv[1]) : 0) ?? 0; + final st = stCode == 2 + ? ItemStatus.Done + : stCode == 1 + ? ItemStatus.Progress + : ItemStatus.Init; + map.putIfAbsent(sn, () => {})[epNo] = st; + } + } + } + return map; + } Widget _resolutionInline(String? res, BuildContext context) { if (res == null || res.isEmpty) return const SizedBox.shrink(); final m = RegExp(r"\d+").firstMatch(res); diff --git a/lib/features/series/presentation/widgets/summary_table.dart b/lib/features/series/presentation/widgets/summary_table.dart new file mode 100644 index 0000000..05c94db --- /dev/null +++ b/lib/features/series/presentation/widgets/summary_table.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart' as widgets; +import 'overlay_scrollbar.dart'; + +class SummaryTable extends StatelessWidget { + final List keys; + final List seasonCols; + final Map seasonWidths; + final Widget Function(List seasons, Map widths) buildHeader; + final Widget Function(String key) buildRow; + + const SummaryTable({ + required this.keys, + required this.seasonCols, + required this.seasonWidths, + required this.buildHeader, + required this.buildRow, + }); + + @override + Widget build(BuildContext context) { + const headerHeight = 32.0; + final tableWidth = _calcTableWidth(); + final summaryHController = ScrollController(); + final summaryVController = ScrollController(); + + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + Scrollbar( + controller: summaryHController, + child: SingleChildScrollView( + controller: summaryHController, + scrollDirection: Axis.horizontal, + child: widgets.SizedBox( + width: tableWidth, + height: constraints.maxHeight, + child: Column( + children: [ + widgets.SizedBox(height: headerHeight, child: buildHeader(seasonCols, seasonWidths)), + Expanded( + child: Scrollbar( + controller: summaryVController, + child: ListView.separated( + controller: summaryVController, + itemCount: keys.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => widgets.SizedBox( + height: 88, + child: buildRow(keys[i]), + ), + ), + ), + ), + ], + ), + ), + ), + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: SizedBox( + width: 10, + child: AlwaysOnScrollbar( + controller: summaryVController, + axis: Axis.vertical, + thickness: 10, + ), + ), + ), + ], + ); + }, + ); + } + + double _calcTableWidth() { + double tableWidth = 360; + for (final s in seasonCols) { + tableWidth += 16 + (seasonWidths[s] ?? 74); + } + return tableWidth; + } +}