Export Series
This commit is contained in:
@@ -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<void> openDownloadPath(BuildContext context, String path) async {
|
||||
final ok = await openFolder(path);
|
||||
@@ -48,6 +52,7 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
||||
final ScrollController _groupedHController = ScrollController();
|
||||
final ScrollController _summaryHController = ScrollController();
|
||||
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_groupedVController.dispose();
|
||||
@@ -80,6 +85,28 @@ class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
|
||||
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<SeriesListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<SeriesListScreen> {
|
||||
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<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) {
|
||||
if (res == null || res.isEmpty) return const SizedBox.shrink();
|
||||
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