620 lines
21 KiB
Dart
620 lines
21 KiB
Dart
import 'package:file_picker/file_picker.dart';
|
|
import '../media/ids.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../focus/focusable_wrapper.dart';
|
|
import '../focus/focusable_button.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_item.dart';
|
|
import '../metadata_edit/metadata_edit_adapters.dart';
|
|
import '../metadata_edit/metadata_edit_models.dart';
|
|
import '../services/file_picker_service.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/dialogs.dart';
|
|
import '../utils/formatters.dart';
|
|
import '../utils/provider_extensions.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../widgets/app_icon.dart';
|
|
import '../widgets/dialog_action_button.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/focused_scroll_scaffold.dart';
|
|
import '../widgets/loading_indicator_box.dart';
|
|
import '../widgets/optimized_media_image.dart';
|
|
import '../widgets/tag_edit_dialog.dart';
|
|
|
|
class MetadataEditScreen extends StatefulWidget {
|
|
final MediaItem metadata;
|
|
|
|
const MetadataEditScreen({super.key, required this.metadata});
|
|
|
|
@override
|
|
State<MetadataEditScreen> createState() => _MetadataEditScreenState();
|
|
}
|
|
|
|
class _MetadataEditScreenState extends State<MetadataEditScreen> {
|
|
MetadataEditAdapter? _adapter;
|
|
MetadataEditDraft? _draft;
|
|
bool _isLoading = true;
|
|
bool _isCommitting = false;
|
|
int _loadGeneration = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadMetadata();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant MetadataEditScreen oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (_sameMedia(oldWidget.metadata, widget.metadata)) return;
|
|
|
|
_adapter = null;
|
|
_draft = null;
|
|
_isLoading = true;
|
|
_loadMetadata();
|
|
}
|
|
|
|
Future<void> _loadMetadata() async {
|
|
final generation = ++_loadGeneration;
|
|
final metadata = widget.metadata;
|
|
try {
|
|
final client = context.getMediaClientWithFallback(serverIdOrNull(metadata.serverId));
|
|
final adapter = metadataEditAdapterFor(client);
|
|
if (adapter == null || !adapter.supportsKind(metadata.kind)) {
|
|
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
|
|
setState(() {
|
|
_adapter = adapter;
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
final draft = await adapter.load(metadata);
|
|
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
|
|
setState(() {
|
|
_adapter = adapter;
|
|
_draft = draft;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to load metadata editor', error: e, stackTrace: st);
|
|
if (!mounted || generation != _loadGeneration || !_sameMedia(widget.metadata, metadata)) return;
|
|
setState(() => _isLoading = false);
|
|
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
|
|
}
|
|
}
|
|
|
|
bool get _hasChanges {
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
return adapter != null && draft != null && adapter.hasChanges(draft);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
final metadata = widget.metadata;
|
|
if (adapter == null || draft == null || !_hasChanges || _isCommitting) return;
|
|
|
|
setState(() => _isCommitting = true);
|
|
bool success = false;
|
|
try {
|
|
success = await adapter.save(draft);
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to update metadata', error: e, stackTrace: st);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata);
|
|
setState(() => _isCommitting = false);
|
|
if (!ownsCompletion) return;
|
|
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.metadataEdit.metadataUpdated);
|
|
Navigator.pop(context, true);
|
|
} else {
|
|
showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
|
|
}
|
|
}
|
|
|
|
Future<void> _editTextField(MetadataEditField field, {bool multiline = false}) async {
|
|
final draft = _draft;
|
|
if (draft == null || _isCommitting) return;
|
|
final currentValue = draft.value<String>(field.id) ?? '';
|
|
final result = multiline
|
|
? await showTextInputDialog(
|
|
context,
|
|
title: field.label,
|
|
labelText: field.label,
|
|
initialValue: currentValue,
|
|
allowEmpty: true,
|
|
multiline: true,
|
|
)
|
|
: await showTextInputDialog(
|
|
context,
|
|
title: field.label,
|
|
labelText: field.label,
|
|
hintText: '',
|
|
initialValue: currentValue,
|
|
allowEmpty: true,
|
|
);
|
|
|
|
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
|
setState(() => draft.setValue(field.id, result));
|
|
}
|
|
}
|
|
|
|
Future<void> _editDate(MetadataEditField field) async {
|
|
final draft = _draft;
|
|
if (draft == null || _isCommitting) return;
|
|
DateTime initial = DateTime.now();
|
|
final current = draft.value<String>(field.id);
|
|
if (current != null && current.isNotEmpty) {
|
|
final parsed = DateTime.tryParse(current);
|
|
if (parsed != null) initial = parsed;
|
|
}
|
|
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: initial,
|
|
firstDate: DateTime(1800),
|
|
lastDate: DateTime(2200),
|
|
useRootNavigator: false,
|
|
);
|
|
|
|
if (picked != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
|
setState(() {
|
|
draft.setValue(field.id, '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}');
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _editStringList(MetadataEditField field) async {
|
|
final draft = _draft;
|
|
if (draft == null || _isCommitting) return;
|
|
final result = await showScopedDialog<List<String>>(
|
|
context: context,
|
|
builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])),
|
|
);
|
|
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
|
setState(() => draft.setValue(field.id, result));
|
|
}
|
|
}
|
|
|
|
Future<void> _editChoice(MetadataEditField field) async {
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
if (adapter == null || draft == null || _isCommitting) return;
|
|
final current = draft.value<String>(field.id) ?? '';
|
|
final result = await showScopedDialog<String>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
String selected = current;
|
|
return StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
return AlertDialog(
|
|
title: Text(field.label),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: RadioGroup<String>(
|
|
groupValue: field.options.any((option) => option.value == selected) ? selected : null,
|
|
onChanged: (value) {
|
|
if (value == null) return;
|
|
setDialogState(() => selected = value);
|
|
Navigator.pop(dialogContext, value);
|
|
},
|
|
child: ListView(
|
|
shrinkWrap: true,
|
|
children: [
|
|
for (final option in field.options)
|
|
FocusableRadioListTile<String>(
|
|
key: ValueKey(option.value),
|
|
title: Text(option.label),
|
|
value: option.value,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel)],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result == null || !mounted || _isCommitting || !identical(_draft, draft)) return;
|
|
if (field.saveMode == MetadataEditSaveMode.immediate) {
|
|
final metadata = widget.metadata;
|
|
final previous = draft.values[field.id];
|
|
setState(() {
|
|
draft.setValue(field.id, result);
|
|
_isCommitting = true;
|
|
});
|
|
bool success = false;
|
|
try {
|
|
success = await adapter.saveImmediateField(draft, field, result);
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to update metadata field', error: e, stackTrace: st);
|
|
}
|
|
if (!mounted) return;
|
|
final ownsCompletion = identical(_draft, draft) && _sameMedia(widget.metadata, metadata);
|
|
setState(() {
|
|
_isCommitting = false;
|
|
if (!success && ownsCompletion && metadataEditValueEquals(draft.values[field.id], result)) {
|
|
draft.setValue(field.id, previous);
|
|
}
|
|
});
|
|
if (!ownsCompletion) return;
|
|
if (!success) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
|
|
} else {
|
|
setState(() => draft.setValue(field.id, result));
|
|
}
|
|
}
|
|
|
|
Future<void> _openArtworkPicker(MetadataEditField field) async {
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
if (adapter == null || draft == null || _isCommitting) return;
|
|
final result = await showScopedDialog<bool>(
|
|
context: context,
|
|
builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field),
|
|
);
|
|
|
|
if (result == true && mounted && !_isCommitting && identical(_draft, draft)) {
|
|
await _reloadArtwork();
|
|
}
|
|
}
|
|
|
|
Future<void> _reloadArtwork() async {
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
if (adapter == null || draft == null) return;
|
|
try {
|
|
final item = await adapter.reloadItem(draft);
|
|
if (!mounted) return;
|
|
if (item != null) {
|
|
setState(() => adapter.syncReloadedItem(draft, item));
|
|
}
|
|
} catch (_) {
|
|
// Artwork was already saved by the picker; display will refresh next time.
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_isLoading) {
|
|
return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]);
|
|
}
|
|
|
|
final adapter = _adapter;
|
|
final draft = _draft;
|
|
if (adapter == null || draft == null) {
|
|
return FocusedScrollScaffold(
|
|
title: Text(t.metadataEdit.screenTitle),
|
|
slivers: [SliverFillRemaining(child: Center(child: Text(t.metadataEdit.metadataUpdateFailed)))],
|
|
);
|
|
}
|
|
|
|
final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList();
|
|
return PopScope(
|
|
canPop: !_isCommitting,
|
|
child: FocusedScrollScaffold(
|
|
title: Text(t.metadataEdit.screenTitle),
|
|
focusableAppBarActions: true,
|
|
actions: [
|
|
if (_isCommitting)
|
|
const Padding(padding: .all(12), child: LoadingIndicatorBox(size: 24))
|
|
else
|
|
FocusableButton(
|
|
onPressed: _hasChanges ? _save : null,
|
|
child: IconButton(
|
|
onPressed: _hasChanges ? _save : null,
|
|
icon: const AppIcon(Symbols.check_rounded, fill: 1),
|
|
tooltip: t.common.save,
|
|
),
|
|
),
|
|
],
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(16),
|
|
sliver: SliverList.separated(
|
|
itemCount: sections.length,
|
|
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
|
itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionCard(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditSection section) {
|
|
return Card(
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Text(section.title, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: .bold)),
|
|
),
|
|
for (final field in section.fields) _buildField(adapter, draft, field),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildField(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) {
|
|
return switch (field.type) {
|
|
MetadataEditFieldType.text => _buildFieldTile(
|
|
label: field.label,
|
|
value: draft.value<String>(field.id),
|
|
onTap: () => _editTextField(field),
|
|
),
|
|
MetadataEditFieldType.multilineText => _buildFieldTile(
|
|
label: field.label,
|
|
value: draft.value<String>(field.id),
|
|
onTap: () => _editTextField(field, multiline: true),
|
|
),
|
|
MetadataEditFieldType.date => _buildFieldTile(
|
|
label: field.label,
|
|
value: draft.value<String>(field.id),
|
|
onTap: () => _editDate(field),
|
|
),
|
|
MetadataEditFieldType.stringList => _buildFieldTile(
|
|
label: field.label,
|
|
value: metadataStringList(draft.values[field.id]).join(', '),
|
|
onTap: () => _editStringList(field),
|
|
),
|
|
MetadataEditFieldType.choice => _buildFieldTile(
|
|
label: field.label,
|
|
value: _choiceDisplayValue(field, draft.value<String>(field.id)),
|
|
onTap: () => _editChoice(field),
|
|
),
|
|
MetadataEditFieldType.artwork => _buildArtworkTile(adapter, draft, field),
|
|
};
|
|
}
|
|
|
|
Widget _buildFieldTile({required String label, String? value, required VoidCallback onTap}) {
|
|
final displayValue = (value == null || value.isEmpty) ? t.metadataEdit.notSet : value;
|
|
final isNotSet = value == null || value.isEmpty;
|
|
return FocusableListTile(
|
|
title: Text(label),
|
|
subtitle: Text(
|
|
displayValue,
|
|
maxLines: 2,
|
|
overflow: .ellipsis,
|
|
style: isNotSet
|
|
? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5))
|
|
: null,
|
|
),
|
|
trailing: const AppIcon(Symbols.chevron_right_rounded),
|
|
enabled: !_isCommitting,
|
|
onTap: _isCommitting ? null : onTap,
|
|
dense: false,
|
|
visualDensity: VisualDensity.standard,
|
|
);
|
|
}
|
|
|
|
Widget _buildArtworkTile(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) {
|
|
final artwork = field.artwork!;
|
|
final imagePath = draft.value<String>(field.id);
|
|
return FocusableListTile(
|
|
leading: SizedBox(
|
|
width: artwork.previewWidth,
|
|
height: artwork.previewHeight,
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
|
child: OptimizedMediaImage(
|
|
client: adapter.mediaClient,
|
|
imagePath: imagePath,
|
|
width: artwork.previewWidth,
|
|
height: artwork.previewHeight,
|
|
fit: artwork.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover,
|
|
imageType: artwork.imageType,
|
|
),
|
|
),
|
|
),
|
|
title: Text(field.label),
|
|
trailing: const AppIcon(Symbols.chevron_right_rounded),
|
|
enabled: !_isCommitting,
|
|
onTap: _isCommitting ? null : () => _openArtworkPicker(field),
|
|
dense: false,
|
|
visualDensity: VisualDensity.standard,
|
|
);
|
|
}
|
|
|
|
String _choiceDisplayValue(MetadataEditField field, String? value) {
|
|
for (final option in field.options) {
|
|
if (option.value == value) return option.label;
|
|
}
|
|
return value == null || value.isEmpty ? t.metadataEdit.notSet : value;
|
|
}
|
|
}
|
|
|
|
bool _sameMedia(MediaItem left, MediaItem right) {
|
|
return left.id == right.id && left.backend == right.backend && left.serverId == right.serverId;
|
|
}
|
|
|
|
class ArtworkPickerDialog extends StatefulWidget {
|
|
final MetadataEditAdapter adapter;
|
|
final MetadataEditDraft draft;
|
|
final MetadataEditField field;
|
|
|
|
const ArtworkPickerDialog({super.key, required this.adapter, required this.draft, required this.field});
|
|
|
|
@override
|
|
State<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState();
|
|
}
|
|
|
|
class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
|
|
List<MetadataArtworkOption>? _artworkList;
|
|
bool _isLoading = true;
|
|
bool _isApplying = false;
|
|
|
|
MetadataArtworkConfig get _config => widget.field.artwork!;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadArtwork();
|
|
}
|
|
|
|
Future<void> _loadArtwork() async {
|
|
try {
|
|
final artwork = await widget.adapter.fetchArtwork(widget.draft, widget.field);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_artworkList = artwork;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to load artwork', error: e, stackTrace: st);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_artworkList = const [];
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectArtwork(MetadataArtworkOption artwork) async {
|
|
if (_isApplying) return;
|
|
await _runArtworkUpdate(() => widget.adapter.applyArtworkOption(widget.draft, widget.field, artwork));
|
|
}
|
|
|
|
Future<void> _addFromUrl() async {
|
|
final url = await showTextInputDialog(
|
|
context,
|
|
title: t.metadataEdit.fromUrl,
|
|
labelText: t.metadataEdit.imageUrl,
|
|
hintText: t.metadataEdit.enterImageUrl,
|
|
);
|
|
|
|
if (url == null || url.isEmpty || !mounted) return;
|
|
await _runArtworkUpdate(() => widget.adapter.applyArtworkFromUrl(widget.draft, widget.field, url));
|
|
}
|
|
|
|
Future<void> _uploadFile() async {
|
|
final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true);
|
|
if (result == null || result.files.isEmpty || !mounted) return;
|
|
final file = result.files.first;
|
|
final bytes = file.bytes;
|
|
if (bytes == null) return;
|
|
await _runArtworkUpdate(() => widget.adapter.uploadArtwork(widget.draft, widget.field, bytes, fileName: file.name));
|
|
}
|
|
|
|
Future<void> _runArtworkUpdate(Future<bool> Function() action) async {
|
|
if (_isApplying) return;
|
|
setState(() => _isApplying = true);
|
|
bool success = false;
|
|
try {
|
|
success = await action();
|
|
} catch (e, st) {
|
|
appLogger.e('Artwork update failed', error: e, stackTrace: st);
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _isApplying = false);
|
|
if (success) {
|
|
showSuccessSnackBar(context, t.metadataEdit.artworkUpdated);
|
|
Navigator.pop(context, true);
|
|
} else {
|
|
showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return PopScope(
|
|
canPop: !_isApplying,
|
|
child: AlertDialog(
|
|
title: Text(_config.selectTitle),
|
|
content: SizedBox(
|
|
width: 500,
|
|
height: 400,
|
|
child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(),
|
|
),
|
|
actions: [
|
|
if (_isApplying) const Padding(padding: .all(8), child: LoadingIndicatorBox(size: 24)),
|
|
DialogActionButton(
|
|
onPressed: _isApplying ? null : _addFromUrl,
|
|
label: t.metadataEdit.fromUrl,
|
|
icon: const AppIcon(Symbols.link_rounded, size: 18),
|
|
),
|
|
DialogActionButton(
|
|
onPressed: _isApplying ? null : _uploadFile,
|
|
label: t.metadataEdit.uploadFile,
|
|
icon: const AppIcon(Symbols.upload_rounded, size: 18),
|
|
),
|
|
DialogActionButton(
|
|
autofocus: true,
|
|
onPressed: _isApplying ? null : () => Navigator.pop(context),
|
|
label: t.common.cancel,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildArtworkContent() {
|
|
if (_artworkList == null || _artworkList!.isEmpty) {
|
|
return Center(child: Text(t.metadataEdit.noArtworkAvailable));
|
|
}
|
|
return GridView.builder(
|
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: _config.gridColumns,
|
|
crossAxisSpacing: 8,
|
|
mainAxisSpacing: 8,
|
|
childAspectRatio: _config.gridAspectRatio,
|
|
),
|
|
itemCount: _artworkList!.length,
|
|
itemBuilder: (context, index) {
|
|
final artwork = _artworkList![index];
|
|
final VoidCallback? onSelect = _isApplying ? null : () => _selectArtwork(artwork);
|
|
return FocusableWrapper(
|
|
borderRadius: 8,
|
|
semanticLabel: artwork.selected
|
|
? t.metadataEdit.selectedArtworkOption(index: index + 1)
|
|
: t.metadataEdit.artworkOption(index: index + 1),
|
|
canRequestFocus: !_isApplying,
|
|
onSelect: onSelect,
|
|
child: GestureDetector(
|
|
onTap: onSelect,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
child: OptimizedMediaImage(
|
|
client: widget.adapter.mediaClient,
|
|
imagePath: artwork.thumbnailPath,
|
|
fit: _config.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover,
|
|
imageType: _config.imageType,
|
|
),
|
|
),
|
|
),
|
|
if (artwork.selected)
|
|
Positioned(
|
|
right: 6,
|
|
bottom: 6,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(4),
|
|
decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle),
|
|
child: AppIcon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|