Export collection with different provider

This commit is contained in:
Ming Ming 2023-04-26 01:02:39 +08:00
parent e7212e0643
commit 3f38efccf3
10 changed files with 635 additions and 1 deletions

View file

@ -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<Collection> asAlbum() async {
final files = await FindFile(KiwiContainer().resolve<DiContainer>())(
account,
items.whereType<CollectionFileItem>().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<Collection> 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<CollectionFileItem>().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<CollectionItem> items;
final String exportName;
}

View file

@ -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");
}

View file

@ -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/use_case/remove.dart';
import 'package:nc_photos/widget/collection_picker.dart'; import 'package:nc_photos/widget/collection_picker.dart';
import 'package:nc_photos/widget/draggable_item_list.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/fancy_option_picker.dart';
import 'package:nc_photos/widget/file_sharer.dart'; import 'package:nc_photos/widget/file_sharer.dart';
import 'package:nc_photos/widget/network_thumbnail.dart'; import 'package:nc_photos/widget/network_thumbnail.dart';

View file

@ -160,6 +160,13 @@ extension _$_DownloadToString on _Download {
} }
} }
extension _$_ExportToString on _Export {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_Export {}";
}
}
extension _$_BeginEditToString on _BeginEdit { extension _$_BeginEditToString on _BeginEdit {
String _$toString() { String _$toString() {
// ignore: unnecessary_string_interpolations // ignore: unnecessary_string_interpolations

View file

@ -41,11 +41,16 @@ class _AppBar extends StatelessWidget {
value: _MenuOption.unsetCover, value: _MenuOption.unsetCover,
child: Text(L10n.global().unsetAlbumCoverTooltip), child: Text(L10n.global().unsetAlbumCoverTooltip),
), ),
if (state.items.isNotEmpty) if (state.items.isNotEmpty) ...[
PopupMenuItem( PopupMenuItem(
value: _MenuOption.download, value: _MenuOption.download,
child: Text(L10n.global().downloadTooltip), child: Text(L10n.global().downloadTooltip),
), ),
const PopupMenuItem(
value: _MenuOption.export,
child: Text("Export"),
),
],
], ],
onSelected: (option) { onSelected: (option) {
_onMenuSelected(context, option); _onMenuSelected(context, option);
@ -82,6 +87,24 @@ class _AppBar extends StatelessWidget {
case _MenuOption.download: case _MenuOption.download:
context.read<_Bloc>().add(const _Download()); context.read<_Bloc>().add(const _Download());
break; break;
case _MenuOption.export:
_onExportSelected(context);
break;
}
}
Future<void> _onExportSelected(BuildContext context) async {
final bloc = context.read<_Bloc>();
final result = await showDialog<Collection>(
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, edit,
unsetCover, unsetCover,
download, download,
export,
} }
enum _SelectionMenuOption { enum _SelectionMenuOption {

View file

@ -112,6 +112,14 @@ class _Download implements _Event {
String toString() => _$toString(); String toString() => _$toString();
} }
@toString
class _Export implements _Event {
const _Export();
@override
String toString() => _$toString();
}
@toString @toString
class _BeginEdit implements _Event { class _BeginEdit implements _Event {
const _BeginEdit(); const _BeginEdit();

View file

@ -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<AccountController>().collectionsController,
collection: collection,
items: items,
),
child: const _WrappedExportCollectionDialog(),
);
}
final Account account;
final Collection collection;
final List<CollectionItem> items;
}
class _WrappedExportCollectionDialog extends StatefulWidget {
const _WrappedExportCollectionDialog();
@override
State<StatefulWidget> 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<void> _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<FormState>();
}
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";
}
}
}

View file

@ -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 {}";
}
}

View file

@ -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<void> _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<void> _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<CollectionItem> items;
}

View file

@ -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();
}