diff --git a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 2fdf19e..2895280 100644 --- a/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,16 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; 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 { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/lib/features/movies/data/movie_exporter.dart b/lib/features/movies/data/movie_exporter.dart new file mode 100644 index 0000000..c406d58 --- /dev/null +++ b/lib/features/movies/data/movie_exporter.dart @@ -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 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 exportMovies( + List movies, { + required bool asXlsx, + ItemStatus? filter, + String? path, + }); +} diff --git a/lib/features/movies/data/movie_exporter_io.dart b/lib/features/movies/data/movie_exporter_io.dart new file mode 100644 index 0000000..61394e4 --- /dev/null +++ b/lib/features/movies/data/movie_exporter_io.dart @@ -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 exportMovies( + List 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 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 _writeCsv(String path, List movies) async { + final rows = >[ + ['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 _writeXlsx(String path, List 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> rows) { + final buffer = StringBuffer(); + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + for (var j = 0; j < row.length; j++) { + buffer.write(_escape(row[j])); + if (j < row.length - 1) buffer.write(','); + } + if (i < rows.length - 1) buffer.writeln(); + } + return buffer.toString(); + } + + String _escape(String v) { + final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r'); + final escaped = v.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; + } +} diff --git a/lib/features/movies/data/movie_exporter_stub.dart b/lib/features/movies/data/movie_exporter_stub.dart new file mode 100644 index 0000000..8bda3ad --- /dev/null +++ b/lib/features/movies/data/movie_exporter_stub.dart @@ -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 chooseSavePath({required bool asXlsx, ItemStatus? filter}) { + return Future.error( + UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'), + ); + } + + @override + Future exportMovies( + List movies, { + required bool asXlsx, + ItemStatus? filter, + String? path, + }) { + return Future.error( + UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'), + ); + } +} diff --git a/lib/features/movies/data/movie_exporter_web.dart b/lib/features/movies/data/movie_exporter_web.dart new file mode 100644 index 0000000..c78c125 --- /dev/null +++ b/lib/features/movies/data/movie_exporter_web.dart @@ -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 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 exportMovies( + List 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 movies) { + final rows = >[ + ['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 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> rows) { + final buffer = StringBuffer(); + for (var i = 0; i < rows.length; i++) { + final row = rows[i]; + for (var j = 0; j < row.length; j++) { + buffer.write(_escape(row[j])); + if (j < row.length - 1) buffer.write(','); + } + if (i < rows.length - 1) buffer.writeln(); + } + return buffer.toString(); + } + + String _escape(String v) { + final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r'); + final escaped = v.replaceAll('"', '""'); + return needsQuotes ? '"$escaped"' : escaped; + } +} diff --git a/lib/features/movies/presentation/movie_list_screen.dart b/lib/features/movies/presentation/movie_list_screen.dart index 7b5b45d..b3b2069 100644 --- a/lib/features/movies/presentation/movie_list_screen.dart +++ b/lib/features/movies/presentation/movie_list_screen.dart @@ -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 _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) { diff --git a/pubspec.lock b/pubspec.lock index f7dd4cb..bb19704 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" async: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -113,6 +129,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -142,6 +166,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -176,6 +208,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d + url: "https://pub.dev" + source: hosted + version: "4.3.0" intl: dependency: "direct main" description: @@ -184,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + jiffy: + dependency: transitive + description: + name: jiffy + sha256: "1c1b86459969ff9f32dc5b0ffe392f1e08181e66396cf9dd8fa7c90552a691af" + url: "https://pub.dev" + source: hosted + version: "6.3.2" leak_tracker: dependency: transitive description: @@ -265,7 +313,7 @@ packages: source: hosted version: "1.9.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -312,6 +360,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -437,6 +493,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -565,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://pub.dev" + source: hosted + version: "5.10.1" xdg_directories: dependency: transitive description: @@ -573,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.5.0 <4.0.0" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index f449d6a..7a61d41 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,9 @@ dependencies: cached_network_image: ^3.3.1 intl: ^0.19.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