From 3f38efccf3c11032c5a4e40aa45286c9275d5c9a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Wed, 26 Apr 2023 01:02:39 +0800 Subject: [PATCH] Export collection with different provider --- app/lib/entity/collection/exporter.dart | 108 +++++++++ app/lib/entity/collection/exporter.g.dart | 14 ++ app/lib/widget/collection_browser.dart | 1 + app/lib/widget/collection_browser.g.dart | 7 + .../widget/collection_browser/app_bar.dart | 26 +- .../collection_browser/state_event.dart | 8 + app/lib/widget/export_collection_dialog.dart | 225 ++++++++++++++++++ .../widget/export_collection_dialog.g.dart | 113 +++++++++ .../widget/export_collection_dialog/bloc.dart | 63 +++++ .../export_collection_dialog/state_event.dart | 71 ++++++ 10 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 app/lib/entity/collection/exporter.dart create mode 100644 app/lib/entity/collection/exporter.g.dart create mode 100644 app/lib/widget/export_collection_dialog.dart create mode 100644 app/lib/widget/export_collection_dialog.g.dart create mode 100644 app/lib/widget/export_collection_dialog/bloc.dart create mode 100644 app/lib/widget/export_collection_dialog/state_event.dart diff --git a/app/lib/entity/collection/exporter.dart b/app/lib/entity/collection/exporter.dart new file mode 100644 index 00000000..9db4a003 --- /dev/null +++ b/app/lib/entity/collection/exporter.dart @@ -0,0 +1,108 @@ +import 'package:clock/clock.dart'; +import 'package:collection/collection.dart'; +import 'package:kiwi/kiwi.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/di_container.dart'; +import 'package:nc_photos/entity/album.dart'; +import 'package:nc_photos/entity/album/cover_provider.dart'; +import 'package:nc_photos/entity/album/item.dart'; +import 'package:nc_photos/entity/album/provider.dart'; +import 'package:nc_photos/entity/album/sort_provider.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/content_provider/album.dart'; +import 'package:nc_photos/entity/collection/content_provider/nc_album.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/entity/file_descriptor.dart'; +import 'package:nc_photos/entity/nc_album.dart'; +import 'package:nc_photos/use_case/find_file.dart'; +import 'package:np_codegen/np_codegen.dart'; + +part 'exporter.g.dart'; + +@npLog +class CollectionExporter { + const CollectionExporter(this.account, this.collectionsController, + this.collection, this.items, this.exportName); + + /// Export as a new collection backed by our client side album + Future asAlbum() async { + final files = await FindFile(KiwiContainer().resolve())( + account, + items.whereType().map((e) => e.file.fdId).toList(), + onFileNotFound: (fileId) { + _log.severe("[asAlbum] File not found: $fileId"); + }, + ); + final newAlbum = Album( + name: exportName, + provider: AlbumStaticProvider( + items: items + .map((e) { + if (e is CollectionFileItem) { + final f = files + .firstWhereOrNull((f) => f.compareServerIdentity(e.file)); + if (f == null) { + return null; + } else { + return AlbumFileItem( + addedBy: account.userId, + addedAt: clock.now().toUtc(), + file: f, + ); + } + } else if (e is CollectionLabelItem) { + return AlbumLabelItem( + addedBy: account.userId, + addedAt: clock.now().toUtc(), + text: e.text, + ); + } else { + return null; + } + }) + .whereNotNull() + .toList(), + latestItemTime: collection.lastModified, + ), + coverProvider: const AlbumAutoCoverProvider(), + sortProvider: const AlbumTimeSortProvider(isAscending: false), + ); + var newCollection = Collection( + name: exportName, + contentProvider: CollectionAlbumProvider( + account: account, + album: newAlbum, + ), + ); + return await collectionsController.createNew(newCollection); + } + + /// Export as a new collection backed by Nextcloud album + Future asNcAlbum() async { + var newCollection = Collection( + name: exportName, + contentProvider: CollectionNcAlbumProvider( + account: account, + album: NcAlbum.createNew(account: account, name: exportName), + ), + ); + newCollection = await collectionsController.createNew(newCollection); + // only files are supported in NcAlbum + final newFiles = + items.whereType().map((e) => e.file).toList(); + final data = collectionsController + .peekStream() + .data + .firstWhere((e) => e.collection.compareIdentity(newCollection)); + await data.controller.addFiles(newFiles); + return newCollection; + } + + final Account account; + final CollectionsController collectionsController; + final Collection collection; + final List items; + final String exportName; +} diff --git a/app/lib/entity/collection/exporter.g.dart b/app/lib/entity/collection/exporter.g.dart new file mode 100644 index 00000000..1ed9e5b7 --- /dev/null +++ b/app/lib/entity/collection/exporter.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'exporter.dart'; + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$CollectionExporterNpLog on CollectionExporter { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("entity.collection.exporter.CollectionExporter"); +} diff --git a/app/lib/widget/collection_browser.dart b/app/lib/widget/collection_browser.dart index 8698aa17..7cc7a4ba 100644 --- a/app/lib/widget/collection_browser.dart +++ b/app/lib/widget/collection_browser.dart @@ -43,6 +43,7 @@ import 'package:nc_photos/use_case/inflate_file_descriptor.dart'; import 'package:nc_photos/use_case/remove.dart'; import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/draggable_item_list.dart'; +import 'package:nc_photos/widget/export_collection_dialog.dart'; import 'package:nc_photos/widget/fancy_option_picker.dart'; import 'package:nc_photos/widget/file_sharer.dart'; import 'package:nc_photos/widget/network_thumbnail.dart'; diff --git a/app/lib/widget/collection_browser.g.dart b/app/lib/widget/collection_browser.g.dart index 46e3f96d..ad8c948b 100644 --- a/app/lib/widget/collection_browser.g.dart +++ b/app/lib/widget/collection_browser.g.dart @@ -160,6 +160,13 @@ extension _$_DownloadToString on _Download { } } +extension _$_ExportToString on _Export { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_Export {}"; + } +} + extension _$_BeginEditToString on _BeginEdit { String _$toString() { // ignore: unnecessary_string_interpolations diff --git a/app/lib/widget/collection_browser/app_bar.dart b/app/lib/widget/collection_browser/app_bar.dart index b69d46d2..fc148692 100644 --- a/app/lib/widget/collection_browser/app_bar.dart +++ b/app/lib/widget/collection_browser/app_bar.dart @@ -41,11 +41,16 @@ class _AppBar extends StatelessWidget { value: _MenuOption.unsetCover, child: Text(L10n.global().unsetAlbumCoverTooltip), ), - if (state.items.isNotEmpty) + if (state.items.isNotEmpty) ...[ PopupMenuItem( value: _MenuOption.download, child: Text(L10n.global().downloadTooltip), ), + const PopupMenuItem( + value: _MenuOption.export, + child: Text("Export"), + ), + ], ], onSelected: (option) { _onMenuSelected(context, option); @@ -82,6 +87,24 @@ class _AppBar extends StatelessWidget { case _MenuOption.download: context.read<_Bloc>().add(const _Download()); break; + case _MenuOption.export: + _onExportSelected(context); + break; + } + } + + Future _onExportSelected(BuildContext context) async { + final bloc = context.read<_Bloc>(); + final result = await showDialog( + context: context, + builder: (_) => ExportCollectionDialog( + account: bloc.account, + collection: bloc.state.collection, + items: bloc.state.items, + ), + ); + if (result != null) { + Navigator.of(context).pop(); } } } @@ -374,6 +397,7 @@ enum _MenuOption { edit, unsetCover, download, + export, } enum _SelectionMenuOption { diff --git a/app/lib/widget/collection_browser/state_event.dart b/app/lib/widget/collection_browser/state_event.dart index c92b9ada..01b5fa9c 100644 --- a/app/lib/widget/collection_browser/state_event.dart +++ b/app/lib/widget/collection_browser/state_event.dart @@ -112,6 +112,14 @@ class _Download implements _Event { String toString() => _$toString(); } +@toString +class _Export implements _Event { + const _Export(); + + @override + String toString() => _$toString(); +} + @toString class _BeginEdit implements _Event { const _BeginEdit(); diff --git a/app/lib/widget/export_collection_dialog.dart b/app/lib/widget/export_collection_dialog.dart new file mode 100644 index 00000000..c69f63d7 --- /dev/null +++ b/app/lib/widget/export_collection_dialog.dart @@ -0,0 +1,225 @@ +import 'dart:async'; + +import 'package:copy_with/copy_with.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/controller/account_controller.dart'; +import 'package:nc_photos/controller/collections_controller.dart'; +import 'package:nc_photos/entity/collection.dart'; +import 'package:nc_photos/entity/collection/exporter.dart'; +import 'package:nc_photos/entity/collection_item.dart'; +import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/exception_util.dart' as exception_util; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/snack_bar_manager.dart'; +import 'package:nc_photos/widget/processing_dialog.dart'; +import 'package:np_codegen/np_codegen.dart'; +import 'package:to_string/to_string.dart'; + +part 'export_collection_dialog.g.dart'; +part 'export_collection_dialog/bloc.dart'; +part 'export_collection_dialog/state_event.dart'; + +class ExportCollectionDialog extends StatelessWidget { + const ExportCollectionDialog({ + super.key, + required this.account, + required this.collection, + required this.items, + }); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => _Bloc( + account: account, + collectionsController: + context.read().collectionsController, + collection: collection, + items: items, + ), + child: const _WrappedExportCollectionDialog(), + ); + } + + final Account account; + final Collection collection; + final List items; +} + +class _WrappedExportCollectionDialog extends StatefulWidget { + const _WrappedExportCollectionDialog(); + + @override + State createState() => _WrappedExportCollectionDialogState(); +} + +@npLog +class _WrappedExportCollectionDialogState + extends State<_WrappedExportCollectionDialog> { + @override + Widget build(BuildContext context) { + return MultiBlocListener( + listeners: [ + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => + previous.result != current.result && current.result != null, + listener: _onResult, + ), + BlocListener<_Bloc, _State>( + listenWhen: (previous, current) => previous.error != current.error, + listener: (_, state) { + if (state.error != null) { + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(state.error!.error)), + duration: k.snackBarDurationNormal, + )); + } + }, + ), + ], + child: BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.isExporting != current.isExporting, + builder: (context, state) { + if (state.isExporting) { + return ProcessingDialog( + text: L10n.global().genericProcessingDialogContent, + ); + } else { + return AlertDialog( + title: const Text("Export collection"), + content: Form( + key: _formKey, + child: Container( + constraints: const BoxConstraints.tightFor(width: 280), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + _NameTextField(), + _ProviderDropdown(), + SizedBox(height: 8), + _ProviderDescription(), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => _onOkPressed(context), + child: Text(MaterialLocalizations.of(context).okButtonLabel), + ), + ], + ); + } + }, + ), + ); + } + + Future _onOkPressed(BuildContext context) async { + if (_formKey.currentState?.validate() == true) { + _bloc.add(const _SubmitForm()); + } + } + + void _onResult(BuildContext context, _State state) { + Navigator.of(context).pop(state.result); + } + + late final _bloc = context.read<_Bloc>(); + + final _formKey = GlobalKey(); +} + +class _NameTextField extends StatelessWidget { + const _NameTextField(); + + @override + Widget build(BuildContext context) { + return TextFormField( + decoration: InputDecoration( + hintText: L10n.global().nameInputHint, + ), + initialValue: context.read<_Bloc>().state.formValue.name, + validator: (value) { + if (value!.isEmpty) { + return L10n.global().albumNameInputInvalidEmpty; + } + return null; + }, + onChanged: (value) { + context.read<_Bloc>().add(_SubmitName(value)); + }, + ); + } +} + +class _ProviderDropdown extends StatelessWidget { + const _ProviderDropdown(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.formValue.provider != current.formValue.provider, + builder: (context, state) => DropdownButtonHideUnderline( + child: DropdownButtonFormField<_ProviderOption>( + value: state.formValue.provider, + items: _ProviderOption.values + .map((e) => DropdownMenuItem<_ProviderOption>( + value: e, + child: Text(e.toValueString(context)), + )) + .toList(), + onChanged: (value) { + context.read<_Bloc>().add(_SubmitProvider(value!)); + }, + ), + ), + ); + } +} + +class _ProviderDescription extends StatelessWidget { + const _ProviderDescription(); + + @override + Widget build(BuildContext context) { + return BlocBuilder<_Bloc, _State>( + buildWhen: (previous, current) => + previous.formValue.provider != current.formValue.provider, + builder: (context, state) => Text( + state.formValue.provider.toDescription(context), + style: Theme.of(context).textTheme.bodyText2, + ), + ); + } +} + +enum _ProviderOption { + appAlbum, + ncAlbum; + + String toValueString(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumLabel; + case ncAlbum: + return "Nextcloud Album"; + } + } + + String toDescription(BuildContext context) { + switch (this) { + case appAlbum: + return L10n.global().createCollectionDialogAlbumDescription; + case ncAlbum: + return "Server-side album, require Nextcloud 25 or above"; + } + } +} diff --git a/app/lib/widget/export_collection_dialog.g.dart b/app/lib/widget/export_collection_dialog.g.dart new file mode 100644 index 00000000..9cae4350 --- /dev/null +++ b/app/lib/widget/export_collection_dialog.g.dart @@ -0,0 +1,113 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'export_collection_dialog.dart'; + +// ************************************************************************** +// CopyWithLintRuleGenerator +// ************************************************************************** + +// ignore_for_file: library_private_types_in_public_api, duplicate_ignore + +// ************************************************************************** +// CopyWithGenerator +// ************************************************************************** + +abstract class $_FormValueCopyWithWorker { + _FormValue call({String? name, _ProviderOption? provider}); +} + +class _$_FormValueCopyWithWorkerImpl implements $_FormValueCopyWithWorker { + _$_FormValueCopyWithWorkerImpl(this.that); + + @override + _FormValue call({dynamic name, dynamic provider}) { + return _FormValue( + name: name as String? ?? that.name, + provider: provider as _ProviderOption? ?? that.provider); + } + + final _FormValue that; +} + +extension $_FormValueCopyWith on _FormValue { + $_FormValueCopyWithWorker get copyWith => _$copyWith; + $_FormValueCopyWithWorker get _$copyWith => + _$_FormValueCopyWithWorkerImpl(this); +} + +abstract class $_StateCopyWithWorker { + _State call( + {_FormValue? formValue, + Collection? result, + bool? isExporting, + ExceptionEvent? error}); +} + +class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker { + _$_StateCopyWithWorkerImpl(this.that); + + @override + _State call( + {dynamic formValue, + dynamic result = copyWithNull, + dynamic isExporting, + dynamic error = copyWithNull}) { + return _State( + formValue: formValue as _FormValue? ?? that.formValue, + result: result == copyWithNull ? that.result : result as Collection?, + isExporting: isExporting as bool? ?? that.isExporting, + error: error == copyWithNull ? that.error : error as ExceptionEvent?); + } + + final _State that; +} + +extension $_StateCopyWith on _State { + $_StateCopyWithWorker get copyWith => _$copyWith; + $_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this); +} + +// ************************************************************************** +// NpLogGenerator +// ************************************************************************** + +extension _$_WrappedExportCollectionDialogStateNpLog + on _WrappedExportCollectionDialogState { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger( + "widget.export_collection_dialog._WrappedExportCollectionDialogState"); +} + +extension _$_BlocNpLog on _Bloc { + // ignore: unused_element + Logger get _log => log; + + static final log = Logger("widget.export_collection_dialog._Bloc"); +} + +// ************************************************************************** +// ToStringGenerator +// ************************************************************************** + +extension _$_SubmitNameToString on _SubmitName { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitName {value: $value}"; + } +} + +extension _$_SubmitProviderToString on _SubmitProvider { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitProvider {value: ${value.name}}"; + } +} + +extension _$_SubmitFormToString on _SubmitForm { + String _$toString() { + // ignore: unnecessary_string_interpolations + return "_SubmitForm {}"; + } +} diff --git a/app/lib/widget/export_collection_dialog/bloc.dart b/app/lib/widget/export_collection_dialog/bloc.dart new file mode 100644 index 00000000..6d2abed7 --- /dev/null +++ b/app/lib/widget/export_collection_dialog/bloc.dart @@ -0,0 +1,63 @@ +part of '../export_collection_dialog.dart'; + +@npLog +class _Bloc extends Bloc<_Event, _State> { + _Bloc({ + required this.account, + required this.collectionsController, + required this.collection, + required this.items, + }) : super(_State.init()) { + on<_FormEvent>(_onFormEvent); + } + + Future _onFormEvent(_FormEvent ev, Emitter<_State> emit) async { + _log.info("$ev"); + if (ev is _SubmitName) { + _onSubmitName(ev, emit); + } else if (ev is _SubmitProvider) { + _onSubmitProvider(ev, emit); + } else if (ev is _SubmitForm) { + await _onSubmitForm(ev, emit); + } + } + + void _onSubmitName(_SubmitName ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(name: ev.value), + )); + } + + void _onSubmitProvider(_SubmitProvider ev, Emitter<_State> emit) { + emit(state.copyWith( + formValue: state.formValue.copyWith(provider: ev.value), + )); + } + + Future _onSubmitForm(_SubmitForm ev, Emitter<_State> emit) async { + emit(state.copyWith(isExporting: true)); + try { + final exporter = CollectionExporter(account, collectionsController, + collection, items, state.formValue.name); + final Collection result; + switch (state.formValue.provider) { + case _ProviderOption.appAlbum: + result = await exporter.asAlbum(); + break; + case _ProviderOption.ncAlbum: + result = await exporter.asNcAlbum(); + break; + } + emit(state.copyWith(result: result)); + } catch (e, stackTrace) { + _log.severe("[_onSubmitForm] Failed while exporting", e, stackTrace); + } finally { + emit(state.copyWith(isExporting: false)); + } + } + + final Account account; + final CollectionsController collectionsController; + final Collection collection; + final List items; +} diff --git a/app/lib/widget/export_collection_dialog/state_event.dart b/app/lib/widget/export_collection_dialog/state_event.dart new file mode 100644 index 00000000..bd56d8e2 --- /dev/null +++ b/app/lib/widget/export_collection_dialog/state_event.dart @@ -0,0 +1,71 @@ +part of '../export_collection_dialog.dart'; + +@genCopyWith +class _FormValue { + const _FormValue({ + this.name = "", + this.provider = _ProviderOption.appAlbum, + }); + + final String name; + final _ProviderOption provider; +} + +@genCopyWith +class _State { + const _State({ + required this.formValue, + this.result, + required this.isExporting, + this.error, + }); + + factory _State.init() { + return const _State( + formValue: _FormValue(), + isExporting: false, + ); + } + + final _FormValue formValue; + final Collection? result; + final bool isExporting; + + final ExceptionEvent? error; +} + +abstract class _Event { + const _Event(); +} + +abstract class _FormEvent implements _Event { + const _FormEvent(); +} + +@toString +class _SubmitName extends _FormEvent { + const _SubmitName(this.value); + + @override + String toString() => _$toString(); + + final String value; +} + +@toString +class _SubmitProvider extends _FormEvent { + const _SubmitProvider(this.value); + + @override + String toString() => _$toString(); + + final _ProviderOption value; +} + +@toString +class _SubmitForm extends _FormEvent { + const _SubmitForm(); + + @override + String toString() => _$toString(); +}