#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",
"newAlbumDialogNameLabel": "Album name",
"newAlbumDialogAlbumAlreadyExistsHelper": "Album already exists",
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists",
"newAlbumDialogStorageLabel": "Storage:",

View file

@ -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) {

View file

@ -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;

View file

@ -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

View file

@ -689,7 +689,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> 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!;

View file

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

View file

@ -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<CreateAlbumDialog> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _nameController = TextEditingController();
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);
late Set<StorageVolume> _allVolumes;
late StorageVolume? _primaryVolume, _selectedVolume;
@ -43,13 +46,14 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
_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 = <Widget>[];
@ -61,7 +65,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
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<CreateAlbumDialog> {
}
return AvesDialog(
title: context.l10n.newAlbumDialogTitle,
title: l10n.newAlbumDialogTitle,
scrollController: _scrollController,
scrollableContent: [
...volumeTiles,
Padding(
padding: contentHorizontalPadding + const EdgeInsets.only(bottom: 8),
child: ValueListenableBuilder<bool>(
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<CreateAlbumDialog> {
actions: [
const CancelButton(),
ValueListenableBuilder<bool>(
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<bool>(
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<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;
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<CreateAlbumDialog> {
}
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;
// 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);

View file

@ -13,7 +13,7 @@ Future<void> showSelectionDialog<T>({
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);

View file

@ -186,7 +186,7 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> 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<AlbumFilter> with
void _createAlbum(BuildContext context, {required bool locked}) async {
final l10n = context.l10n;
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;
if (locked) {
if (!await showSkippableConfirmationDialog(
@ -226,36 +231,46 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> 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<void> _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<HighlightInfo>();
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<HighlightInfo>();
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<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),
);
// 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!;

View file

@ -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;

View file

@ -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,