Export Series
This commit is contained in:
@@ -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<int, Map<int, ItemStatus>>? 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<int, List<EpisodeItem>> 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 = <int, Map<int, ItemStatus>>{};
|
||||||
|
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<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter});
|
||||||
|
|
||||||
|
Future<String?> exportSeries(
|
||||||
|
List<SeriesExportRow> rows, {
|
||||||
|
required bool asXlsx,
|
||||||
|
ItemStatus? filter,
|
||||||
|
String? path,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<String?> 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<String?> exportSeries(
|
||||||
|
List<SeriesExportRow> 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<void> _writeCsv(String path, List<SeriesExportRow> rows) async {
|
||||||
|
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
|
||||||
|
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
|
||||||
|
final episodeSeasons = <int>{};
|
||||||
|
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 = <List<String>>[];
|
||||||
|
// First header row: season labels over episode columns
|
||||||
|
final seasonHeader = <String>[];
|
||||||
|
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 = <String>[];
|
||||||
|
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<void> _writeXlsx(String path, List<SeriesExportRow> 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 = <int>{};
|
||||||
|
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<List<String>> 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<SeriesExportRow> 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../../../core/status.dart';
|
||||||
|
import 'series_exporter.dart';
|
||||||
|
|
||||||
|
SeriesExporter getSeriesExporter() => _StubSeriesExporter();
|
||||||
|
|
||||||
|
class _StubSeriesExporter implements SeriesExporter {
|
||||||
|
@override
|
||||||
|
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) {
|
||||||
|
return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> exportSeries(List<SeriesExportRow> rows,
|
||||||
|
{required bool asXlsx, ItemStatus? filter, String? path}) {
|
||||||
|
return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) async {
|
||||||
|
// Browser decides; we only provide filename.
|
||||||
|
return _defaultFilename(filter, asXlsx);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> exportSeries(
|
||||||
|
List<SeriesExportRow> 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<SeriesExportRow> rows) {
|
||||||
|
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
|
||||||
|
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
|
||||||
|
final episodeSeasons = <int>{};
|
||||||
|
if (hasEpisodes) {
|
||||||
|
for (final r in rowsWithEpisodes) {
|
||||||
|
episodeSeasons.addAll(r.episodesBySeason!.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final seasonList = episodeSeasons.toList()..sort();
|
||||||
|
|
||||||
|
final csvRows = <List<String>>[];
|
||||||
|
// Header rows
|
||||||
|
final baseHeader = ['Titel', 'Jahr', 'Serienstatus', 'Cliffhanger', 'Aufloesung', 'DownloadPfad', 'Staffeln', 'Init', 'Progress', 'Done', 'Episoden Gesamt'];
|
||||||
|
final seasonHeader = <String>[]..addAll(baseHeader);
|
||||||
|
final epHeader = <String>[]..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<SeriesExportRow> rows) {
|
||||||
|
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
|
||||||
|
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
|
||||||
|
final episodeSeasons = <int>{};
|
||||||
|
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<SeriesExportRow> 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<List<String>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import '../../../core/status.dart';
|
import '../../../core/status.dart';
|
||||||
import '../../movies/presentation/widgets/status_chip.dart';
|
import '../../movies/presentation/widgets/status_chip.dart';
|
||||||
import '../data/series_repository.dart';
|
import '../data/series_repository.dart';
|
||||||
|
import '../data/series_exporter.dart';
|
||||||
import 'widgets/episode_status_strip.dart';
|
import 'widgets/episode_status_strip.dart';
|
||||||
import 'series_detail_screen.dart';
|
import 'series_detail_screen.dart';
|
||||||
import 'series_add_screen.dart';
|
import 'series_add_screen.dart';
|
||||||
@@ -18,6 +19,9 @@ import '../../../core/io_open.dart';
|
|||||||
import '../../../core/config.dart';
|
import '../../../core/config.dart';
|
||||||
import '../../../core/async_utils.dart';
|
import '../../../core/async_utils.dart';
|
||||||
import 'widgets/overlay_scrollbar.dart';
|
import 'widgets/overlay_scrollbar.dart';
|
||||||
|
import 'widgets/summary_table.dart';
|
||||||
|
|
||||||
|
enum _SeriesExportFormat { csv, xlsx }
|
||||||
|
|
||||||
Future<void> openDownloadPath(BuildContext context, String path) async {
|
Future<void> openDownloadPath(BuildContext context, String path) async {
|
||||||
final ok = await openFolder(path);
|
final ok = await openFolder(path);
|
||||||
@@ -48,6 +52,7 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
|||||||
final ScrollController _groupedHController = ScrollController();
|
final ScrollController _groupedHController = ScrollController();
|
||||||
final ScrollController _summaryHController = ScrollController();
|
final ScrollController _summaryHController = ScrollController();
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_groupedVController.dispose();
|
_groupedVController.dispose();
|
||||||
@@ -80,6 +85,28 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
TextButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Neue Serien hinzufügen'),
|
label: const Text('Neue Serien hinzufügen'),
|
||||||
@@ -1336,8 +1363,12 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildHeader() {
|
return SummaryTable(
|
||||||
return Container(
|
keys: keys,
|
||||||
|
seasonCols: seasonCols,
|
||||||
|
seasonWidths: seasonWidths,
|
||||||
|
buildRow: buildRow,
|
||||||
|
buildHeader: (seasons, widths) => Container(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
padding: const EdgeInsets.only(bottom: 8, top: 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -1347,10 +1378,10 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
|||||||
width: 360,
|
width: 360,
|
||||||
child: Text('Serie',
|
child: Text('Serie',
|
||||||
style: Theme.of(context).textTheme.labelMedium)),
|
style: Theme.of(context).textTheme.labelMedium)),
|
||||||
for (final s in seasonCols) ...[
|
for (final s in seasons) ...[
|
||||||
const widgets.SizedBox(width: 16),
|
const widgets.SizedBox(width: 16),
|
||||||
widgets.SizedBox(
|
widgets.SizedBox(
|
||||||
width: seasonWidths[s] ?? 74,
|
width: widths[s] ?? 74,
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Text('Season $s',
|
child: Text('Season $s',
|
||||||
@@ -1363,63 +1394,297 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
|||||||
]
|
]
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
|
||||||
|
|
||||||
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<void> _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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool?> _askIncludeEpisodes(BuildContext context) async {
|
||||||
|
bool include = false;
|
||||||
|
return showDialog<bool>(
|
||||||
|
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<List<SeriesExportRow>> _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<SeriesExportRow> _rowsFromSummary(List<Map<String, dynamic>> items, ItemStatus? filter,
|
||||||
|
{required bool includeEpisodes}) {
|
||||||
|
bool includeByFilter(Map<String, dynamic> m) {
|
||||||
|
var initTotal = 0;
|
||||||
|
var progTotal = 0;
|
||||||
|
var doneTotal = 0;
|
||||||
|
if (m['seasons'] is List) {
|
||||||
|
for (final s in (m['seasons'] as List).cast<Map<String, dynamic>>()) {
|
||||||
|
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 = <SeriesExportRow>[];
|
||||||
|
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<String, dynamic>;
|
||||||
|
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<Map<String, dynamic>>();
|
||||||
|
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<SeriesExportRow> _rowsFromGrouped(
|
||||||
|
Map<String, Map<int, List<EpisodeItem>>> data, ItemStatus? filter,
|
||||||
|
{required bool includeEpisodes}) {
|
||||||
|
final rows = <SeriesExportRow>[];
|
||||||
|
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<int, Map<int, ItemStatus>> _episodesMapFromSummary(Map<String, dynamic> m) {
|
||||||
|
final map = <int, Map<int, ItemStatus>>{};
|
||||||
|
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) {
|
Widget _resolutionInline(String? res, BuildContext context) {
|
||||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
||||||
final m = RegExp(r"\d+").firstMatch(res);
|
final m = RegExp(r"\d+").firstMatch(res);
|
||||||
|
|||||||
@@ -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<String> keys;
|
||||||
|
final List<int> seasonCols;
|
||||||
|
final Map<int, double> seasonWidths;
|
||||||
|
final Widget Function(List<int> seasons, Map<int, double> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user