diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 95df01843..781a00af8 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -423,6 +423,7 @@ "newAlbumDialogTitle": "New Album", "newAlbumDialogNameLabel": "Album name", + "newAlbumDialogAlbumAlreadyExistsHelper": "Album already exists", "newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists", "newAlbumDialogStorageLabel": "Storage:", diff --git a/lib/model/source/album.dart b/lib/model/source/album.dart index 859d4ad5c..b30b693d8 100644 --- a/lib/model/source/album.dart +++ b/lib/model/source/album.dart @@ -147,8 +147,10 @@ mixin AlbumMixin on SourceBase { // new albums void createAlbum(String directory) { - _newAlbums.add(directory); - addDirectories(albums: {directory}); + if (!_directories.contains(directory)) { + _newAlbums.add(directory); + addDirectories(albums: {directory}); + } } void renameNewAlbum(String source, String destination) { diff --git a/lib/model/source/media_store_source.dart b/lib/model/source/media_store_source.dart index 88acbcaed..c2a85e28f 100644 --- a/lib/model/source/media_store_source.dart +++ b/lib/model/source/media_store_source.dart @@ -63,7 +63,7 @@ class MediaStoreSource extends CollectionSource { if (currentTimeZoneOffset != null) { final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis; 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.clearCatalogMetadata(); settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset; diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 12b277132..637fbbbf8 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -19,6 +19,11 @@ class AndroidFileUtils { // cf https://developer.android.com/reference/android/provider/MediaStore#VOLUME_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 mediaUriPathRoots = {'/$externalVolume/images/', '/$externalVolume/video/'}; @@ -43,12 +48,13 @@ class AndroidFileUtils { await _initStorageVolumes(); vaultRoot = await storageService.getVaultRoot(); primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator; - // standard - dcimPath = pContext.join(primaryStorage, 'DCIM'); + // standard dirs + dcimPath = pContext.join(primaryStorage, standardDirDcim); // effective download path may have a different case - downloadPath = pContext.join(primaryStorage, 'Download').toLowerCase(); - moviesPath = pContext.join(primaryStorage, 'Movies'); - picturesPath = pContext.join(primaryStorage, 'Pictures'); + downloadPath = pContext.join(primaryStorage, standardDirDownloads).toLowerCase(); + moviesPath = pContext.join(primaryStorage, standardDirMovies); + picturesPath = pContext.join(primaryStorage, standardDirPictures); + // custom dirs avesVideoCapturesPath = pContext.join(dcimPath, 'Video Captures'); videoCapturesPaths = { // from Samsung diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 388ac7b7a..aae93bac0 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -689,7 +689,7 @@ class _CollectionAppBarState extends State with SingleTickerPr }, 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); if (value != null && initialValue != value) { settings.collectionSortFactor = value.$1!; diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 72fe2cf4a..fd9956bc4 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -512,7 +512,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); 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); return supported; } diff --git a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart index 9af1c8ef5..af47176c0 100644 --- a/lib/widgets/dialogs/filter_editors/create_album_dialog.dart +++ b/lib/widgets/dialogs/filter_editors/create_album_dialog.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:aves/model/source/collection_source.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.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:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class CreateAlbumDialog extends StatefulWidget { static const routeName = '/dialog/create_album'; @@ -23,7 +25,8 @@ class _CreateAlbumDialogState extends State { final ScrollController _scrollController = ScrollController(); final TextEditingController _nameController = TextEditingController(); final FocusNode _nameFieldFocusNode = FocusNode(); - final ValueNotifier _existsNotifier = ValueNotifier(false); + final ValueNotifier _directoryExistsNotifier = ValueNotifier(false); + final ValueNotifier _albumExistsNotifier = ValueNotifier(false); final ValueNotifier _isValidNotifier = ValueNotifier(false); late Set _allVolumes; late StorageVolume? _primaryVolume, _selectedVolume; @@ -43,13 +46,14 @@ class _CreateAlbumDialogState extends State { _nameController.dispose(); _nameFieldFocusNode.removeListener(_onFocus); _nameFieldFocusNode.dispose(); - _existsNotifier.dispose(); + _directoryExistsNotifier.dispose(); _isValidNotifier.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final l10n = context.l10n; const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); final volumeTiles = []; @@ -61,7 +65,7 @@ class _CreateAlbumDialogState extends State { volumeTiles.addAll([ Padding( padding: contentHorizontalPadding + const EdgeInsets.only(top: 20), - child: Text(context.l10n.newAlbumDialogStorageLabel), + child: Text(l10n.newAlbumDialogStorageLabel), ), ...primaryVolumes.map((volume) => _buildVolumeTile(context, volume)), ...otherVolumes.map((volume) => _buildVolumeTile(context, volume)), @@ -70,21 +74,27 @@ class _CreateAlbumDialogState extends State { } return AvesDialog( - title: context.l10n.newAlbumDialogTitle, + title: l10n.newAlbumDialogTitle, scrollController: _scrollController, scrollableContent: [ ...volumeTiles, Padding( padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8), - child: ValueListenableBuilder( - valueListenable: _existsNotifier, - builder: (context, exists, child) { + child: AnimatedBuilder( + animation: Listenable.merge([_albumExistsNotifier, _directoryExistsNotifier]), + builder: (context, child) { + var helperText = ''; + if (_albumExistsNotifier.value) { + helperText = l10n.newAlbumDialogAlbumAlreadyExistsHelper; + } else if (_directoryExistsNotifier.value) { + helperText = l10n.newAlbumDialogNameLabelAlreadyExistsHelper; + } return TextField( controller: _nameController, focusNode: _nameFieldFocusNode, decoration: InputDecoration( - labelText: context.l10n.newAlbumDialogNameLabel, - helperText: exists ? context.l10n.newAlbumDialogNameLabelAlreadyExistsHelper : '', + labelText: l10n.newAlbumDialogNameLabel, + helperText: helperText, ), autofocus: _allVolumes.length == 1, onChanged: (_) => _validate(), @@ -96,11 +106,16 @@ class _CreateAlbumDialogState extends State { actions: [ const CancelButton(), ValueListenableBuilder( - valueListenable: _isValidNotifier, - builder: (context, isValid, child) { - return TextButton( - onPressed: isValid ? () => _submit(context) : null, - child: Text(context.l10n.createAlbumButtonLabel), + valueListenable: _albumExistsNotifier, + builder: (context, albumExists, child) { + return ValueListenableBuilder( + valueListenable: _isValidNotifier, + 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 { ); } - String _sanitize(String input) => input.trim(); + Future _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 _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 _submit(BuildContext context) async { - if (!_isValidNotifier.value) return; - - final newName = _sanitize(_nameController.text); - final albumPath = _buildAlbumPath(newName); 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 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) { String? resolvedSegment; final directory = Directory(resolvedPath); @@ -184,6 +183,22 @@ class _CreateAlbumDialogState extends State { } resolvedPath = pContext.join(resolvedPath, resolvedSegment ?? targetSegment); } - Navigator.maybeOf(context)?.pop(resolvedPath); + + return resolvedPath; + } + + Future _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().rawAlbums.contains(path); + } + + Future _submit(BuildContext context) async { + final path = await _buildAlbumPath(); + if (path == null) return; + + Navigator.maybeOf(context)?.pop(path); } } diff --git a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart index 40b1682df..66fd7a2b4 100644 --- a/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart +++ b/lib/widgets/dialogs/pick_dialogs/album_pick_page.dart @@ -250,7 +250,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { ); 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); _pickAlbum(directory); @@ -274,7 +274,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> { ); 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 vaults.create(details); diff --git a/lib/widgets/dialogs/selection_dialogs/common.dart b/lib/widgets/dialogs/selection_dialogs/common.dart index 2f702780b..fe34e4f97 100644 --- a/lib/widgets/dialogs/selection_dialogs/common.dart +++ b/lib/widgets/dialogs/selection_dialogs/common.dart @@ -13,7 +13,7 @@ Future showSelectionDialog({ builder: builder, 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); if (value != null) { onSelection(value); diff --git a/lib/widgets/filter_grids/common/action_delegates/album_set.dart b/lib/widgets/filter_grids/common/action_delegates/album_set.dart index aff3e0599..3b49cfb59 100644 --- a/lib/widgets/filter_grids/common/action_delegates/album_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/album_set.dart @@ -186,7 +186,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with }, 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); if (value != null && initialValue != value) { sortFactor = value.$1!; @@ -199,6 +199,11 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with void _createAlbum(BuildContext context, {required bool locked}) async { final l10n = context.l10n; final source = context.read(); + + // get navigator beforehand because + // local context may be deactivated when action is triggered after navigation + final navigator = Navigator.maybeOf(context); + late final String? directory; if (locked) { if (!await showSkippableConfirmationDialog( @@ -226,36 +231,46 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate with routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName), ); 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)); - // 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 _showAlbum(NavigatorState? navigator, AlbumFilter filter) async { // local context may be deactivated when action is triggered after navigation - final navigator = Navigator.maybeOf(context); - final showAction = SnackBarAction( - label: l10n.showButtonLabel, - onPressed: () async { - // local context may be deactivated when action is triggered after navigation - if (navigator != null) { - final context = navigator.context; - final highlightInfo = context.read(); - if (context.currentRouteName == AlbumListPage.routeName) { - highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); - } else { - highlightInfo.set(filter); - await navigator.pushAndRemoveUntil( - MaterialPageRoute( - settings: const RouteSettings(name: AlbumListPage.routeName), - builder: (_) => const AlbumListPage(), - ), - (route) => false, - ); - } - } - }, - ); - showFeedback(context, FeedbackType.info, l10n.genericSuccessFeedback, showAction); + if (navigator != null) { + final context = navigator.context; + final highlightInfo = context.read(); + if (context.currentRouteName == AlbumListPage.routeName) { + highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter); + } else { + highlightInfo.set(filter); + await navigator.pushAndRemoveUntil( + MaterialPageRoute( + settings: const RouteSettings(name: AlbumListPage.routeName), + builder: (_) => const AlbumListPage(), + ), + (route) => false, + ); + } + } } Future _delete(BuildContext context) async { diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index 4d6bc1806..a6a6cb814 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -249,7 +249,7 @@ abstract class ChipSetActionDelegate with FeedbackMi }, 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); if (value != null && initialValue != value) { sortFactor = value.$1!; diff --git a/lib/widgets/settings/language/locale_tile.dart b/lib/widgets/settings/language/locale_tile.dart index 4a2e50876..2469286a7 100644 --- a/lib/widgets/settings/language/locale_tile.dart +++ b/lib/widgets/settings/language/locale_tile.dart @@ -31,7 +31,7 @@ class LocaleTile extends StatelessWidget { 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); if (value != null) { settings.locale = value == systemLocaleOption ? null : value; diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index a53ae8dd0..1d2cba339 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -479,7 +479,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix ); 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 rename( context,