Export Movies

This commit is contained in:
2025-11-24 22:26:47 +01:00
parent 2628ae138a
commit 4777fe49f9
8 changed files with 470 additions and 1 deletions
@@ -0,0 +1,22 @@
import '../../../core/status.dart';
import 'movie_model.dart';
import 'movie_exporter_stub.dart'
if (dart.library.html) 'movie_exporter_web.dart'
if (dart.library.io) 'movie_exporter_io.dart';
/// Factory to obtain the platform-specific exporter implementation.
MovieExporter createMovieExporter() => getMovieExporter();
abstract class MovieExporter {
/// Opens a save dialog and returns the chosen path, or null if cancelled.
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter});
/// Exports movies to disk and returns the written file path.
/// Returns `null` if the user cancels the dialog.
Future<String?> exportMovies(
List<Movie> movies, {
required bool asXlsx,
ItemStatus? filter,
String? path,
});
}
@@ -0,0 +1,127 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio;
import '../../../core/status.dart';
import 'movie_model.dart';
import 'movie_exporter.dart';
MovieExporter getMovieExporter() => _IoMovieExporter();
class _IoMovieExporter implements MovieExporter {
@override
Future<String?> exportMovies(
List<Movie> movies, {
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, movies);
} else {
await _writeCsv(savePath, movies);
}
return savePath;
}
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) async {
final dir = await getApplicationDocumentsDirectory();
await dir.create(recursive: true);
final safeFilter = _sanitize(filter?.name ?? 'alle');
final ts = DateTime.now();
final name = 'movies_${safeFilter}_${_ts(ts)}.${asXlsx ? 'xlsx' : 'csv'}';
String? savePath;
try {
savePath = await FilePicker.platform.saveFile(
dialogTitle: 'Export speichern',
fileName: name,
type: FileType.custom,
allowedExtensions: [asXlsx ? 'xlsx' : 'csv'],
initialDirectory: dir.path,
);
} on PlatformException catch (e) {
// propagate to trigger UI fallback
throw Exception('Speicherdialog fehlgeschlagen: ${e.code}');
}
if (savePath == null) return null; // user cancelled
return savePath;
}
Future<void> _writeCsv(String path, List<Movie> movies) async {
final rows = <List<String>>[
['Titel', 'Jahr', 'Status', 'Aufloesung', 'TMDB-ID', 'Beschreibung'],
for (final m in movies)
[
m.title,
m.releaseYear?.toString() ?? '',
m.status.name,
m.resolution ?? '',
m.tmdbId.toString(),
m.overview ?? '',
],
];
final content = _toCsv(rows);
final file = File(path);
await file.writeAsString(content, flush: true);
}
Future<void> _writeXlsx(String path, List<Movie> movies) async {
final wb = xlsio.Workbook();
final sheet = wb.worksheets[0];
sheet.name = 'Filme';
// Header
final headers = ['Titel', 'Jahr', 'Status', 'Aufloesung', 'TMDB-ID', 'Beschreibung'];
for (var c = 0; c < headers.length; c++) {
sheet.getRangeByIndex(1, c + 1).setText(headers[c]);
}
// Data
for (var i = 0; i < movies.length; i++) {
final m = movies[i];
final row = i + 2;
sheet.getRangeByIndex(row, 1).setText(m.title);
sheet.getRangeByIndex(row, 2).setNumber((m.releaseYear ?? 0).toDouble());
sheet.getRangeByIndex(row, 3).setText(m.status.name);
sheet.getRangeByIndex(row, 4).setText(m.resolution ?? '');
sheet.getRangeByIndex(row, 5).setNumber(m.tmdbId.toDouble());
sheet.getRangeByIndex(row, 6).setText(m.overview ?? '');
}
final bytes = wb.saveAsStream();
wb.dispose();
final file = File(path);
await file.writeAsBytes(bytes, flush: true);
}
String _ts(DateTime dt) {
final two = (int v) => v.toString().padLeft(2, '0');
return '${dt.year}${two(dt.month)}${two(dt.day)}_${two(dt.hour)}${two(dt.minute)}${two(dt.second)}';
}
String _sanitize(String input) => input.replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
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;
}
}
@@ -0,0 +1,26 @@
import '../../../core/status.dart';
import 'movie_model.dart';
import 'movie_exporter.dart';
MovieExporter getMovieExporter() => _WebMovieExporter();
class _WebMovieExporter implements MovieExporter {
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) {
return Future.error(
UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'),
);
}
@override
Future<String?> exportMovies(
List<Movie> movies, {
required bool asXlsx,
ItemStatus? filter,
String? path,
}) {
return Future.error(
UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'),
);
}
}
@@ -0,0 +1,111 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:html' as html;
import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio;
import '../../../core/status.dart';
import 'movie_exporter.dart';
import 'movie_model.dart';
MovieExporter getMovieExporter() => _WebMovieExporter();
class _WebMovieExporter implements MovieExporter {
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) async {
// On web, saveFile shows the browser download dialog when bytes are provided.
// We return only the filename here; actual save happens in exportMovies.
return _defaultFilename(filter, asXlsx);
}
@override
Future<String?> exportMovies(
List<Movie> movies, {
required bool asXlsx,
ItemStatus? filter,
String? path,
}) async {
final filename = path ?? _defaultFilename(filter, asXlsx);
final bytes = asXlsx ? _buildXlsxBytes(movies) : _buildCsvBytes(movies);
_downloadBytes(bytes, filename);
return filename;
}
Uint8List _buildCsvBytes(List<Movie> movies) {
final rows = <List<String>>[
['Titel', 'Jahr', 'Status', 'Aufloesung', 'TMDB-ID', 'Beschreibung'],
for (final m in movies)
[
m.title,
m.releaseYear?.toString() ?? '',
m.status.name,
m.resolution ?? '',
m.tmdbId.toString(),
m.overview ?? '',
],
];
final csv = _toCsv(rows);
return Uint8List.fromList(utf8.encode(csv));
}
Uint8List _buildXlsxBytes(List<Movie> movies) {
final wb = xlsio.Workbook();
final sheet = wb.worksheets[0];
sheet.name = 'Filme';
final headers = ['Titel', 'Jahr', 'Status', 'Aufloesung', 'TMDB-ID', 'Beschreibung'];
for (var c = 0; c < headers.length; c++) {
sheet.getRangeByIndex(1, c + 1).setText(headers[c]);
}
for (var i = 0; i < movies.length; i++) {
final m = movies[i];
final row = i + 2;
sheet.getRangeByIndex(row, 1).setText(m.title);
sheet.getRangeByIndex(row, 2).setNumber((m.releaseYear ?? 0).toDouble());
sheet.getRangeByIndex(row, 3).setText(m.status.name);
sheet.getRangeByIndex(row, 4).setText(m.resolution ?? '');
sheet.getRangeByIndex(row, 5).setNumber(m.tmdbId.toDouble());
sheet.getRangeByIndex(row, 6).setText(m.overview ?? '');
}
final bytes = wb.saveAsStream();
wb.dispose();
return Uint8List.fromList(bytes);
}
void _downloadBytes(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 'movies_${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;
}
}
@@ -3,11 +3,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../data/movie_repository.dart';
import '../data/movie_exporter.dart';
import 'widgets/status_chip.dart';
import 'movie_detail_screen.dart';
import '../../shared/providers.dart';
import 'movie_add_screen.dart';
enum _ExportFormat { csv, xlsx }
class MovieListScreen extends ConsumerWidget {
const MovieListScreen({super.key});
@@ -31,6 +34,28 @@ class MovieListScreen extends ConsumerWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<_ExportFormat>(
onSelected: (fmt) => _exportMovies(context, ref, filter, fmt),
itemBuilder: (ctx) => const [
PopupMenuItem(
value: _ExportFormat.csv,
child: Text('CSV exportieren'),
),
PopupMenuItem(
value: _ExportFormat.xlsx,
child: Text('XLSX exportieren'),
),
],
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.download),
SizedBox(width: 6),
Text('Export'),
],
),
),
const SizedBox(width: 8),
TextButton.icon(
icon: const Icon(Icons.add),
label: const Text('Neue Filme hinzufügen'),
@@ -269,6 +294,55 @@ class MovieListScreen extends ConsumerWidget {
);
}
Future<void> _exportMovies(
BuildContext context,
WidgetRef ref,
ItemStatus? filter,
_ExportFormat fmt,
) async {
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context, rootNavigator: true);
final exporter = createMovieExporter();
// Save-Ziel vor dem Export auswählen
String? savePath;
try {
savePath = await exporter.chooseSavePath(asXlsx: fmt == _ExportFormat.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: $e')));
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 movies = await ref.read(moviesProvider.future);
final path = await exporter.exportMovies(
movies,
asXlsx: fmt == _ExportFormat.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();
}
}
Color? _statusBg(ItemStatus s, BuildContext context) {
switch (s) {