#1272 albums: improved album creation feedback

This commit is contained in:
Thibault Deckers 2024-11-08 23:29:51 +01:00
parent e071ff299a
commit 339a039dd6
13 changed files with 121 additions and 82 deletions

View file

@ -423,6 +423,7 @@
"newAlbumDialogTitle": "New Album", "newAlbumDialogTitle": "New Album",
"newAlbumDialogNameLabel": "Album name", "newAlbumDialogNameLabel": "Album name",
"newAlbumDialogAlbumAlreadyExistsHelper": "Album already exists",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists", "newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists",
"newAlbumDialogStorageLabel": "Storage:", "newAlbumDialogStorageLabel": "Storage:",

View file

@ -147,8 +147,10 @@ mixin AlbumMixin on SourceBase {
// new albums // new albums
void createAlbum(String directory) { void createAlbum(String directory) {
_newAlbums.add(directory); if (!_directories.contains(directory)) {
addDirectories(albums: {directory}); _newAlbums.add(directory);
addDirectories(albums: {directory});
}
} }
void renameNewAlbum(String source, String destination) { void renameNewAlbum(String source, String destination) {

View file

@ -63,7 +63,7 @@ class MediaStoreSource extends CollectionSource {
if (currentTimeZoneOffset != null) { if (currentTimeZoneOffset != null) {
final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis; final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis;
if (currentTimeZoneOffset != catalogTimeZoneOffset) { if (currentTimeZoneOffset != catalogTimeZoneOffset) {
unawaited(reportService.log('Time zone offset change: $currentTimeZoneOffset -> $catalogTimeZoneOffset. Clear catalog metadata to get correct date/times.')); unawaited(reportService.recordError('Time zone offset change: $currentTimeZoneOffset -> $catalogTimeZoneOffset. Clear catalog metadata to get correct date/times.', null));
await localMediaDb.clearDates(); await localMediaDb.clearDates();
await localMediaDb.clearCatalogMetadata(); await localMediaDb.clearCatalogMetadata();
settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset; settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset;

View file

@ -19,6 +19,11 @@ class AndroidFileUtils {
// cf https://developer.android.com/reference/android/provider/MediaStore#VOLUME_EXTERNAL // cf https://developer.android.com/reference/android/provider/MediaStore#VOLUME_EXTERNAL
static const externalVolume = 'external'; static const externalVolume = 'external';
static const standardDirDcim = 'DCIM';
static const standardDirDownloads = 'Download';
static const standardDirMovies = 'Movies';
static const standardDirPictures = 'Pictures';
static const mediaStoreUriRoot = '$contentScheme://$mediaStoreAuthority/'; static const mediaStoreUriRoot = '$contentScheme://$mediaStoreAuthority/';
static const mediaUriPathRoots = {'/$externalVolume/images/', '/$externalVolume/video/'}; static const mediaUriPathRoots = {'/$externalVolume/images/', '/$externalVolume/video/'};
@ -43,12 +48,13 @@ class AndroidFileUtils {
await _initStorageVolumes(); await _initStorageVolumes();
vaultRoot = await storageService.getVaultRoot(); vaultRoot = await storageService.getVaultRoot();
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator; primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator;
// standard // standard dirs
dcimPath = pContext.join(primaryStorage, 'DCIM'); dcimPath = pContext.join(primaryStorage, standardDirDcim);
// effective download path may have a different case // effective download path may have a different case
downloadPath = pContext.join(primaryStorage, 'Download').toLowerCase(); downloadPath = pContext.join(primaryStorage, standardDirDownloads).toLowerCase();
moviesPath = pContext.join(primaryStorage, 'Movies'); moviesPath = pContext.join(primaryStorage, standardDirMovies);
picturesPath = pContext.join(primaryStorage, 'Pictures'); picturesPath = pContext.join(primaryStorage, standardDirPictures);
// custom dirs
avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures'); avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures');
videoCapturesPaths = { videoCapturesPaths = {
// from Samsung // from Samsung

View file

@ -689,7 +689,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}, },
routeSettings: const RouteSettings(name: TileViewDialog.routeName), routeSettings: const RouteSettings(name: TileViewDialog.routeName),
); );
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
if (value != null && initialValue != value) { if (value != null && initialValue != value) {
settings.collectionSortFactor = value.$1!; settings.collectionSortFactor = value.$1!;

View file

@ -512,7 +512,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
); );
if (confirmed == null || !confirmed) return null; if (confirmed == null || !confirmed) return null;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
return supported; return supported;
} }

View file

@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
@ -9,6 +10,7 @@ import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves_model/aves_model.dart'; import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CreateAlbumDialog extends StatefulWidget { class CreateAlbumDialog extends StatefulWidget {
static const routeName = '/dialog/create_album'; static const routeName = '/dialog/create_album';
@ -23,7 +25,8 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final TextEditingController _nameController = TextEditingController(); final TextEditingController _nameController = TextEditingController();
final FocusNode _nameFieldFocusNode = FocusNode(); final FocusNode _nameFieldFocusNode = FocusNode();
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false); final ValueNotifier<bool> _directoryExistsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _albumExistsNotifier = ValueNotifier(false);
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false); final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
late Set<StorageVolume> _allVolumes; late Set<StorageVolume> _allVolumes;
late StorageVolume? _primaryVolume, _selectedVolume; late StorageVolume? _primaryVolume, _selectedVolume;
@ -43,13 +46,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
_nameController.dispose(); _nameController.dispose();
_nameFieldFocusNode.removeListener(_onFocus); _nameFieldFocusNode.removeListener(_onFocus);
_nameFieldFocusNode.dispose(); _nameFieldFocusNode.dispose();
_existsNotifier.dispose(); _directoryExistsNotifier.dispose();
_isValidNotifier.dispose(); _isValidNotifier.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding);
final volumeTiles = <Widget>[]; final volumeTiles = <Widget>[];
@ -61,7 +65,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
volumeTiles.addAll([ volumeTiles.addAll([
Padding( Padding(
padding: contentHorizontalPadding + const EdgeInsets.only(top: 20), padding: contentHorizontalPadding + const EdgeInsets.only(top: 20),
child: Text(context.l10n.newAlbumDialogStorageLabel), child: Text(l10n.newAlbumDialogStorageLabel),
), ),
...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)), ...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)),
...otherVolumes.map((volume) => _buildVolumeTile(context, volume)), ...otherVolumes.map((volume) => _buildVolumeTile(context, volume)),
@ -70,21 +74,27 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
} }
return AvesDialog( return AvesDialog(
title: context.l10n.newAlbumDialogTitle, title: l10n.newAlbumDialogTitle,
scrollController: _scrollController, scrollController: _scrollController,
scrollableContent: [ scrollableContent: [
...volumeTiles, ...volumeTiles,
Padding( Padding(
padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8), padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>( child: AnimatedBuilder(
valueListenable: _existsNotifier, animation: Listenable.merge([_albumExistsNotifier, _directoryExistsNotifier]),
builder: (context, exists, child) { builder: (context, child) {
var helperText = '';
if (_albumExistsNotifier.value) {
helperText = l10n.newAlbumDialogAlbumAlreadyExistsHelper;
} else if (_directoryExistsNotifier.value) {
helperText = l10n.newAlbumDialogNameLabelAlreadyExistsHelper;
}
return TextField( return TextField(
controller: _nameController, controller: _nameController,
focusNode: _nameFieldFocusNode, focusNode: _nameFieldFocusNode,
decoration: InputDecoration( decoration: InputDecoration(
labelText: context.l10n.newAlbumDialogNameLabel, labelText: l10n.newAlbumDialogNameLabel,
helperText: exists ? context.l10n.newAlbumDialogNameLabelAlreadyExistsHelper : '', helperText: helperText,
), ),
autofocus: _allVolumes.length == 1, autofocus: _allVolumes.length == 1,
onChanged: (_) => _validate(), onChanged: (_) => _validate(),
@ -96,11 +106,16 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
actions: [ actions: [
const CancelButton(), const CancelButton(),
ValueListenableBuilder<bool>( ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier, valueListenable: _albumExistsNotifier,
builder: (context, isValid, child) { builder: (context, albumExists, child) {
return TextButton( return ValueListenableBuilder<bool>(
onPressed: isValid ? () => _submit(context) : null, valueListenable: _isValidNotifier,
child: Text(context.l10n.createAlbumButtonLabel), builder: (context, isValid, child) {
return TextButton(
onPressed: isValid ? () => _submit(context) : null,
child: Text(albumExists ? l10n.showButtonLabel : l10n.createAlbumButtonLabel),
);
},
); );
}, },
), ),
@ -147,34 +162,18 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
); );
} }
String _sanitize(String input) => input.trim(); Future<String?> _buildAlbumPath() async {
final name = _nameController.text.trim();
if (name.isEmpty) return null;
String? _buildAlbumPath(String name) {
final selectedVolume = _selectedVolume;
if (selectedVolume == null || name.isEmpty) return null;
return pContext.join(selectedVolume.path, 'Pictures', name);
}
Future<void> _validate() async {
final newName = _sanitize(_nameController.text);
final path = _buildAlbumPath(newName);
// this check ignores case
final exists = path != null && await Directory(path).exists();
_existsNotifier.value = exists;
_isValidNotifier.value = path != null && newName.isNotEmpty;
}
Future<void> _submit(BuildContext context) async {
if (!_isValidNotifier.value) return;
final newName = _sanitize(_nameController.text);
final albumPath = _buildAlbumPath(newName);
final volumePath = _selectedVolume?.path; final volumePath = _selectedVolume?.path;
if (albumPath == null || volumePath == null) return; if (volumePath == null) return null;
final candidatePath = pContext.join(volumePath, AndroidFileUtils.standardDirPictures, name);
// uses resolved directory name case if it exists // uses resolved directory name case if it exists
var resolvedPath = volumePath; var resolvedPath = volumePath;
final relativePathSegments = pContext.split(pContext.relative(albumPath, from: volumePath)); final relativePathSegments = pContext.split(pContext.relative(candidatePath, from: volumePath));
for (final targetSegment in relativePathSegments) { for (final targetSegment in relativePathSegments) {
String? resolvedSegment; String? resolvedSegment;
final directory = Directory(resolvedPath); final directory = Directory(resolvedPath);
@ -184,6 +183,22 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
} }
resolvedPath = pContext.join(resolvedPath, resolvedSegment ?? targetSegment); resolvedPath = pContext.join(resolvedPath, resolvedSegment ?? targetSegment);
} }
Navigator.maybeOf(context)?.pop(resolvedPath);
return resolvedPath;
}
Future<void> _validate() async {
final path = await _buildAlbumPath();
final isValid = path != null;
_isValidNotifier.value = isValid;
_directoryExistsNotifier.value = isValid && await Directory(path).exists();
_albumExistsNotifier.value = isValid && context.read<CollectionSource>().rawAlbums.contains(path);
}
Future<void> _submit(BuildContext context) async {
final path = await _buildAlbumPath();
if (path == null) return;
Navigator.maybeOf(context)?.pop(path);
} }
} }

View file

@ -250,7 +250,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
); );
if (directory == null) return; if (directory == null) return;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
_pickAlbum(directory); _pickAlbum(directory);
@ -274,7 +274,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
); );
if (details == null) return; if (details == null) return;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
await vaults.create(details); await vaults.create(details);

View file

@ -13,7 +13,7 @@ Future<void> showSelectionDialog<T>({
builder: builder, builder: builder,
routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName),
); );
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
if (value != null) { if (value != null) {
onSelection(value); onSelection(value);

View file

@ -186,7 +186,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
}, },
routeSettings: const RouteSettings(name: TileViewDialog.routeName), routeSettings: const RouteSettings(name: TileViewDialog.routeName),
); );
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
if (value != null && initialValue != value) { if (value != null && initialValue != value) {
sortFactor = value.$1!; sortFactor = value.$1!;
@ -199,6 +199,11 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
void _createAlbum(BuildContext context, {required bool locked}) async { void _createAlbum(BuildContext context, {required bool locked}) async {
final l10n = context.l10n; final l10n = context.l10n;
final source = context.read<CollectionSource>(); final source = context.read<CollectionSource>();
// get navigator beforehand because
// local context may be deactivated when action is triggered after navigation
final navigator = Navigator.maybeOf(context);
late final String? directory; late final String? directory;
if (locked) { if (locked) {
if (!await showSkippableConfirmationDialog( if (!await showSkippableConfirmationDialog(
@ -226,36 +231,46 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
); );
if (directory == null) return; if (directory == null) return;
}
source.createAlbum(directory);
// wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
}
final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory)); final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory));
// get navigator beforehand because
final albumExists = source.rawAlbums.contains(directory);
if (albumExists) {
// album already exists, so we just need to highlight it
await _showAlbum(navigator, filter);
} else {
// create the album and mark it as new
source.createAlbum(directory);
final showAction = SnackBarAction(
label: l10n.showButtonLabel,
onPressed: () => _showAlbum(navigator, filter),
);
showFeedback(context, FeedbackType.info, l10n.genericSuccessFeedback, showAction);
}
}
Future<void> _showAlbum(NavigatorState? navigator, AlbumFilter filter) async {
// local context may be deactivated when action is triggered after navigation // local context may be deactivated when action is triggered after navigation
final navigator = Navigator.maybeOf(context); if (navigator != null) {
final showAction = SnackBarAction( final context = navigator.context;
label: l10n.showButtonLabel, final highlightInfo = context.read<HighlightInfo>();
onPressed: () async { if (context.currentRouteName == AlbumListPage.routeName) {
// local context may be deactivated when action is triggered after navigation highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter);
if (navigator != null) { } else {
final context = navigator.context; highlightInfo.set(filter);
final highlightInfo = context.read<HighlightInfo>(); await navigator.pushAndRemoveUntil(
if (context.currentRouteName == AlbumListPage.routeName) { MaterialPageRoute(
highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); settings: const RouteSettings(name: AlbumListPage.routeName),
} else { builder: (_) => const AlbumListPage(),
highlightInfo.set(filter); ),
await navigator.pushAndRemoveUntil( (route) => false,
MaterialPageRoute( );
settings: const RouteSettings(name: AlbumListPage.routeName), }
builder: (_) => const AlbumListPage(), }
),
(route) => false,
);
}
}
},
);
showFeedback(context, FeedbackType.info, l10n.genericSuccessFeedback, showAction);
} }
Future<void> _delete(BuildContext context) async { Future<void> _delete(BuildContext context) async {

View file

@ -249,7 +249,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
}, },
routeSettings: const RouteSettings(name: TileViewDialog.routeName), routeSettings: const RouteSettings(name: TileViewDialog.routeName),
); );
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
if (value != null && initialValue != value) { if (value != null && initialValue != value) {
sortFactor = value.$1!; sortFactor = value.$1!;

View file

@ -31,7 +31,7 @@ class LocaleTile extends StatelessWidget {
builder: (context) => const LocaleSelectionPage(), builder: (context) => const LocaleSelectionPage(),
), ),
); );
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.pageTransitionLoose * timeDilation); await Future.delayed(ADurations.pageTransitionLoose * timeDilation);
if (value != null) { if (value != null) {
settings.locale = value == systemLocaleOption ? null : value; settings.locale = value == systemLocaleOption ? null : value;

View file

@ -479,7 +479,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
); );
if (newName == null || newName.isEmpty || newName == targetEntry.filenameWithoutExtension) return; if (newName == null || newName.isEmpty || newName == targetEntry.filenameWithoutExtension) return;
// wait for the dialog to hide as applying the change may block the UI // wait for the dialog to hide
await Future.delayed(ADurations.dialogTransitionLoose * timeDilation); await Future.delayed(ADurations.dialogTransitionLoose * timeDilation);
await rename( await rename(
context, context,