Export Movies

main
Herwig Birke 4 weeks ago
parent 2628ae138a
commit 4777fe49f9

@ -15,6 +15,16 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant { public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant"; private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) { public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try { try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) { } catch (Exception e) {

@ -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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart'; import '../../../core/status.dart';
import '../data/movie_repository.dart'; import '../data/movie_repository.dart';
import '../data/movie_exporter.dart';
import 'widgets/status_chip.dart'; import 'widgets/status_chip.dart';
import 'movie_detail_screen.dart'; import 'movie_detail_screen.dart';
import '../../shared/providers.dart'; import '../../shared/providers.dart';
import 'movie_add_screen.dart'; import 'movie_add_screen.dart';
enum _ExportFormat { csv, xlsx }
class MovieListScreen extends ConsumerWidget { class MovieListScreen extends ConsumerWidget {
const MovieListScreen({super.key}); const MovieListScreen({super.key});
@ -31,6 +34,28 @@ class MovieListScreen extends ConsumerWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( TextButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Neue Filme hinzufügen'), 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) { Color? _statusBg(ItemStatus s, BuildContext context) {
switch (s) { switch (s) {

@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
async: async:
dependency: transitive dependency: transitive
description: description:
@ -65,6 +73,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -113,6 +129,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.1" version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -142,6 +166,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "1c2b787f99bdca1f3718543f81d38aa1b124817dfeb9fb196201bea85b6134bf"
url: "https://pub.dev"
source: hosted
version: "2.0.26"
flutter_riverpod: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@ -176,6 +208,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.2" version: "4.0.2"
image:
dependency: transitive
description:
name: image
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.3.0"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@ -184,6 +224,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.19.0" version: "0.19.0"
jiffy:
dependency: transitive
description:
name: jiffy
sha256: "1c1b86459969ff9f32dc5b0ffe392f1e08181e66396cf9dd8fa7c90552a691af"
url: "https://pub.dev"
source: hosted
version: "6.3.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@ -265,7 +313,7 @@ packages:
source: hosted source: hosted
version: "1.9.0" version: "1.9.0"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@ -312,6 +360,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27
url: "https://pub.dev"
source: hosted
version: "6.0.2"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -437,6 +493,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.2.0"
syncfusion_flutter_core:
dependency: transitive
description:
name: syncfusion_flutter_core
sha256: "6e67726b85812afc7105725a23620b876ab7f6b04b8410e211330ffb8c2cdbe8"
url: "https://pub.dev"
source: hosted
version: "26.2.14"
syncfusion_flutter_xlsio:
dependency: "direct main"
description:
name: syncfusion_flutter_xlsio
sha256: "6250427a781642e22d250b87689d9a09acfd864f0bfbd95642e3e0c52b4e3b46"
url: "https://pub.dev"
source: hosted
version: "26.2.14"
syncfusion_officecore:
dependency: transitive
description:
name: syncfusion_officecore
sha256: "9ec8c9d9f66bb20440ff7e31c83fed52fb9d55bc4ba32b6cbaea70fa82ca9b41"
url: "https://pub.dev"
source: hosted
version: "26.2.14"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
@ -565,6 +645,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e
url: "https://pub.dev"
source: hosted
version: "5.10.1"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@ -573,6 +661,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
sdks: sdks:
dart: ">=3.5.0 <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.24.0" flutter: ">=3.24.0"

@ -14,6 +14,9 @@ dependencies:
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
intl: ^0.19.0 intl: ^0.19.0
url_launcher: ^6.3.0 url_launcher: ^6.3.0
path_provider: ^2.1.4
file_picker: ^8.1.2
syncfusion_flutter_xlsio: ^26.2.7
# Material 3 ist im Flutter SDK enthalten # Material 3 ist im Flutter SDK enthalten

Loading…
Cancel
Save