#1272 albums: improved album creation feedback
This commit is contained in:
parent
e071ff299a
commit
339a039dd6
13 changed files with 121 additions and 82 deletions
|
@ -423,6 +423,7 @@
|
|||
|
||||
"newAlbumDialogTitle": "New Album",
|
||||
"newAlbumDialogNameLabel": "Album name",
|
||||
"newAlbumDialogAlbumAlreadyExistsHelper": "Album already exists",
|
||||
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists",
|
||||
"newAlbumDialogStorageLabel": "Storage:",
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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!;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue