diff --git a/packages/neon/neon_files/lib/l10n/en.arb b/packages/neon/neon_files/lib/l10n/en.arb index 5ec332df087..671aa881038 100644 --- a/packages/neon/neon_files/lib/l10n/en.arb +++ b/packages/neon/neon_files/lib/l10n/en.arb @@ -1,7 +1,5 @@ { "@@locale": "en", - "actionYes": "Yes", - "actionNo": "No", "actionDelete": "Delete", "actionRename": "Rename", "actionMove": "Move", @@ -43,6 +41,8 @@ } } }, + "actionDeleteTitle": "Permanently delete?", + "filesChooseCreate": "Add to Nextcloud", "folderCreate": "Create folder", "folderName": "Folder name", "folderRename": "Rename folder", diff --git a/packages/neon/neon_files/lib/l10n/localizations.dart b/packages/neon/neon_files/lib/l10n/localizations.dart index 6db0be3ca5d..1ce9f9ac30d 100644 --- a/packages/neon/neon_files/lib/l10n/localizations.dart +++ b/packages/neon/neon_files/lib/l10n/localizations.dart @@ -89,18 +89,6 @@ abstract class FilesLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; - /// No description provided for @actionYes. - /// - /// In en, this message translates to: - /// **'Yes'** - String get actionYes; - - /// No description provided for @actionNo. - /// - /// In en, this message translates to: - /// **'No'** - String get actionNo; - /// No description provided for @actionDelete. /// /// In en, this message translates to: @@ -185,6 +173,18 @@ abstract class FilesLocalizations { /// **'Are you sure you want to download a file that is bigger than {warningSize} ({actualSize})?'** String downloadConfirmSizeWarning(String warningSize, String actualSize); + /// No description provided for @actionDeleteTitle. + /// + /// In en, this message translates to: + /// **'Permanently delete?'** + String get actionDeleteTitle; + + /// No description provided for @filesChooseCreate. + /// + /// In en, this message translates to: + /// **'Add to Nextcloud'** + String get filesChooseCreate; + /// No description provided for @folderCreate. /// /// In en, this message translates to: diff --git a/packages/neon/neon_files/lib/l10n/localizations_en.dart b/packages/neon/neon_files/lib/l10n/localizations_en.dart index 397b08f8860..1513b83015f 100644 --- a/packages/neon/neon_files/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_files/lib/l10n/localizations_en.dart @@ -4,12 +4,6 @@ import 'localizations.dart'; class FilesLocalizationsEn extends FilesLocalizations { FilesLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get actionYes => 'Yes'; - - @override - String get actionNo => 'No'; - @override String get actionDelete => 'Delete'; @@ -58,6 +52,12 @@ class FilesLocalizationsEn extends FilesLocalizations { return 'Are you sure you want to download a file that is bigger than $warningSize ($actualSize)?'; } + @override + String get actionDeleteTitle => 'Permanently delete?'; + + @override + String get filesChooseCreate => 'Add to Nextcloud'; + @override String get folderCreate => 'Create folder'; diff --git a/packages/neon/neon_files/lib/src/blocs/browser.dart b/packages/neon/neon_files/lib/src/blocs/browser.dart index b446e9cabcf..47344aa30b8 100644 --- a/packages/neon/neon_files/lib/src/blocs/browser.dart +++ b/packages/neon/neon_files/lib/src/blocs/browser.dart @@ -8,17 +8,33 @@ import 'package:neon_framework/utils.dart'; import 'package:nextcloud/webdav.dart'; import 'package:rxdart/rxdart.dart'; +/// Mode to operate the `FilesBrowserView` in. +enum FilesBrowserMode { + /// Default file browser mode. + /// + /// When a file is selected it will be opened or downloaded. + browser, + + /// Select directory. + selectDirectory, + + /// Do not show file actions. + noActions, +} + sealed class FilesBrowserBloc implements InteractiveBloc { @internal factory FilesBrowserBloc( final FilesOptions options, final Account account, { final PathUri? initialPath, + final FilesBrowserMode? mode, }) => _FilesBrowserBloc( options, account, initialPath: initialPath, + mode: mode, ); void setPath(final PathUri uri); @@ -30,18 +46,25 @@ sealed class FilesBrowserBloc implements InteractiveBloc { BehaviorSubject get uri; FilesOptions get options; + + /// Mode to operate the `FilesBrowserView` in. + FilesBrowserMode get mode; } class _FilesBrowserBloc extends InteractiveBloc implements FilesBrowserBloc { _FilesBrowserBloc( this.options, this.account, { - final PathUri? initialPath, - }) { - if (initialPath != null) { - uri.add(initialPath); + this.initialPath, + final FilesBrowserMode? mode, + }) : mode = mode ?? FilesBrowserMode.browser { + final parent = initialPath?.parent; + if (parent != null) { + uri.add(parent); } + options.showHiddenFilesOption.addListener(refresh); + unawaited(refresh()); } @@ -49,8 +72,15 @@ class _FilesBrowserBloc extends InteractiveBloc implements FilesBrowserBloc { final FilesOptions options; final Account account; + @override + final FilesBrowserMode mode; + + final PathUri? initialPath; + @override void dispose() { + options.showHiddenFilesOption.removeListener(refresh); + unawaited(files.close()); unawaited(uri.close()); super.dispose(); @@ -80,7 +110,28 @@ class _FilesBrowserBloc extends InteractiveBloc implements FilesBrowserBloc { ), depth: WebDavDepth.one, ), - (final response) => response.toWebDavFiles().sublist(1), + (final response) { + final unwrapped = response.toWebDavFiles().sublist(1); + + return unwrapped.where((final file) { + // Do not show files when selecting a directory + if (mode == FilesBrowserMode.selectDirectory && !file.isDirectory) { + return false; + } + + // Do not show itself when selecting a directory + if (mode == FilesBrowserMode.selectDirectory && initialPath == file.path) { + return false; + } + + // Do not show hidden files unless the option is enabled + if (!options.showHiddenFilesOption.value && file.isHidden) { + return false; + } + + return true; + }).toList(); + }, emitEmptyCache: true, ); } diff --git a/packages/neon/neon_files/lib/src/blocs/files.dart b/packages/neon/neon_files/lib/src/blocs/files.dart index 990ad812ab1..eb3cea2218a 100644 --- a/packages/neon/neon_files/lib/src/blocs/files.dart +++ b/packages/neon/neon_files/lib/src/blocs/files.dart @@ -56,7 +56,7 @@ sealed class FilesBloc implements InteractiveBloc { FilesBrowserBloc get browser; - FilesBrowserBloc getNewFilesBrowserBloc({final PathUri? initialUri}); + FilesBrowserBloc getNewFilesBrowserBloc({final PathUri? initialUri, final FilesBrowserMode? mode}); } class _FilesBloc extends InteractiveBloc implements FilesBloc { @@ -235,13 +235,12 @@ class _FilesBloc extends InteractiveBloc implements FilesBloc { } @override - FilesBrowserBloc getNewFilesBrowserBloc({ - final PathUri? initialUri, - }) => + FilesBrowserBloc getNewFilesBrowserBloc({final PathUri? initialUri, final FilesBrowserMode? mode}) => FilesBrowserBloc( options, account, initialPath: initialUri, + mode: mode, ); void downloadParallelismListener() { diff --git a/packages/neon/neon_files/lib/src/dialogs/choose_create.dart b/packages/neon/neon_files/lib/src/dialogs/choose_create.dart deleted file mode 100644 index af8e0fc91ef..00000000000 --- a/packages/neon/neon_files/lib/src/dialogs/choose_create.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:filesize/filesize.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:neon_files/l10n/localizations.dart'; -import 'package:neon_files/src/blocs/files.dart'; -import 'package:neon_files/src/dialogs/create_folder.dart'; -import 'package:neon_framework/platform.dart'; -import 'package:neon_framework/utils.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:nextcloud/webdav.dart'; -import 'package:path/path.dart' as p; -import 'package:universal_io/io.dart'; - -class FilesChooseCreateDialog extends StatefulWidget { - const FilesChooseCreateDialog({ - required this.bloc, - required this.basePath, - super.key, - }); - - final FilesBloc bloc; - final PathUri basePath; - - @override - State createState() => _FilesChooseCreateDialogState(); -} - -class _FilesChooseCreateDialogState extends State { - Future uploadFromPick(final FileType type) async { - final result = await FilePicker.platform.pickFiles( - allowMultiple: true, - type: type, - ); - if (result != null) { - for (final file in result.files) { - await upload(File(file.path!)); - } - } - } - - Future upload(final File file) async { - final sizeWarning = widget.bloc.options.uploadSizeWarning.value; - if (sizeWarning != null) { - final stat = file.statSync(); - if (stat.size > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).uploadConfirmSizeWarning( - filesize(sizeWarning), - filesize(stat.size), - ), - ))) { - return; - } - } - } - widget.bloc.uploadFile( - widget.basePath.join(PathUri.parse(p.basename(file.path))), - file.path, - ); - } - - @override - Widget build(final BuildContext context) => NeonDialog( - children: [ - ListTile( - leading: Icon( - MdiIcons.filePlus, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(FilesLocalizations.of(context).uploadFiles), - onTap: () async { - await uploadFromPick(FileType.any); - - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - ListTile( - leading: Icon( - MdiIcons.fileImagePlus, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(FilesLocalizations.of(context).uploadImages), - onTap: () async { - await uploadFromPick(FileType.image); - - if (mounted) { - Navigator.of(context).pop(); - } - }, - ), - if (NeonPlatform.instance.canUseCamera) ...[ - ListTile( - leading: Icon( - MdiIcons.cameraPlus, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(FilesLocalizations.of(context).uploadCamera), - onTap: () async { - Navigator.of(context).pop(); - - final picker = ImagePicker(); - final result = await picker.pickImage(source: ImageSource.camera); - if (result != null) { - await upload(File(result.path)); - } - }, - ), - ], - ListTile( - leading: Icon( - MdiIcons.folderPlus, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(FilesLocalizations.of(context).folderCreate), - onTap: () async { - Navigator.of(context).pop(); - - final result = await showDialog( - context: context, - builder: (final context) => const FilesCreateFolderDialog(), - ); - if (result != null) { - widget.bloc.browser.createFolder(widget.basePath.join(PathUri.parse(result))); - } - }, - ), - ], - ); -} diff --git a/packages/neon/neon_files/lib/src/dialogs/choose_folder.dart b/packages/neon/neon_files/lib/src/dialogs/choose_folder.dart deleted file mode 100644 index 320d7a57d45..00000000000 --- a/packages/neon/neon_files/lib/src/dialogs/choose_folder.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_files/l10n/localizations.dart'; -import 'package:neon_files/src/blocs/browser.dart'; -import 'package:neon_files/src/blocs/files.dart'; -import 'package:neon_files/src/dialogs/create_folder.dart'; -import 'package:neon_files/src/widgets/browser_view.dart'; -import 'package:nextcloud/webdav.dart'; - -class FilesChooseFolderDialog extends StatelessWidget { - const FilesChooseFolderDialog({ - required this.bloc, - required this.filesBloc, - required this.originalPath, - super.key, - }); - - final FilesBrowserBloc bloc; - final FilesBloc filesBloc; - - final PathUri originalPath; - - @override - Widget build(final BuildContext context) => AlertDialog( - title: Text(FilesLocalizations.of(context).folderChoose), - contentPadding: EdgeInsets.zero, - content: SizedBox( - width: double.maxFinite, - child: Column( - children: [ - Expanded( - child: FilesBrowserView( - bloc: bloc, - filesBloc: filesBloc, - mode: FilesBrowserMode.selectDirectory, - ), - ), - StreamBuilder( - stream: bloc.uri, - builder: (final context, final uriSnapshot) => uriSnapshot.hasData - ? Container( - margin: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - onPressed: () async { - final result = await showDialog( - context: context, - builder: (final context) => const FilesCreateFolderDialog(), - ); - if (result != null) { - bloc.createFolder(uriSnapshot.requireData.join(PathUri.parse(result))); - } - }, - child: Text(FilesLocalizations.of(context).folderCreate), - ), - ElevatedButton( - onPressed: originalPath != uriSnapshot.requireData - ? () => Navigator.of(context).pop(uriSnapshot.requireData) - : null, - child: Text(FilesLocalizations.of(context).folderChoose), - ), - ], - ), - ) - : const SizedBox(), - ), - ], - ), - ), - ); -} diff --git a/packages/neon/neon_files/lib/src/dialogs/create_folder.dart b/packages/neon/neon_files/lib/src/dialogs/create_folder.dart deleted file mode 100644 index dd9f4e40512..00000000000 --- a/packages/neon/neon_files/lib/src/dialogs/create_folder.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_files/l10n/localizations.dart'; -import 'package:neon_framework/utils.dart'; -import 'package:neon_framework/widgets.dart'; - -class FilesCreateFolderDialog extends StatefulWidget { - const FilesCreateFolderDialog({ - super.key, - }); - - @override - State createState() => _FilesCreateFolderDialogState(); -} - -class _FilesCreateFolderDialogState extends State { - final formKey = GlobalKey(); - - final controller = TextEditingController(); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop(controller.text); - } - } - - @override - Widget build(final BuildContext context) => NeonDialog( - title: Text(FilesLocalizations.of(context).folderCreate), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextFormField( - controller: controller, - decoration: InputDecoration( - hintText: FilesLocalizations.of(context).folderName, - ), - autofocus: true, - validator: (final input) => validateNotEmpty(context, input), - onFieldSubmitted: (final _) { - submit(); - }, - ), - ElevatedButton( - onPressed: submit, - child: Text(FilesLocalizations.of(context).folderCreate), - ), - ], - ), - ), - ], - ); -} diff --git a/packages/neon/neon_files/lib/src/pages/details.dart b/packages/neon/neon_files/lib/src/pages/details.dart index 6ff5b502c28..5199a9f8283 100644 --- a/packages/neon/neon_files/lib/src/pages/details.dart +++ b/packages/neon/neon_files/lib/src/pages/details.dart @@ -4,6 +4,7 @@ import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/src/blocs/files.dart'; import 'package:neon_files/src/models/file_details.dart'; import 'package:neon_files/src/widgets/file_preview.dart'; +import 'package:neon_framework/l10n/localizations.dart'; class FilesDetailsPage extends StatelessWidget { const FilesDetailsPage({ @@ -61,8 +62,8 @@ class FilesDetailsPage extends StatelessWidget { }, if (details.isFavorite != null) ...{ FilesLocalizations.of(context).detailsIsFavorite: details.isFavorite! - ? FilesLocalizations.of(context).actionYes - : FilesLocalizations.of(context).actionNo, + ? NeonLocalizations.of(context).actionYes + : NeonLocalizations.of(context).actionNo, }, }.entries) ...[ DataRow( diff --git a/packages/neon/neon_files/lib/src/pages/main.dart b/packages/neon/neon_files/lib/src/pages/main.dart index d026f0e650a..f774cc28b33 100644 --- a/packages/neon/neon_files/lib/src/pages/main.dart +++ b/packages/neon/neon_files/lib/src/pages/main.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/src/blocs/files.dart'; -import 'package:neon_files/src/dialogs/choose_create.dart'; +import 'package:neon_files/src/utils/dialog.dart'; import 'package:neon_files/src/widgets/browser_view.dart'; import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; @@ -35,15 +35,7 @@ class _FilesMainPageState extends State { filesBloc: bloc, ), floatingActionButton: FloatingActionButton( - onPressed: () async { - await showDialog( - context: context, - builder: (final context) => FilesChooseCreateDialog( - bloc: bloc, - basePath: bloc.browser.uri.value, - ), - ); - }, + onPressed: () async => showFilesCreateModal(context), tooltip: FilesLocalizations.of(context).uploadFiles, child: const Icon(Icons.add), ), diff --git a/packages/neon/neon_files/lib/src/utils/dialog.dart b/packages/neon/neon_files/lib/src/utils/dialog.dart new file mode 100644 index 00000000000..16f1a6d92f7 --- /dev/null +++ b/packages/neon/neon_files/lib/src/utils/dialog.dart @@ -0,0 +1,135 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:neon_files/l10n/localizations.dart'; +import 'package:neon_files/src/blocs/browser.dart'; +import 'package:neon_files/src/blocs/files.dart'; +import 'package:neon_files/src/models/file_details.dart'; +import 'package:neon_files/src/widgets/dialog.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:nextcloud/nextcloud.dart'; + +/// Displays a [FilesCreateFolderDialog] for creating a new folder. +/// +/// Returns a future with the folder name split by `/`. +Future showFolderCreateDialog({ + required final BuildContext context, +}) => + showAdaptiveDialog( + context: context, + builder: (final context) => const FilesCreateFolderDialog(), + ); + +/// Displays a [NeonConfirmationDialog] to confirm downloading a file larger +/// than the configured limit. +/// +/// Returns a future whether the action has been accepted. +Future showDownloadConfirmationDialog( + final BuildContext context, + final int warningSize, + final int actualSize, +) async => + await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + title: FilesLocalizations.of(context).optionsDownloadSizeWarning, + content: Text( + FilesLocalizations.of(context).downloadConfirmSizeWarning( + filesize(warningSize), + filesize(actualSize), + ), + ), + ), + ) ?? + false; + +/// Displays a [NeonConfirmationDialog] to confirm uploading a file larger than +/// the configured limit. +/// +/// Returns a future whether the action has been accepted. +Future showUploadConfirmationDialog( + final BuildContext context, + final int warningSize, + final int actualSize, +) async => + await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + title: FilesLocalizations.of(context).optionsUploadSizeWarning, + content: Text( + FilesLocalizations.of(context).uploadConfirmSizeWarning( + filesize(warningSize), + filesize(actualSize), + ), + ), + ), + ) ?? + false; + +/// Displays a [FilesChooseFolderDialog] to choose a new location for a file with the given [details]. +/// +/// Returns a future with the new location. +Future showChooseFolderDialog(final BuildContext context, final FileDetails details) async { + final bloc = NeonProvider.of(context); + + final originalUri = details.uri; + final b = bloc.getNewFilesBrowserBloc( + initialUri: originalUri, + mode: FilesBrowserMode.selectDirectory, + ); + + final result = await showDialog( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: bloc, + originalPath: originalUri, + ), + ); + b.dispose(); + + return result; +} + +/// Displays a [NeonConfirmationDialog] to confirm deleting a file or folder with the given [details]. +/// +/// Returns a future whether the action has been accepted. +Future showDeleteConfirmationDialog(final BuildContext context, final FileDetails details) async => + await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + title: FilesLocalizations.of(context).actionDeleteTitle, + icon: const Icon(Icons.delete_outlined), + content: Text( + details.isDirectory + ? FilesLocalizations.of(context).folderDeleteConfirm(details.name) + : FilesLocalizations.of(context).fileDeleteConfirm(details.name), + ), + ), + ) ?? + false; + +/// Displays an adaptive modal to select or create a file. +Future showFilesCreateModal(final BuildContext context) { + final theme = Theme.of(context); + final bloc = NeonProvider.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return showModalBottomSheet( + context: context, + builder: (final _) => FilesChooseCreateModal(bloc: bloc), + ); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return showCupertinoModalPopup( + context: context, + builder: (final _) => FilesChooseCreateModal(bloc: bloc), + ); + } +} diff --git a/packages/neon/neon_files/lib/src/widgets/actions.dart b/packages/neon/neon_files/lib/src/widgets/actions.dart index d86f31da9cc..0c28e411530 100644 --- a/packages/neon/neon_files/lib/src/widgets/actions.dart +++ b/packages/neon/neon_files/lib/src/widgets/actions.dart @@ -1,10 +1,9 @@ -import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/src/blocs/files.dart'; -import 'package:neon_files/src/dialogs/choose_folder.dart'; import 'package:neon_files/src/models/file_details.dart'; import 'package:neon_files/src/pages/details.dart'; +import 'package:neon_files/src/utils/dialog.dart'; import 'package:neon_framework/platform.dart'; import 'package:neon_framework/utils.dart'; import 'package:nextcloud/webdav.dart'; @@ -47,7 +46,7 @@ class FileActions extends StatelessWidget { title: details.isDirectory ? FilesLocalizations.of(context).folderRename : FilesLocalizations.of(context).fileRename, - value: details.name, + initialValue: details.name, ); if (result != null) { bloc.rename(details.uri, result); @@ -56,17 +55,8 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - final originalPath = details.uri.parent!; - final b = bloc.getNewFilesBrowserBloc(initialUri: originalPath); - final result = await showDialog( - context: context, - builder: (final context) => FilesChooseFolderDialog( - bloc: b, - filesBloc: bloc, - originalPath: originalPath, - ), - ); - b.dispose(); + final result = await showChooseFolderDialog(context, details); + if (result != null) { bloc.move(details.uri, result.join(PathUri.parse(details.name))); } @@ -74,17 +64,8 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - final originalPath = details.uri.parent!; - final b = bloc.getNewFilesBrowserBloc(initialUri: originalPath); - final result = await showDialog( - context: context, - builder: (final context) => FilesChooseFolderDialog( - bloc: b, - filesBloc: bloc, - originalPath: originalPath, - ), - ); - b.dispose(); + + final result = await showChooseFolderDialog(context, details); if (result != null) { bloc.copy(details.uri, result.join(PathUri.parse(details.name))); } @@ -94,13 +75,9 @@ class FileActions extends StatelessWidget { } final sizeWarning = browserBloc.options.downloadSizeWarning.value; if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { + final decision = await showDownloadConfirmationDialog(context, sizeWarning, details.size!); + + if (!decision) { return; } } @@ -109,12 +86,8 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - if (await showConfirmationDialog( - context, - details.isDirectory - ? FilesLocalizations.of(context).folderDeleteConfirm(details.name) - : FilesLocalizations.of(context).fileDeleteConfirm(details.name), - )) { + final decision = await showDeleteConfirmationDialog(context, details); + if (decision) { bloc.delete(details.uri); } } @@ -123,13 +96,12 @@ class FileActions extends StatelessWidget { @override Widget build(final BuildContext context) => PopupMenuButton( itemBuilder: (final context) => [ - if (!details.isDirectory && NeonPlatform.instance.canUseSharing) ...[ + if (!details.isDirectory && NeonPlatform.instance.canUseSharing) PopupMenuItem( value: FilesFileAction.share, child: Text(FilesLocalizations.of(context).actionShare), ), - ], - if (details.isFavorite != null) ...[ + if (details.isFavorite != null) PopupMenuItem( value: FilesFileAction.toggleFavorite, child: Text( @@ -138,7 +110,7 @@ class FileActions extends StatelessWidget { : FilesLocalizations.of(context).addToFavorites, ), ), - ], + PopupMenuItem( value: FilesFileAction.details, child: Text(FilesLocalizations.of(context).details), @@ -156,12 +128,12 @@ class FileActions extends StatelessWidget { child: Text(FilesLocalizations.of(context).actionCopy), ), // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 - if (!details.isDirectory) ...[ + if (!details.isDirectory) PopupMenuItem( value: FilesFileAction.sync, child: Text(FilesLocalizations.of(context).actionSync), ), - ], + PopupMenuItem( value: FilesFileAction.delete, child: Text(FilesLocalizations.of(context).actionDelete), diff --git a/packages/neon/neon_files/lib/src/widgets/browser_view.dart b/packages/neon/neon_files/lib/src/widgets/browser_view.dart index 3f70414185b..a2e8da3cc35 100644 --- a/packages/neon/neon_files/lib/src/widgets/browser_view.dart +++ b/packages/neon/neon_files/lib/src/widgets/browser_view.dart @@ -13,31 +13,15 @@ import 'package:neon_framework/sort_box.dart'; import 'package:neon_framework/widgets.dart'; import 'package:nextcloud/webdav.dart'; -/// Mode to operate the [FilesBrowserView] in. -enum FilesBrowserMode { - /// Default file browser mode. - /// - /// When a file is selected it will be opened or downloaded. - browser, - - /// Select directory. - selectDirectory, - - /// Don't show file actions. - noActions, -} - class FilesBrowserView extends StatefulWidget { const FilesBrowserView({ required this.bloc, required this.filesBloc, - this.mode = FilesBrowserMode.browser, super.key, }); final FilesBrowserBloc bloc; final FilesBloc filesBloc; - final FilesBrowserMode mode; @override State createState() => _FilesBrowserViewState(); @@ -64,81 +48,63 @@ class _FilesBrowserViewState extends State { if (!uriSnapshot.hasData || !tasksSnapshot.hasData) { return const SizedBox(); } - return ValueListenableBuilder( - valueListenable: widget.bloc.options.showHiddenFilesOption, - builder: (final context, final showHiddenFiles, final _) { - final files = filesSnapshot.data?.where((final file) { - var hideFile = false; - if (widget.mode == FilesBrowserMode.selectDirectory && !file.isDirectory) { - hideFile = true; - } - if (!showHiddenFiles && file.isHidden) { - hideFile = true; - } - - return !hideFile; - }).toList(); - - return BackButtonListener( - onBackButtonPressed: () async { - final parent = uriSnapshot.requireData.parent; - if (parent != null) { - widget.bloc.setPath(parent); - return true; - } - return false; - }, - child: SortBoxBuilder( - sortBox: filesSortBox, - sortProperty: widget.bloc.options.filesSortPropertyOption, - sortBoxOrder: widget.bloc.options.filesSortBoxOrderOption, - presort: const { - (property: FilesSortProperty.isFolder, order: SortBoxOrder.ascending), - }, - input: files, - builder: (final context, final sorted) { - final uploadingTaskTiles = buildUploadTasks(tasksSnapshot.requireData, sorted); - - return NeonListView( - scrollKey: 'files-${uriSnapshot.requireData.path}', - itemCount: sorted.length, - itemBuilder: (final context, final index) { - final file = sorted[index]; - final matchingTask = tasksSnapshot.requireData.firstWhereOrNull( - (final task) => file.name == task.uri.name && widget.bloc.uri.value == task.uri.parent, - ); - - final details = matchingTask != null - ? FileDetails.fromTask( - task: matchingTask, - file: file, - ) - : FileDetails.fromWebDav( - file: file, - ); + return BackButtonListener( + onBackButtonPressed: () async { + final parent = uriSnapshot.requireData.parent; + if (parent != null) { + widget.bloc.setPath(parent); + return true; + } + return false; + }, + child: SortBoxBuilder( + sortBox: filesSortBox, + sortProperty: widget.bloc.options.filesSortPropertyOption, + sortBoxOrder: widget.bloc.options.filesSortBoxOrderOption, + presort: const { + (property: FilesSortProperty.isFolder, order: SortBoxOrder.ascending), + }, + input: filesSnapshot.data, + builder: (final context, final sorted) { + final uploadingTaskTiles = buildUploadTasks(tasksSnapshot.requireData, sorted); + + return NeonListView( + scrollKey: 'files-${uriSnapshot.requireData.path}', + itemCount: sorted.length, + itemBuilder: (final context, final index) { + final file = sorted[index]; + final matchingTask = tasksSnapshot.requireData.firstWhereOrNull( + (final task) => file.name == task.uri.name && widget.bloc.uri.value == task.uri.parent, + ); - return FileListTile( - bloc: widget.filesBloc, - browserBloc: widget.bloc, - details: details, - mode: widget.mode, - ); - }, - isLoading: filesSnapshot.isLoading, - error: filesSnapshot.error, - onRefresh: widget.bloc.refresh, - topScrollingChildren: [ - FilesBrowserNavigator( - uri: uriSnapshot.requireData, - bloc: widget.bloc, - ), - ...uploadingTaskTiles, - ], + final details = matchingTask != null + ? FileDetails.fromTask( + task: matchingTask, + file: file, + ) + : FileDetails.fromWebDav( + file: file, + ); + + return FileListTile( + bloc: widget.filesBloc, + browserBloc: widget.bloc, + details: details, ); }, - ), - ); - }, + isLoading: filesSnapshot.isLoading, + error: filesSnapshot.error, + onRefresh: widget.bloc.refresh, + topScrollingChildren: [ + FilesBrowserNavigator( + uri: uriSnapshot.requireData, + bloc: widget.bloc, + ), + ...uploadingTaskTiles, + ], + ); + }, + ), ); }, ), diff --git a/packages/neon/neon_files/lib/src/widgets/dialog.dart b/packages/neon/neon_files/lib/src/widgets/dialog.dart new file mode 100644 index 00000000000..5abf11f7b15 --- /dev/null +++ b/packages/neon/neon_files/lib/src/widgets/dialog.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:neon_files/l10n/localizations.dart'; +import 'package:neon_files/src/blocs/browser.dart'; +import 'package:neon_files/src/blocs/files.dart'; +import 'package:neon_files/src/utils/dialog.dart'; +import 'package:neon_files/src/widgets/browser_view.dart'; +import 'package:neon_framework/platform.dart'; +import 'package:neon_framework/theme.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:path/path.dart' as p; +import 'package:universal_io/io.dart'; + +/// Creates an adaptive bottom sheet to select an action to add a file. +class FilesChooseCreateModal extends StatefulWidget { + /// Creates a new add files modal. + const FilesChooseCreateModal({ + required this.bloc, + super.key, + }); + + /// The bloc of the flies client. + final FilesBloc bloc; + + @override + State createState() => _FilesChooseCreateModalState(); +} + +class _FilesChooseCreateModalState extends State { + late PathUri baseUri; + + @override + void initState() { + baseUri = widget.bloc.browser.uri.value; + + super.initState(); + } + + Future uploadFromPick(final FileType type) async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: type, + ); + + if (mounted) { + Navigator.of(context).pop(); + } + + if (result != null) { + for (final file in result.files) { + await upload(File(file.path!)); + } + } + } + + Future upload(final File file) async { + final sizeWarning = widget.bloc.options.uploadSizeWarning.value; + if (sizeWarning != null) { + final stat = file.statSync(); + if (stat.size > sizeWarning) { + final result = await showUploadConfirmationDialog(context, sizeWarning, stat.size); + + if (!result) { + return; + } + } + } + widget.bloc.uploadFile( + baseUri.join(PathUri.parse(p.basename(file.path))), + file.path, + ); + } + + Widget wrapAction({ + required final Widget icon, + required final Widget message, + required final VoidCallback onPressed, + }) { + final theme = Theme.of(context); + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return ListTile( + leading: icon, + title: message, + onTap: onPressed, + ); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoActionSheetAction( + onPressed: onPressed, + child: message, + ); + } + } + + @override + Widget build(final BuildContext context) { + final theme = Theme.of(context); + final title = FilesLocalizations.of(context).filesChooseCreate; + + final actions = [ + wrapAction( + icon: Icon( + MdiIcons.filePlus, + color: Theme.of(context).colorScheme.primary, + ), + message: Text(FilesLocalizations.of(context).uploadFiles), + onPressed: () async => uploadFromPick(FileType.any), + ), + wrapAction( + icon: Icon( + MdiIcons.fileImagePlus, + color: Theme.of(context).colorScheme.primary, + ), + message: Text(FilesLocalizations.of(context).uploadImages), + onPressed: () async => uploadFromPick(FileType.image), + ), + if (NeonPlatform.instance.canUseCamera) + wrapAction( + icon: Icon( + MdiIcons.cameraPlus, + color: Theme.of(context).colorScheme.primary, + ), + message: Text(FilesLocalizations.of(context).uploadCamera), + onPressed: () async { + Navigator.of(context).pop(); + + final picker = ImagePicker(); + final result = await picker.pickImage(source: ImageSource.camera); + if (result != null) { + await upload(File(result.path)); + } + }, + ), + wrapAction( + icon: Icon( + MdiIcons.folderPlus, + color: Theme.of(context).colorScheme.primary, + ), + message: Text(FilesLocalizations.of(context).folderCreate), + onPressed: () async { + Navigator.of(context).pop(); + + final result = await showFolderCreateDialog(context: context); + if (result != null) { + widget.bloc.browser.createFolder(baseUri.join(PathUri.parse(result))); + } + }, + ), + ]; + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + return BottomSheet( + onClosing: () {}, + builder: (final context) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + title, + style: theme.textTheme.titleLarge, + ), + ), + ), + ...actions, + ], + ), + ), + ); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoActionSheet( + actions: actions, + title: Text(title), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: Text(NeonLocalizations.of(context).actionCancel), + ), + ); + } + } +} + +/// A dialog for choosing a folder. +/// +/// This dialog is not adaptive and always builds a material design dialog. +class FilesChooseFolderDialog extends StatelessWidget { + /// Creates a new folder chooser dialog. + const FilesChooseFolderDialog({ + required this.bloc, + required this.filesBloc, + this.originalPath, + super.key, + }); + + final FilesBrowserBloc bloc; + final FilesBloc filesBloc; + + /// The initial path to start at. + final PathUri? originalPath; + + @override + Widget build(final BuildContext context) { + final dialogTheme = NeonDialogTheme.of(context); + + return StreamBuilder( + stream: bloc.uri, + builder: (final context, final uriSnapshot) { + final actions = [ + OutlinedButton( + onPressed: () async { + final result = await showFolderCreateDialog(context: context); + + if (result != null) { + bloc.createFolder(uriSnapshot.requireData.join(PathUri.parse(result))); + } + }, + child: Text( + FilesLocalizations.of(context).folderCreate, + textAlign: TextAlign.end, + ), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(uriSnapshot.data), + child: Text( + FilesLocalizations.of(context).folderChoose, + textAlign: TextAlign.end, + ), + ), + ]; + + return AlertDialog( + title: Text(FilesLocalizations.of(context).folderChoose), + content: ConstrainedBox( + constraints: dialogTheme.constraints, + child: SizedBox( + width: double.maxFinite, + child: FilesBrowserView( + bloc: bloc, + filesBloc: filesBloc, + ), + ), + ), + actions: uriSnapshot.hasData ? actions : null, + ); + }, + ); + } +} + +/// A [NeonDialog] that shows for renaming creating a new folder. +/// +/// Use `showFolderCreateDialog` to display this dialog. +/// +/// When submitted the folder name will be popped as a `String`. +class FilesCreateFolderDialog extends StatefulWidget { + /// Creates a new NeonDialog for creating a folder. + const FilesCreateFolderDialog({ + super.key, + }); + + @override + State createState() => _FilesCreateFolderDialogState(); +} + +class _FilesCreateFolderDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(controller.text); + } + } + + @override + Widget build(final BuildContext context) { + final content = Material( + child: TextFormField( + controller: controller, + decoration: InputDecoration( + hintText: FilesLocalizations.of(context).folderName, + ), + autofocus: true, + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ); + + return NeonDialog( + title: Text(FilesLocalizations.of(context).folderCreate), + content: Form( + key: formKey, + child: content, + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + FilesLocalizations.of(context).folderCreate, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart b/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart index 4b1fbf4f502..f3ac47f06bb 100644 --- a/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart +++ b/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart @@ -1,16 +1,14 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/src/blocs/browser.dart'; import 'package:neon_files/src/blocs/files.dart'; import 'package:neon_files/src/models/file_details.dart'; +import 'package:neon_files/src/utils/dialog.dart'; import 'package:neon_files/src/utils/task.dart'; import 'package:neon_files/src/widgets/actions.dart'; -import 'package:neon_files/src/widgets/browser_view.dart'; import 'package:neon_files/src/widgets/file_preview.dart'; import 'package:neon_framework/theme.dart'; -import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; class FileListTile extends StatelessWidget { @@ -18,28 +16,22 @@ class FileListTile extends StatelessWidget { required this.bloc, required this.browserBloc, required this.details, - this.mode = FilesBrowserMode.browser, super.key, }); final FilesBloc bloc; final FilesBrowserBloc browserBloc; final FileDetails details; - final FilesBrowserMode mode; Future _onTap(final BuildContext context, final FileDetails details) async { if (details.isDirectory) { browserBloc.setPath(details.uri); - } else if (mode == FilesBrowserMode.browser) { + } else if (browserBloc.mode == FilesBrowserMode.browser) { final sizeWarning = bloc.options.downloadSizeWarning.value; if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { + final decision = await showDownloadConfirmationDialog(context, sizeWarning, details.size!); + + if (!decision) { return; } } @@ -78,7 +70,7 @@ class FileListTile extends StatelessWidget { details: details, bloc: bloc, ), - trailing: !details.hasTask && mode == FilesBrowserMode.browser + trailing: !details.hasTask && browserBloc.mode == FilesBrowserMode.browser ? FileActions(details: details) : const SizedBox.square( dimension: largeIconSize, diff --git a/packages/neon/neon_news/lib/l10n/en.arb b/packages/neon/neon_news/lib/l10n/en.arb index cdafa96614b..4b520c595f2 100644 --- a/packages/neon/neon_news/lib/l10n/en.arb +++ b/packages/neon/neon_news/lib/l10n/en.arb @@ -1,8 +1,6 @@ { "@@locale": "en", - "actionClose": "Close", "actionDelete": "Delete", - "actionRemove": "Remove", "actionRename": "Rename", "actionMove": "Move", "general": "General", @@ -19,6 +17,7 @@ } } }, + "actionDeleteTitle": "Permanently delete?", "folderRename": "Rename folder", "feeds": "Feeds", "feedAdd": "Add feed", diff --git a/packages/neon/neon_news/lib/l10n/localizations.dart b/packages/neon/neon_news/lib/l10n/localizations.dart index aab0f2da1b1..8b5e07a1a81 100644 --- a/packages/neon/neon_news/lib/l10n/localizations.dart +++ b/packages/neon/neon_news/lib/l10n/localizations.dart @@ -89,24 +89,12 @@ abstract class NewsLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; - /// No description provided for @actionClose. - /// - /// In en, this message translates to: - /// **'Close'** - String get actionClose; - /// No description provided for @actionDelete. /// /// In en, this message translates to: /// **'Delete'** String get actionDelete; - /// No description provided for @actionRemove. - /// - /// In en, this message translates to: - /// **'Remove'** - String get actionRemove; - /// No description provided for @actionRename. /// /// In en, this message translates to: @@ -161,6 +149,12 @@ abstract class NewsLocalizations { /// **'Are you sure you want to delete the folder \'{name}\'?'** String folderDeleteConfirm(String name); + /// No description provided for @actionDeleteTitle. + /// + /// In en, this message translates to: + /// **'Permanently delete?'** + String get actionDeleteTitle; + /// No description provided for @folderRename. /// /// In en, this message translates to: diff --git a/packages/neon/neon_news/lib/l10n/localizations_en.dart b/packages/neon/neon_news/lib/l10n/localizations_en.dart index af1adcbecfb..3a9cae952b0 100644 --- a/packages/neon/neon_news/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_news/lib/l10n/localizations_en.dart @@ -4,15 +4,9 @@ import 'localizations.dart'; class NewsLocalizationsEn extends NewsLocalizations { NewsLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get actionClose => 'Close'; - @override String get actionDelete => 'Delete'; - @override - String get actionRemove => 'Remove'; - @override String get actionRename => 'Rename'; @@ -42,6 +36,9 @@ class NewsLocalizationsEn extends NewsLocalizations { return 'Are you sure you want to delete the folder \'$name\'?'; } + @override + String get actionDeleteTitle => 'Permanently delete?'; + @override String get folderRename => 'Rename folder'; diff --git a/packages/neon/neon_news/lib/src/dialogs/add_feed.dart b/packages/neon/neon_news/lib/src/dialogs/add_feed.dart deleted file mode 100644 index 4f8fefb2239..00000000000 --- a/packages/neon/neon_news/lib/src/dialogs/add_feed.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:neon_framework/blocs.dart'; -import 'package:neon_framework/utils.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:neon_news/l10n/localizations.dart'; -import 'package:neon_news/src/blocs/news.dart'; -import 'package:neon_news/src/widgets/folder_select.dart'; -import 'package:nextcloud/news.dart' as news; - -class NewsAddFeedDialog extends StatefulWidget { - const NewsAddFeedDialog({ - required this.bloc, - this.folderID, - super.key, - }); - - final NewsBloc bloc; - final int? folderID; - - @override - State createState() => _NewsAddFeedDialogState(); -} - -class _NewsAddFeedDialogState extends State { - final formKey = GlobalKey(); - final controller = TextEditingController(); - - news.Folder? folder; - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop((controller.text, widget.folderID ?? folder?.id)); - } - } - - @override - void initState() { - super.initState(); - - unawaited( - Clipboard.getData(Clipboard.kTextPlain).then((final clipboardContent) { - if (clipboardContent != null && clipboardContent.text != null) { - final uri = Uri.tryParse(clipboardContent.text!); - if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { - controller.text = clipboardContent.text!; - } - } - }), - ); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(final BuildContext context) => ResultBuilder>.behaviorSubject( - subject: widget.bloc.folders, - builder: (final context, final folders) => NeonDialog( - title: Text(NewsLocalizations.of(context).feedAdd), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextFormField( - autofocus: true, - controller: controller, - decoration: const InputDecoration( - hintText: 'https://...', - ), - keyboardType: TextInputType.url, - validator: (final input) => validateHttpUrl(context, input), - onFieldSubmitted: (final _) { - submit(); - }, - ), - if (widget.folderID == null) ...[ - Center( - child: NeonError( - folders.error, - onRetry: widget.bloc.refresh, - ), - ), - Center( - child: NeonLinearProgressIndicator( - visible: folders.isLoading, - ), - ), - if (folders.hasData) ...[ - NewsFolderSelect( - folders: folders.requireData, - value: folder, - onChanged: (final f) { - setState(() { - folder = f; - }); - }, - ), - ], - ], - ElevatedButton( - onPressed: folders.hasData ? submit : null, - child: Text(NewsLocalizations.of(context).feedAdd), - ), - ], - ), - ), - ], - ), - ); -} diff --git a/packages/neon/neon_news/lib/src/dialogs/create_folder.dart b/packages/neon/neon_news/lib/src/dialogs/create_folder.dart deleted file mode 100644 index 19f4169af27..00000000000 --- a/packages/neon/neon_news/lib/src/dialogs/create_folder.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/utils.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:neon_news/l10n/localizations.dart'; - -class NewsCreateFolderDialog extends StatefulWidget { - const NewsCreateFolderDialog({ - super.key, - }); - - @override - State createState() => _NewsCreateFolderDialogState(); -} - -class _NewsCreateFolderDialogState extends State { - final formKey = GlobalKey(); - - final controller = TextEditingController(); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop(controller.text); - } - } - - @override - Widget build(final BuildContext context) => NeonDialog( - title: Text(NewsLocalizations.of(context).folderCreate), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextFormField( - autofocus: true, - controller: controller, - decoration: InputDecoration( - hintText: NewsLocalizations.of(context).folderCreateName, - ), - validator: (final input) => validateNotEmpty(context, input), - onFieldSubmitted: (final _) { - submit(); - }, - ), - ElevatedButton( - onPressed: submit, - child: Text(NewsLocalizations.of(context).folderCreate), - ), - ], - ), - ), - ], - ); -} diff --git a/packages/neon/neon_news/lib/src/dialogs/feed_show_url.dart b/packages/neon/neon_news/lib/src/dialogs/feed_show_url.dart deleted file mode 100644 index 8d5dceb73c9..00000000000 --- a/packages/neon/neon_news/lib/src/dialogs/feed_show_url.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:neon_news/l10n/localizations.dart'; -import 'package:nextcloud/news.dart' as news; - -class NewsFeedShowURLDialog extends StatefulWidget { - const NewsFeedShowURLDialog({ - required this.feed, - super.key, - }); - - final news.Feed feed; - - @override - State createState() => _NewsFeedShowURLDialogState(); -} - -class _NewsFeedShowURLDialogState extends State { - @override - Widget build(final BuildContext context) => AlertDialog( - title: Text(widget.feed.url), - actions: [ - ElevatedButton( - onPressed: () async { - await Clipboard.setData( - ClipboardData( - text: widget.feed.url, - ), - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(NewsLocalizations.of(context).feedCopiedURL), - ), - ); - Navigator.of(context).pop(); - } - }, - child: Text(NewsLocalizations.of(context).feedCopyURL), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NewsLocalizations.of(context).actionClose), - ), - ], - ); -} diff --git a/packages/neon/neon_news/lib/src/dialogs/feed_update_error.dart b/packages/neon/neon_news/lib/src/dialogs/feed_update_error.dart deleted file mode 100644 index 18c12dd7f96..00000000000 --- a/packages/neon/neon_news/lib/src/dialogs/feed_update_error.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:neon_news/l10n/localizations.dart'; -import 'package:nextcloud/news.dart' as news; - -class NewsFeedUpdateErrorDialog extends StatefulWidget { - const NewsFeedUpdateErrorDialog({ - required this.feed, - super.key, - }); - - final news.Feed feed; - - @override - State createState() => _NewsFeedUpdateErrorDialogState(); -} - -class _NewsFeedUpdateErrorDialogState extends State { - @override - Widget build(final BuildContext context) => AlertDialog( - title: Text(widget.feed.lastUpdateError!), - actions: [ - ElevatedButton( - onPressed: () async { - await Clipboard.setData( - ClipboardData( - text: widget.feed.lastUpdateError!, - ), - ); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), - ), - ); - Navigator.of(context).pop(); - } - }, - child: Text(NewsLocalizations.of(context).feedCopyErrorMessage), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NewsLocalizations.of(context).actionClose), - ), - ], - ); -} diff --git a/packages/neon/neon_news/lib/src/dialogs/move_feed.dart b/packages/neon/neon_news/lib/src/dialogs/move_feed.dart deleted file mode 100644 index c966c21de02..00000000000 --- a/packages/neon/neon_news/lib/src/dialogs/move_feed.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:neon_news/l10n/localizations.dart'; -import 'package:neon_news/src/widgets/folder_select.dart'; -import 'package:nextcloud/news.dart' as news; - -class NewsMoveFeedDialog extends StatefulWidget { - const NewsMoveFeedDialog({ - required this.folders, - required this.feed, - super.key, - }); - - final List folders; - final news.Feed feed; - - @override - State createState() => _NewsMoveFeedDialogState(); -} - -class _NewsMoveFeedDialogState extends State { - final formKey = GlobalKey(); - - news.Folder? folder; - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop([folder?.id]); - } - } - - @override - Widget build(final BuildContext context) => NeonDialog( - title: Text(NewsLocalizations.of(context).feedMove), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - NewsFolderSelect( - folders: widget.folders, - value: widget.feed.folderId != null - ? widget.folders.singleWhere((final folder) => folder.id == widget.feed.folderId) - : null, - onChanged: (final f) { - setState(() { - folder = f; - }); - }, - ), - ElevatedButton( - onPressed: submit, - child: Text(NewsLocalizations.of(context).feedMove), - ), - ], - ), - ), - ], - ); -} diff --git a/packages/neon/neon_news/lib/src/widgets/dialog.dart b/packages/neon/neon_news/lib/src/widgets/dialog.dart new file mode 100644 index 00000000000..232f04be153 --- /dev/null +++ b/packages/neon/neon_news/lib/src/widgets/dialog.dart @@ -0,0 +1,376 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:neon_news/l10n/localizations.dart'; +import 'package:neon_news/src/blocs/news.dart'; +import 'package:neon_news/src/widgets/folder_select.dart'; +import 'package:nextcloud/news.dart' as news; + +/// A dialog for adding a news feed by url. +/// +/// When created a record with `(String url, int? folderId)` will be popped. +class NewsAddFeedDialog extends StatefulWidget { + /// Creates a new add feed dialog. + const NewsAddFeedDialog({ + required this.bloc, + this.folderID, + super.key, + }); + + /// The active client bloc. + final NewsBloc bloc; + + /// The initial id of the folder the feed is in. + final int? folderID; + + @override + State createState() => _NewsAddFeedDialogState(); +} + +class _NewsAddFeedDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + + news.Folder? folder; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop((controller.text, widget.folderID ?? folder?.id)); + } + } + + @override + void initState() { + super.initState(); + + unawaited( + Clipboard.getData(Clipboard.kTextPlain).then((final clipboardContent) { + if (clipboardContent != null && clipboardContent.text != null) { + final uri = Uri.tryParse(clipboardContent.text!); + if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { + controller.text = clipboardContent.text!; + } + } + }), + ); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(final BuildContext context) { + final urlField = Form( + key: formKey, + child: TextFormField( + autofocus: true, + controller: controller, + decoration: const InputDecoration( + hintText: 'https://...', + ), + keyboardType: TextInputType.url, + validator: (final input) => validateHttpUrl(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + autofillHints: const [AutofillHints.url], + ), + ); + + final folderSelector = ResultBuilder>.behaviorSubject( + subject: widget.bloc.folders, + builder: (final context, final folders) { + if (folders.hasError) { + return Center( + child: NeonError( + folders.error, + onRetry: widget.bloc.refresh, + ), + ); + } + if (!folders.hasData) { + return Center( + child: NeonLinearProgressIndicator( + visible: folders.isLoading, + ), + ); + } + + return NewsFolderSelect( + folders: folders.requireData, + value: folder, + onChanged: (final f) { + setState(() { + folder = f; + }); + }, + ); + }, + ); + + return NeonDialog( + title: Text(NewsLocalizations.of(context).feedAdd), + content: Material( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + urlField, + const SizedBox(height: 8), + folderSelector, + ], + ), + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + NewsLocalizations.of(context).feedAdd, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} + +/// A dialog for displaying the url of a news feed. +class NewsFeedShowURLDialog extends StatelessWidget { + /// Creates a new display url dialog. + const NewsFeedShowURLDialog({ + required this.feed, + super.key, + }); + + /// The feed to display the url for. + final news.Feed feed; + + @override + Widget build(final BuildContext context) => NeonDialog( + title: Text(feed.url), + actions: [ + NeonDialogAction( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + NeonLocalizations.of(context).actionClose, + textAlign: TextAlign.end, + ), + ), + NeonDialogAction( + isDefaultAction: true, + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: feed.url, + ), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(NewsLocalizations.of(context).feedCopiedURL), + ), + ); + Navigator.of(context).pop(); + } + }, + child: Text( + NewsLocalizations.of(context).feedCopyURL, + textAlign: TextAlign.end, + ), + ), + ], + ); +} + +class NewsFeedUpdateErrorDialog extends StatelessWidget { + const NewsFeedUpdateErrorDialog({ + required this.feed, + super.key, + }); + + final news.Feed feed; + + @override + Widget build(final BuildContext context) => NeonDialog( + title: Text(feed.lastUpdateError!), + actions: [ + NeonDialogAction( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + NeonLocalizations.of(context).actionClose, + textAlign: TextAlign.end, + ), + ), + NeonDialogAction( + isDefaultAction: true, + onPressed: () async { + await Clipboard.setData( + ClipboardData( + text: feed.lastUpdateError!, + ), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), + ), + ); + Navigator.of(context).pop(); + } + }, + child: Text( + NewsLocalizations.of(context).feedCopyErrorMessage, + textAlign: TextAlign.end, + ), + ), + ], + ); +} + +/// A dialog for moving a news feed by into a different folder. +/// +/// When moved the id of the new folder will be popped. +class NewsMoveFeedDialog extends StatefulWidget { + /// Creates a new move feed dialog. + const NewsMoveFeedDialog({ + required this.folders, + required this.feed, + super.key, + }); + + /// The list of available folders. + final List folders; + + /// The feed to move. + final news.Feed feed; + + @override + State createState() => _NewsMoveFeedDialogState(); +} + +class _NewsMoveFeedDialogState extends State { + final formKey = GlobalKey(); + + news.Folder? folder; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(folder?.id); + } + } + + @override + void initState() { + folder = widget.folders.singleWhereOrNull((final folder) => folder.id == widget.feed.folderId); + + super.initState(); + } + + @override + Widget build(final BuildContext context) => NeonDialog( + title: Text(NewsLocalizations.of(context).feedMove), + content: Material( + child: Form( + key: formKey, + child: NewsFolderSelect( + folders: widget.folders, + value: folder, + onChanged: (final f) { + setState(() { + folder = f; + }); + }, + ), + ), + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + NewsLocalizations.of(context).feedMove, + textAlign: TextAlign.end, + ), + ), + ], + ); +} + +/// A [NeonDialog] that shows for renaming creating a new folder. +/// +/// Use `showFolderCreateDialog` to display this dialog. +/// +/// When submitted the folder name will be popped as a `String`. +class NewsCreateFolderDialog extends StatefulWidget { + /// Creates a new NeonDialog for creating a folder. + const NewsCreateFolderDialog({ + super.key, + }); + + @override + State createState() => _NewsCreateFolderDialogState(); +} + +class _NewsCreateFolderDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(controller.text); + } + } + + @override + Widget build(final BuildContext context) { + final content = Material( + child: TextFormField( + controller: controller, + decoration: InputDecoration( + hintText: NewsLocalizations.of(context).folderCreateName, + ), + autofocus: true, + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ); + + return NeonDialog( + title: Text(NewsLocalizations.of(context).folderCreate), + content: Form( + key: formKey, + child: content, + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + NewsLocalizations.of(context).folderCreate, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/packages/neon/neon_news/lib/src/widgets/feed_floating_action_button.dart b/packages/neon/neon_news/lib/src/widgets/feed_floating_action_button.dart index 3e87beb7b7b..92f3d826bf2 100644 --- a/packages/neon/neon_news/lib/src/widgets/feed_floating_action_button.dart +++ b/packages/neon/neon_news/lib/src/widgets/feed_floating_action_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/src/blocs/news.dart'; -import 'package:neon_news/src/dialogs/add_feed.dart'; +import 'package:neon_news/src/widgets/dialog.dart'; class NewsFeedFloatingActionButton extends StatelessWidget { const NewsFeedFloatingActionButton({ @@ -16,7 +16,7 @@ class NewsFeedFloatingActionButton extends StatelessWidget { @override Widget build(final BuildContext context) => FloatingActionButton( onPressed: () async { - final result = await showDialog<(String, int?)>( + final result = await showAdaptiveDialog<(String, int?)>( context: context, builder: (final context) => NewsAddFeedDialog( bloc: bloc, diff --git a/packages/neon/neon_news/lib/src/widgets/feeds_view.dart b/packages/neon/neon_news/lib/src/widgets/feeds_view.dart index 3b6d843d5ff..78db4002b52 100644 --- a/packages/neon/neon_news/lib/src/widgets/feeds_view.dart +++ b/packages/neon/neon_news/lib/src/widgets/feeds_view.dart @@ -5,13 +5,12 @@ import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/src/blocs/news.dart'; -import 'package:neon_news/src/dialogs/feed_show_url.dart'; -import 'package:neon_news/src/dialogs/feed_update_error.dart'; -import 'package:neon_news/src/dialogs/move_feed.dart'; import 'package:neon_news/src/options.dart'; import 'package:neon_news/src/pages/feed.dart'; import 'package:neon_news/src/sort/feeds.dart'; +import 'package:neon_news/src/widgets/dialog.dart'; import 'package:neon_news/src/widgets/feed_icon.dart'; +import 'package:neon_news/utils/dialog.dart'; import 'package:nextcloud/news.dart' as news; class NewsFeedsView extends StatelessWidget { @@ -71,16 +70,14 @@ class NewsFeedsView extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (feed.updateErrorCount > 0) ...[ + if (feed.updateErrorCount > 0) IconButton( - onPressed: () async { - await showDialog( - context: context, - builder: (final context) => NewsFeedUpdateErrorDialog( - feed: feed, - ), - ); - }, + onPressed: () async => showAdaptiveDialog( + context: context, + builder: (final context) => NewsFeedUpdateErrorDialog( + feed: feed, + ), + ), tooltip: NewsLocalizations.of(context).feedShowErrorMessage, iconSize: 30, icon: Text( @@ -90,7 +87,6 @@ class NewsFeedsView extends StatelessWidget { ), ), ), - ], PopupMenuButton( itemBuilder: (final context) => [ PopupMenuItem( @@ -105,17 +101,16 @@ class NewsFeedsView extends StatelessWidget { value: NewsFeedAction.rename, child: Text(NewsLocalizations.of(context).actionRename), ), - if (folders.isNotEmpty) ...[ + if (folders.isNotEmpty) PopupMenuItem( value: NewsFeedAction.move, child: Text(NewsLocalizations.of(context).actionMove), ), - ], ], onSelected: (final action) async { switch (action) { case NewsFeedAction.showURL: - await showDialog( + await showAdaptiveDialog( context: context, builder: (final context) => NewsFeedShowURLDialog( feed: feed, @@ -125,10 +120,9 @@ class NewsFeedsView extends StatelessWidget { if (!context.mounted) { return; } - if (await showConfirmationDialog( - context, - NewsLocalizations.of(context).feedRemoveConfirm(feed.title), - )) { + final result = await showDeleteFeedDialog(context, feed); + + if (result) { bloc.removeFeed(feed.id); } case NewsFeedAction.rename: @@ -138,7 +132,7 @@ class NewsFeedsView extends StatelessWidget { final result = await showRenameDialog( context: context, title: NewsLocalizations.of(context).feedRename, - value: feed.title, + initialValue: feed.title, ); if (result != null) { bloc.renameFeed(feed.id, result); @@ -147,15 +141,15 @@ class NewsFeedsView extends StatelessWidget { if (!context.mounted) { return; } - final result = await showDialog>( + final result = await showAdaptiveDialog( context: context, builder: (final context) => NewsMoveFeedDialog( folders: folders, feed: feed, ), ); - if (result != null) { - bloc.moveFeed(feed.id, result[0]); + if (result != null && result != feed.folderId) { + bloc.moveFeed(feed.id, result); } } }, diff --git a/packages/neon/neon_news/lib/src/widgets/folder_floating_action_button.dart b/packages/neon/neon_news/lib/src/widgets/folder_floating_action_button.dart index 969fcd54b4c..14591a296ad 100644 --- a/packages/neon/neon_news/lib/src/widgets/folder_floating_action_button.dart +++ b/packages/neon/neon_news/lib/src/widgets/folder_floating_action_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/src/blocs/news.dart'; -import 'package:neon_news/src/dialogs/create_folder.dart'; +import 'package:neon_news/utils/dialog.dart'; class NewsFolderFloatingActionButton extends StatelessWidget { const NewsFolderFloatingActionButton({ @@ -14,10 +14,8 @@ class NewsFolderFloatingActionButton extends StatelessWidget { @override Widget build(final BuildContext context) => FloatingActionButton( onPressed: () async { - final result = await showDialog( - context: context, - builder: (final context) => const NewsCreateFolderDialog(), - ); + final result = await showFolderCreateDialog(context: context); + if (result != null) { bloc.createFolder(result); } diff --git a/packages/neon/neon_news/lib/src/widgets/folder_select.dart b/packages/neon/neon_news/lib/src/widgets/folder_select.dart index 4b86d159e62..74662b5997a 100644 --- a/packages/neon/neon_news/lib/src/widgets/folder_select.dart +++ b/packages/neon/neon_news/lib/src/widgets/folder_select.dart @@ -11,6 +11,8 @@ class NewsFolderSelect extends StatelessWidget { }); final List folders; + + /// {@macro flutter.material.dropdownButton.onChanged} final ValueChanged onChanged; final news.Folder? value; diff --git a/packages/neon/neon_news/lib/src/widgets/folders_view.dart b/packages/neon/neon_news/lib/src/widgets/folders_view.dart index e45722fa26f..2a9077df9a0 100644 --- a/packages/neon/neon_news/lib/src/widgets/folders_view.dart +++ b/packages/neon/neon_news/lib/src/widgets/folders_view.dart @@ -2,13 +2,13 @@ import 'package:flutter/material.dart'; import 'package:neon_framework/blocs.dart'; import 'package:neon_framework/sort_box.dart'; import 'package:neon_framework/theme.dart'; -import 'package:neon_framework/utils.dart'; import 'package:neon_framework/widgets.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/src/blocs/news.dart'; import 'package:neon_news/src/options.dart'; import 'package:neon_news/src/pages/folder.dart'; import 'package:neon_news/src/sort/folders.dart'; +import 'package:neon_news/utils/dialog.dart'; import 'package:nextcloud/news.dart' as news; class NewsFoldersView extends StatelessWidget { @@ -99,21 +99,15 @@ class NewsFoldersView extends StatelessWidget { onSelected: (final action) async { switch (action) { case NewsFolderAction.delete: - if (await showConfirmationDialog( - context, - NewsLocalizations.of(context).folderDeleteConfirm(folder.name), - )) { + final result = await showFolderDeleteDialog(context: context, folderName: folder.name); + if (result) { bloc.deleteFolder(folder.id); } case NewsFolderAction.rename: if (!context.mounted) { return; } - final result = await showRenameDialog( - context: context, - title: NewsLocalizations.of(context).folderRename, - value: folder.name, - ); + final result = await showFolderRenameDialog(context: context, folderName: folder.name); if (result != null) { bloc.renameFolder(folder.id, result); } diff --git a/packages/neon/neon_news/lib/utils/dialog.dart b/packages/neon/neon_news/lib/utils/dialog.dart new file mode 100644 index 00000000000..07b4fccba50 --- /dev/null +++ b/packages/neon/neon_news/lib/utils/dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:neon_news/l10n/localizations.dart'; +import 'package:neon_news/src/widgets/dialog.dart'; +import 'package:nextcloud/news.dart'; + +/// Displays a [NeonConfirmationDialog] to confirm the deletion of the given [feed]. +/// +/// Returns a future whether the action has been accepted. +Future showDeleteFeedDialog(final BuildContext context, final Feed feed) async { + final content = NewsLocalizations.of(context).feedRemoveConfirm(feed.title); + + final result = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + title: NewsLocalizations.of(context).actionDeleteTitle, + content: Text(content), + ), + ); + + return result ?? false; +} + +/// Displays a [NewsCreateFolderDialog] for creating a new folder. +/// +/// Returns a future with the folder name split by `/`. +Future showFolderCreateDialog({ + required final BuildContext context, +}) => + showAdaptiveDialog( + context: context, + builder: (final context) => const NewsCreateFolderDialog(), + ); + +/// Displays a [NeonConfirmationDialog] for deleting a folder. +/// +/// Returns a future whether the action has been accepted. +Future showFolderDeleteDialog({ + required final BuildContext context, + required final String folderName, +}) async { + final content = NewsLocalizations.of(context).folderDeleteConfirm(folderName); + + final result = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + title: NewsLocalizations.of(context).actionDeleteTitle, + content: Text(content), + ), + ); + + return result ?? false; +} + +/// Displays a `NeonRenameDialog` for renaming a folder. +/// +/// Returns a future with the new name of name. +Future showFolderRenameDialog({ + required final BuildContext context, + required final String folderName, +}) async => + showRenameDialog( + context: context, + title: NewsLocalizations.of(context).folderRename, + initialValue: folderName, + ); diff --git a/packages/neon/neon_news/pubspec.yaml b/packages/neon/neon_news/pubspec.yaml index 53f49f2ee99..64c2611f676 100644 --- a/packages/neon/neon_news/pubspec.yaml +++ b/packages/neon/neon_news/pubspec.yaml @@ -7,6 +7,7 @@ environment: flutter: '>=3.13.0' dependencies: + collection: ^1.0.0 flutter: sdk: flutter flutter_html: ^3.0.0-beta.2 diff --git a/packages/neon/neon_notes/lib/src/dialogs/create_note.dart b/packages/neon/neon_notes/lib/src/dialogs/create_note.dart deleted file mode 100644 index 7563b6e9c52..00000000000 --- a/packages/neon/neon_notes/lib/src/dialogs/create_note.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/blocs.dart'; -import 'package:neon_framework/utils.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:neon_notes/l10n/localizations.dart'; -import 'package:neon_notes/src/blocs/notes.dart'; -import 'package:neon_notes/src/widgets/category_select.dart'; -import 'package:nextcloud/notes.dart' as notes; - -class NotesCreateNoteDialog extends StatefulWidget { - const NotesCreateNoteDialog({ - required this.bloc, - this.category, - super.key, - }); - - final NotesBloc bloc; - final String? category; - - @override - State createState() => _NotesCreateNoteDialogState(); -} - -class _NotesCreateNoteDialogState extends State { - final formKey = GlobalKey(); - final controller = TextEditingController(); - String? selectedCategory; - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop((controller.text, widget.category ?? selectedCategory)); - } - } - - @override - Widget build(final BuildContext context) => ResultBuilder>.behaviorSubject( - subject: widget.bloc.notesList, - builder: (final context, final notes) => NeonDialog( - title: Text(NotesLocalizations.of(context).noteCreate), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextFormField( - autofocus: true, - controller: controller, - decoration: InputDecoration( - hintText: NotesLocalizations.of(context).noteTitle, - ), - validator: (final input) => validateNotEmpty(context, input), - onFieldSubmitted: (final _) { - submit(); - }, - ), - if (widget.category == null) ...[ - Center( - child: NeonError( - notes.error, - onRetry: widget.bloc.refresh, - ), - ), - Center( - child: NeonLinearProgressIndicator( - visible: notes.isLoading, - ), - ), - if (notes.hasData) ...[ - NotesCategorySelect( - categories: notes.requireData.map((final note) => note.category).toSet().toList(), - onChanged: (final category) { - selectedCategory = category; - }, - onSubmitted: submit, - ), - ], - ], - ElevatedButton( - onPressed: submit, - child: Text(NotesLocalizations.of(context).noteCreate), - ), - ], - ), - ), - ], - ), - ); -} diff --git a/packages/neon/neon_notes/lib/src/dialogs/select_category.dart b/packages/neon/neon_notes/lib/src/dialogs/select_category.dart deleted file mode 100644 index 22046ecba47..00000000000 --- a/packages/neon/neon_notes/lib/src/dialogs/select_category.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/blocs.dart'; -import 'package:neon_framework/widgets.dart'; -import 'package:neon_notes/l10n/localizations.dart'; -import 'package:neon_notes/src/blocs/notes.dart'; -import 'package:neon_notes/src/widgets/category_select.dart'; -import 'package:nextcloud/notes.dart' as notes; - -class NotesSelectCategoryDialog extends StatefulWidget { - const NotesSelectCategoryDialog({ - required this.bloc, - this.initialCategory, - super.key, - }); - - final NotesBloc bloc; - final String? initialCategory; - - @override - State createState() => _NotesSelectCategoryDialogState(); -} - -class _NotesSelectCategoryDialogState extends State { - final formKey = GlobalKey(); - - String? selectedCategory; - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop(selectedCategory); - } - } - - @override - Widget build(final BuildContext context) => ResultBuilder>.behaviorSubject( - subject: widget.bloc.notesList, - builder: (final context, final notes) => NeonDialog( - title: Text(NotesLocalizations.of(context).category), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Center( - child: NeonError( - notes.error, - onRetry: widget.bloc.refresh, - ), - ), - Center( - child: NeonLinearProgressIndicator( - visible: notes.isLoading, - ), - ), - if (notes.hasData) ...[ - NotesCategorySelect( - categories: notes.requireData.map((final note) => note.category).toSet().toList(), - initialValue: widget.initialCategory, - onChanged: (final category) { - selectedCategory = category; - }, - onSubmitted: submit, - ), - ], - ElevatedButton( - onPressed: submit, - child: Text(NotesLocalizations.of(context).noteSetCategory), - ), - ], - ), - ), - ], - ), - ); -} diff --git a/packages/neon/neon_notes/lib/src/pages/note.dart b/packages/neon/neon_notes/lib/src/pages/note.dart index aa9c0ce7bc6..c3dbe3a1909 100644 --- a/packages/neon/neon_notes/lib/src/pages/note.dart +++ b/packages/neon/neon_notes/lib/src/pages/note.dart @@ -6,10 +6,10 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:neon_notes/l10n/localizations.dart'; import 'package:neon_notes/src/blocs/note.dart'; import 'package:neon_notes/src/blocs/notes.dart'; -import 'package:neon_notes/src/dialogs/select_category.dart'; import 'package:neon_notes/src/options.dart'; import 'package:neon_notes/src/utils/category_color.dart'; import 'package:neon_notes/src/utils/exception_handler.dart'; +import 'package:neon_notes/src/widgets/dialog.dart'; import 'package:rxdart/rxdart.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; @@ -134,7 +134,7 @@ class _NotesNotePageState extends State { return IconButton( onPressed: () async { - final result = await showDialog( + final result = await showAdaptiveDialog( context: context, builder: (final context) => NotesSelectCategoryDialog( bloc: widget.notesBloc, diff --git a/packages/neon/neon_notes/lib/src/widgets/dialog.dart b/packages/neon/neon_notes/lib/src/widgets/dialog.dart new file mode 100644 index 00000000000..5e7baedb53f --- /dev/null +++ b/packages/neon/neon_notes/lib/src/widgets/dialog.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:neon_framework/widgets.dart'; +import 'package:neon_notes/l10n/localizations.dart'; +import 'package:neon_notes/src/blocs/notes.dart'; +import 'package:neon_notes/src/widgets/category_select.dart'; +import 'package:nextcloud/notes.dart' as notes; + +/// A dialog for creating a note. +class NotesCreateNoteDialog extends StatefulWidget { + /// Creates a new create note dialog. + const NotesCreateNoteDialog({ + required this.bloc, + this.initialCategory, + super.key, + }); + + /// The active notes bloc. + final NotesBloc bloc; + + /// The initial category of the note. + final String? initialCategory; + + @override + State createState() => _NotesCreateNoteDialogState(); +} + +class _NotesCreateNoteDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + String? selectedCategory; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop((controller.text, widget.initialCategory ?? selectedCategory)); + } + } + + @override + Widget build(final BuildContext context) { + final titleField = Form( + key: formKey, + child: TextFormField( + autofocus: true, + controller: controller, + decoration: InputDecoration( + hintText: NotesLocalizations.of(context).noteTitle, + ), + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ); + + final folderSelector = ResultBuilder>.behaviorSubject( + subject: widget.bloc.notesList, + builder: (final context, final notes) { + if (notes.hasError) { + return Center( + child: NeonError( + notes.error, + onRetry: widget.bloc.refresh, + ), + ); + } + if (!notes.hasData) { + return Center( + child: NeonLinearProgressIndicator( + visible: notes.isLoading, + ), + ); + } + + return NotesCategorySelect( + categories: notes.requireData.map((final note) => note.category).toSet().toList(), + onChanged: (final category) { + selectedCategory = category; + }, + onSubmitted: submit, + ); + }, + ); + + return NeonDialog( + title: Text(NotesLocalizations.of(context).noteCreate), + content: Material( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + titleField, + const SizedBox(height: 8), + folderSelector, + ], + ), + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + NotesLocalizations.of(context).noteCreate, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} + +/// A dialog for selecting a category for a note. +class NotesSelectCategoryDialog extends StatefulWidget { + /// Creates a new category selection dialog. + const NotesSelectCategoryDialog({ + required this.bloc, + this.initialCategory, + super.key, + }); + + /// The active notes bloc. + final NotesBloc bloc; + + /// The initial category of the note. + final String? initialCategory; + + @override + State createState() => _NotesSelectCategoryDialogState(); +} + +class _NotesSelectCategoryDialogState extends State { + final formKey = GlobalKey(); + + String? selectedCategory; + + void submit() { + if (formKey.currentState!.validate()) { + Navigator.of(context).pop(selectedCategory); + } + } + + @override + Widget build(final BuildContext context) { + final folderSelector = ResultBuilder>.behaviorSubject( + subject: widget.bloc.notesList, + builder: (final context, final notes) { + if (notes.hasError) { + return Center( + child: NeonError( + notes.error, + onRetry: widget.bloc.refresh, + ), + ); + } + if (!notes.hasData) { + return Center( + child: NeonLinearProgressIndicator( + visible: notes.isLoading, + ), + ); + } + + return Form( + key: formKey, + child: NotesCategorySelect( + categories: notes.requireData.map((final note) => note.category).toSet().toList(), + initialValue: widget.initialCategory, + onChanged: (final category) { + selectedCategory = category; + }, + onSubmitted: submit, + ), + ); + }, + ); + + return NeonDialog( + title: Text(NotesLocalizations.of(context).category), + content: Material( + child: folderSelector, + ), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + NotesLocalizations.of(context).noteSetCategory, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} diff --git a/packages/neon/neon_notes/lib/src/widgets/notes_floating_action_button.dart b/packages/neon/neon_notes/lib/src/widgets/notes_floating_action_button.dart index 17414fe4584..7375973284a 100644 --- a/packages/neon/neon_notes/lib/src/widgets/notes_floating_action_button.dart +++ b/packages/neon/neon_notes/lib/src/widgets/notes_floating_action_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:neon_notes/l10n/localizations.dart'; import 'package:neon_notes/src/blocs/notes.dart'; -import 'package:neon_notes/src/dialogs/create_note.dart'; +import 'package:neon_notes/src/widgets/dialog.dart'; class NotesFloatingActionButton extends StatelessWidget { const NotesFloatingActionButton({ @@ -16,11 +16,11 @@ class NotesFloatingActionButton extends StatelessWidget { @override Widget build(final BuildContext context) => FloatingActionButton( onPressed: () async { - final result = await showDialog<(String, String?)>( + final result = await showAdaptiveDialog<(String, String?)>( context: context, builder: (final context) => NotesCreateNoteDialog( bloc: bloc, - category: category, + initialCategory: category, ), ); if (result != null) { diff --git a/packages/neon/neon_notes/lib/src/widgets/notes_view.dart b/packages/neon/neon_notes/lib/src/widgets/notes_view.dart index 72f1683dc3b..00187a514c0 100644 --- a/packages/neon/neon_notes/lib/src/widgets/notes_view.dart +++ b/packages/neon/neon_notes/lib/src/widgets/notes_view.dart @@ -105,8 +105,8 @@ class NotesView extends StatelessWidget { }, onLongPress: () async { final result = await showConfirmationDialog( - context, - NotesLocalizations.of(context).noteDeleteConfirm(note.title), + context: context, + title: NotesLocalizations.of(context).noteDeleteConfirm(note.title), ); if (result) { bloc.deleteNote(note.id); diff --git a/packages/neon/neon_notifications/lib/l10n/en.arb b/packages/neon/neon_notifications/lib/l10n/en.arb index a4b62eb35af..d0382224804 100644 --- a/packages/neon/neon_notifications/lib/l10n/en.arb +++ b/packages/neon/neon_notifications/lib/l10n/en.arb @@ -1,6 +1,5 @@ { "@@locale": "en", - "actionClose": "Close", "notificationsDismissAll": "Dismiss all notifications", "notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet" } diff --git a/packages/neon/neon_notifications/lib/l10n/localizations.dart b/packages/neon/neon_notifications/lib/l10n/localizations.dart index a9716aa2523..3dbb713c0ce 100644 --- a/packages/neon/neon_notifications/lib/l10n/localizations.dart +++ b/packages/neon/neon_notifications/lib/l10n/localizations.dart @@ -89,12 +89,6 @@ abstract class NotificationsLocalizations { /// A list of this localizations delegate's supported locales. static const List supportedLocales = [Locale('en')]; - /// No description provided for @actionClose. - /// - /// In en, this message translates to: - /// **'Close'** - String get actionClose; - /// No description provided for @notificationsDismissAll. /// /// In en, this message translates to: diff --git a/packages/neon/neon_notifications/lib/l10n/localizations_en.dart b/packages/neon/neon_notifications/lib/l10n/localizations_en.dart index b8e6ff33b4f..48b2caa4daf 100644 --- a/packages/neon/neon_notifications/lib/l10n/localizations_en.dart +++ b/packages/neon/neon_notifications/lib/l10n/localizations_en.dart @@ -4,9 +4,6 @@ import 'localizations.dart'; class NotificationsLocalizationsEn extends NotificationsLocalizations { NotificationsLocalizationsEn([String locale = 'en']) : super(locale); - @override - String get actionClose => 'Close'; - @override String get notificationsDismissAll => 'Dismiss all notifications'; diff --git a/packages/neon/neon_notifications/lib/src/pages/main.dart b/packages/neon/neon_notifications/lib/src/pages/main.dart index 8ead9636338..9ecdb77ed0d 100644 --- a/packages/neon/neon_notifications/lib/src/pages/main.dart +++ b/packages/neon/neon_notifications/lib/src/pages/main.dart @@ -106,25 +106,9 @@ class _NotificationsMainPageState extends State { final accountsBloc = NeonProvider.of(context); accountsBloc.activeAppsBloc.setActiveApp(app.id); } else { - final colorScheme = Theme.of(context).colorScheme; - - await showDialog( + await showUnimplementedDialog( context: context, - builder: (final context) => AlertDialog( - title: Text(NotificationsLocalizations.of(context).notificationAppNotImplementedYet), - actions: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ), - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NotificationsLocalizations.of(context).actionClose), - ), - ], - ), + title: NotificationsLocalizations.of(context).notificationAppNotImplementedYet, ); } }, diff --git a/packages/neon_framework/lib/l10n/en.arb b/packages/neon_framework/lib/l10n/en.arb index d5a3f415fa2..d699422f29b 100644 --- a/packages/neon_framework/lib/l10n/en.arb +++ b/packages/neon_framework/lib/l10n/en.arb @@ -77,6 +77,7 @@ } } }, + "errorDialog": "An error has occurred", "actionYes": "Yes", "actionNo": "No", "actionClose": "Close", @@ -84,6 +85,7 @@ "actionShowSlashHide": "Show/Hide", "actionExit": "Exit", "actionContinue": "Continue", + "actionCancel": "Cancel", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", @@ -97,9 +99,11 @@ "settingsAccountManage": "Manage accounts", "settingsExport": "Export settings", "settingsImport": "Import settings", + "settingsReset": "Reset settings?", "settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)", "settingsResetAll": "Reset all settings", "settingsResetAllConfirmation": "Do you want to reset all settings?", + "settingsResetAllExplanation": "This will reset all preferences back to their default settings.", "settingsResetFor": "Reset all settings for {name}", "@settingsResetFor": { "placeholders": { @@ -108,6 +112,8 @@ } } }, + "settingsResetForExplanation": "This will reset your account preferences back to their default settings.", + "settingsResetForClientExplanation": "This will reset all preferences for the app back to their default settings.", "settingsResetForConfirmation": "Do you want to reset all settings for {name}?", "@settingsResetForConfirmation": { "placeholders": { diff --git a/packages/neon_framework/lib/l10n/localizations.dart b/packages/neon_framework/lib/l10n/localizations.dart index cd6851d816d..52e77fe39cc 100644 --- a/packages/neon_framework/lib/l10n/localizations.dart +++ b/packages/neon_framework/lib/l10n/localizations.dart @@ -269,6 +269,12 @@ abstract class NeonLocalizations { /// **'Route not found: {route}'** String errorRouteNotFound(String route); + /// No description provided for @errorDialog. + /// + /// In en, this message translates to: + /// **'An error has occurred'** + String get errorDialog; + /// No description provided for @actionYes. /// /// In en, this message translates to: @@ -311,6 +317,12 @@ abstract class NeonLocalizations { /// **'Continue'** String get actionContinue; + /// No description provided for @actionCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get actionCancel; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: @@ -389,6 +401,12 @@ abstract class NeonLocalizations { /// **'Import settings'** String get settingsImport; + /// No description provided for @settingsReset. + /// + /// In en, this message translates to: + /// **'Reset settings?'** + String get settingsReset; + /// No description provided for @settingsImportWrongFileExtension. /// /// In en, this message translates to: @@ -407,12 +425,30 @@ abstract class NeonLocalizations { /// **'Do you want to reset all settings?'** String get settingsResetAllConfirmation; + /// No description provided for @settingsResetAllExplanation. + /// + /// In en, this message translates to: + /// **'This will reset all preferences back to their default settings.'** + String get settingsResetAllExplanation; + /// No description provided for @settingsResetFor. /// /// In en, this message translates to: /// **'Reset all settings for {name}'** String settingsResetFor(String name); + /// No description provided for @settingsResetForExplanation. + /// + /// In en, this message translates to: + /// **'This will reset your account preferences back to their default settings.'** + String get settingsResetForExplanation; + + /// No description provided for @settingsResetForClientExplanation. + /// + /// In en, this message translates to: + /// **'This will reset all preferences for the app back to their default settings.'** + String get settingsResetForClientExplanation; + /// No description provided for @settingsResetForConfirmation. /// /// In en, this message translates to: diff --git a/packages/neon_framework/lib/l10n/localizations_en.dart b/packages/neon_framework/lib/l10n/localizations_en.dart index c5d3ad882e5..98ac68edb93 100644 --- a/packages/neon_framework/lib/l10n/localizations_en.dart +++ b/packages/neon_framework/lib/l10n/localizations_en.dart @@ -126,6 +126,9 @@ class NeonLocalizationsEn extends NeonLocalizations { return 'Route not found: $route'; } + @override + String get errorDialog => 'An error has occurred'; + @override String get actionYes => 'Yes'; @@ -147,6 +150,9 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get actionContinue => 'Continue'; + @override + String get actionCancel => 'Cancel'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; @@ -187,6 +193,9 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get settingsImport => 'Import settings'; + @override + String get settingsReset => 'Reset settings?'; + @override String get settingsImportWrongFileExtension => 'Settings import has wrong file extension (has to be .json.base64)'; @@ -196,11 +205,21 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get settingsResetAllConfirmation => 'Do you want to reset all settings?'; + @override + String get settingsResetAllExplanation => 'This will reset all preferences back to their default settings.'; + @override String settingsResetFor(String name) { return 'Reset all settings for $name'; } + @override + String get settingsResetForExplanation => 'This will reset your account preferences back to their default settings.'; + + @override + String get settingsResetForClientExplanation => + 'This will reset all preferences for the app back to their default settings.'; + @override String settingsResetForConfirmation(String name) { return 'Do you want to reset all settings for $name?'; diff --git a/packages/neon_framework/lib/src/pages/account_settings.dart b/packages/neon_framework/lib/src/pages/account_settings.dart index 64f328e39f7..991f457bcd1 100644 --- a/packages/neon_framework/lib/src/pages/account_settings.dart +++ b/packages/neon_framework/lib/src/pages/account_settings.dart @@ -13,7 +13,7 @@ import 'package:neon_framework/src/settings/widgets/settings_category.dart'; import 'package:neon_framework/src/settings/widgets/settings_list.dart'; import 'package:neon_framework/src/theme/dialog.dart'; import 'package:neon_framework/src/utils/adaptive.dart'; -import 'package:neon_framework/src/utils/confirmation_dialog.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; import 'package:neon_framework/src/widgets/error.dart'; import 'package:nextcloud/provisioning_api.dart' as provisioning_api; @@ -46,15 +46,23 @@ class AccountSettingsPage extends StatelessWidget { actions: [ IconButton( onPressed: () async { - if (await showConfirmationDialog( - context, - NeonLocalizations.of(context).accountOptionsRemoveConfirm(account.humanReadableID), - )) { + final decision = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + icon: const Icon(Icons.logout), + title: NeonLocalizations.of(context).accountOptionsRemove, + content: Text( + NeonLocalizations.of(context).accountOptionsRemoveConfirm(account.humanReadableID), + ), + ), + ); + + if (decision ?? false) { final isActive = bloc.activeAccount.valueOrNull == account; + options.reset(); bloc.removeAccount(account); - // ignore: use_build_context_synchronously if (!context.mounted) { return; } @@ -71,10 +79,18 @@ class AccountSettingsPage extends StatelessWidget { ), IconButton( onPressed: () async { - if (await showConfirmationDialog( - context, - NeonLocalizations.of(context).settingsResetForConfirmation(name), - )) { + final content = + '${NeonLocalizations.of(context).settingsResetForConfirmation(name)} ${NeonLocalizations.of(context).settingsResetForExplanation}'; + final decision = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + icon: const Icon(Icons.restart_alt), + title: NeonLocalizations.of(context).settingsReset, + content: Text(content), + ), + ); + + if (decision ?? false) { options.reset(); } }, diff --git a/packages/neon_framework/lib/src/pages/app_implementation_settings.dart b/packages/neon_framework/lib/src/pages/app_implementation_settings.dart index 815c1c229d4..618aab4c1b7 100644 --- a/packages/neon_framework/lib/src/pages/app_implementation_settings.dart +++ b/packages/neon_framework/lib/src/pages/app_implementation_settings.dart @@ -7,7 +7,7 @@ import 'package:neon_framework/src/settings/widgets/option_settings_tile.dart'; import 'package:neon_framework/src/settings/widgets/settings_category.dart'; import 'package:neon_framework/src/settings/widgets/settings_list.dart'; import 'package:neon_framework/src/theme/dialog.dart'; -import 'package:neon_framework/src/utils/confirmation_dialog.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; @internal class AppImplementationSettingsPage extends StatelessWidget { @@ -25,10 +25,19 @@ class AppImplementationSettingsPage extends StatelessWidget { actions: [ IconButton( onPressed: () async { - if (await showConfirmationDialog( - context, - NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), - )) { + final content = + '${NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context))} ${NeonLocalizations.of(context).settingsResetForClientExplanation}'; + + final decision = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + icon: const Icon(Icons.restart_alt), + title: NeonLocalizations.of(context).settingsReset, + content: Text(content), + ), + ); + + if (decision ?? false) { appImplementation.options.reset(); } }, diff --git a/packages/neon_framework/lib/src/pages/home.dart b/packages/neon_framework/lib/src/pages/home.dart index da7e18e9181..d91e07d1889 100644 --- a/packages/neon_framework/lib/src/pages/home.dart +++ b/packages/neon_framework/lib/src/pages/home.dart @@ -8,6 +8,7 @@ import 'package:neon_framework/src/blocs/accounts.dart'; import 'package:neon_framework/src/blocs/apps.dart'; import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/models/app_implementation.dart'; +import 'package:neon_framework/src/utils/dialog.dart'; import 'package:neon_framework/src/utils/global_options.dart' as global_options; import 'package:neon_framework/src/utils/global_popups.dart'; import 'package:neon_framework/src/utils/provider.dart'; @@ -62,7 +63,7 @@ class _HomePageState extends State { } final message = l10n.errorUnsupportedAppVersions(buffer.toString()); - unawaited(_showProblem(message)); + unawaited(showErrorDialog(context: context, message: message)); }); GlobalPopups().register(context); @@ -80,10 +81,10 @@ class _HomePageState extends State { Future _checkMaintenanceMode() async { try { final status = await _account.client.core.getStatus(); + if (status.body.maintenance && mounted) { - await _showProblem( - NeonLocalizations.of(context).errorServerInMaintenanceMode, - ); + final message = NeonLocalizations.of(context).errorServerInMaintenanceMode; + await showErrorDialog(context: context, message: message); } } catch (e, s) { debugPrint(e.toString()); @@ -94,29 +95,6 @@ class _HomePageState extends State { } } - Future _showProblem(final String title) async { - final colorScheme = Theme.of(context).colorScheme; - - await showDialog( - context: context, - builder: (final context) => AlertDialog( - title: Text(title), - actions: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: colorScheme.error, - foregroundColor: colorScheme.onError, - ), - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NeonLocalizations.of(context).actionClose), - ), - ], - ), - ); - } - @override Widget build(final BuildContext context) { const drawer = NeonDrawer(); diff --git a/packages/neon_framework/lib/src/pages/settings.dart b/packages/neon_framework/lib/src/pages/settings.dart index ff9bdf95503..76908547401 100644 --- a/packages/neon_framework/lib/src/pages/settings.dart +++ b/packages/neon_framework/lib/src/pages/settings.dart @@ -19,10 +19,10 @@ import 'package:neon_framework/src/settings/widgets/text_settings_tile.dart'; import 'package:neon_framework/src/theme/branding.dart'; import 'package:neon_framework/src/theme/dialog.dart'; import 'package:neon_framework/src/utils/adaptive.dart'; -import 'package:neon_framework/src/utils/confirmation_dialog.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/provider.dart'; import 'package:neon_framework/src/utils/save_file.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; import 'package:neon_framework/src/widgets/error.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -93,7 +93,18 @@ class _SettingsPageState extends State { actions: [ IconButton( onPressed: () async { - if (await showConfirmationDialog(context, NeonLocalizations.of(context).settingsResetAllConfirmation)) { + final content = + '${NeonLocalizations.of(context).settingsResetAllConfirmation} ${NeonLocalizations.of(context).settingsResetAllExplanation}'; + final decision = await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog( + icon: const Icon(Icons.restart_alt), + title: NeonLocalizations.of(context).settingsReset, + content: Text(content), + ), + ); + + if (decision ?? false) { globalOptions.reset(); for (final appImplementation in appImplementations) { diff --git a/packages/neon_framework/lib/src/theme/dialog.dart b/packages/neon_framework/lib/src/theme/dialog.dart index d542b26138e..72b445cca2b 100644 --- a/packages/neon_framework/lib/src/theme/dialog.dart +++ b/packages/neon_framework/lib/src/theme/dialog.dart @@ -15,6 +15,7 @@ class NeonDialogTheme { minWidth: 280, maxWidth: 560, ), + this.padding = const EdgeInsets.all(24), }); /// Used to configure the [BoxConstraints] for the [NeonDialog] widget. @@ -23,13 +24,21 @@ class NeonDialogTheme { /// By default it follows the default [m3 dialog specification](https://m3.material.io/components/dialogs/specs). final BoxConstraints constraints; + /// Padding around the content. + /// + /// This property defaults to providing a padding of 24 pixels on all sides + /// to separate the content from the edges of the dialog. + final EdgeInsets padding; + /// Creates a copy of this object but with the given fields replaced with the /// new values. NeonDialogTheme copyWith({ final BoxConstraints? constraints, + final EdgeInsets? padding, }) => NeonDialogTheme( constraints: constraints ?? this.constraints, + padding: padding ?? this.padding, ); /// The data from the closest [NeonDialogTheme] instance given the build context. @@ -45,11 +54,15 @@ class NeonDialogTheme { } return NeonDialogTheme( constraints: BoxConstraints.lerp(a.constraints, b.constraints, t)!, + padding: EdgeInsets.lerp(a.padding, b.padding, t)!, ); } @override - int get hashCode => constraints.hashCode; + int get hashCode => Object.hashAll([ + constraints, + padding, + ]); @override bool operator ==(final Object other) { @@ -57,6 +70,6 @@ class NeonDialogTheme { return true; } - return other is NeonDialogTheme && other.constraints == constraints; + return other is NeonDialogTheme && other.constraints == constraints && other.padding == padding; } } diff --git a/packages/neon_framework/lib/src/theme/theme.dart b/packages/neon_framework/lib/src/theme/theme.dart index acea710b659..faa014be760 100644 --- a/packages/neon_framework/lib/src/theme/theme.dart +++ b/packages/neon_framework/lib/src/theme/theme.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; +import 'package:neon_framework/src/theme/branding.dart'; import 'package:neon_framework/src/theme/colors.dart'; import 'package:neon_framework/src/theme/neon.dart'; import 'package:neon_framework/src/theme/server.dart'; @@ -19,7 +20,23 @@ class AppTheme { this.useNextcloudTheme = false, this.oledAsDark = false, this.appThemes, - }); + }) : platform = null; + + @visibleForTesting + const AppTheme.test({ + this.serverTheme = const ServerTheme(nextcloudTheme: null), + this.deviceThemeLight, + this.deviceThemeDark, + this.neonTheme = const NeonTheme( + branding: Branding( + name: 'Test App', + logo: Placeholder(), + ), + ), + this.useNextcloudTheme = false, + this.oledAsDark = false, + this.platform, + }) : appThemes = null; /// The theme provided by the Nextcloud server. final ServerTheme serverTheme; @@ -42,6 +59,10 @@ class AppTheme { /// The base theme for the Neon app. final NeonTheme neonTheme; + /// The platform the material widgets should adapt to target. + @visibleForTesting + final TargetPlatform? platform; + ColorScheme _buildColorScheme(final Brightness brightness) { ColorScheme? colorScheme; @@ -83,6 +104,7 @@ class AppTheme { return ThemeData( useMaterial3: true, + platform: platform, colorScheme: colorScheme, scaffoldBackgroundColor: colorScheme.background, cardColor: colorScheme.background, diff --git a/packages/neon_framework/lib/src/utils/confirmation_dialog.dart b/packages/neon_framework/lib/src/utils/confirmation_dialog.dart deleted file mode 100644 index e36b8e329a9..00000000000 --- a/packages/neon_framework/lib/src/utils/confirmation_dialog.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/l10n/localizations.dart'; -import 'package:neon_framework/src/theme/colors.dart'; - -/// Shows a dialog asking the user to confirm the question posed in the [title]. -Future showConfirmationDialog(final BuildContext context, final String title) async => - await showDialog( - context: context, - builder: (final context) => AlertDialog( - title: Text(title), - actionsAlignment: MainAxisAlignment.spaceEvenly, - actions: [ - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: NcColors.decline, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(NeonLocalizations.of(context).actionNo), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: NcColors.accept, - foregroundColor: Theme.of(context).colorScheme.onPrimary, - ), - onPressed: () { - Navigator.of(context).pop(true); - }, - child: Text(NeonLocalizations.of(context).actionYes), - ), - ], - ), - ) ?? - false; diff --git a/packages/neon_framework/lib/src/utils/dialog.dart b/packages/neon_framework/lib/src/utils/dialog.dart new file mode 100644 index 00000000000..b738b52f6ec --- /dev/null +++ b/packages/neon_framework/lib/src/utils/dialog.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:neon_framework/l10n/localizations.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; + +/// Displays a simple [NeonConfirmationDialog] with the given [title]. +/// +/// Returns a future whether the action has been accepted. +Future showConfirmationDialog({ + required final BuildContext context, + required final String title, +}) async => + await showAdaptiveDialog( + context: context, + builder: (final context) => NeonConfirmationDialog(title: title), + ) ?? + false; + +/// Displays a [NeonRenameDialog] with the given [title] and [initialValue]. +/// +/// Returns a future with the new name of name. +Future showRenameDialog({ + required final BuildContext context, + required final String title, + required final String initialValue, +}) async => + showAdaptiveDialog( + context: context, + builder: (final context) => NeonRenameDialog( + title: title, + value: initialValue, + ), + ); + +/// Displays a [NeonErrorDialog] with the given [message]. +Future showErrorDialog({ + required final BuildContext context, + required final String message, + final String? title, +}) => + showAdaptiveDialog( + context: context, + builder: (final context) => NeonErrorDialog(content: message), + ); + +/// Displays a [NeonDialog] with the given [title] informing the user that a +/// feature is not implemented yet. +Future showUnimplementedDialog({ + required final BuildContext context, + required final String title, +}) => + showAdaptiveDialog( + context: context, + builder: (final context) => NeonDialog( + automaticallyShowCancel: false, + title: Text(title), + actions: [ + NeonDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + NeonLocalizations.of(context).actionClose, + textAlign: TextAlign.end, + ), + ), + ], + ), + ); diff --git a/packages/neon_framework/lib/src/utils/global_popups.dart b/packages/neon_framework/lib/src/utils/global_popups.dart index 3a9c4756f47..840c1385594 100644 --- a/packages/neon_framework/lib/src/utils/global_popups.dart +++ b/packages/neon_framework/lib/src/utils/global_popups.dart @@ -10,7 +10,7 @@ import 'package:neon_framework/src/platform/platform.dart'; import 'package:neon_framework/src/router.dart'; import 'package:neon_framework/src/utils/global_options.dart'; import 'package:neon_framework/src/utils/provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; /// Singleton class managing global popups. @internal @@ -88,30 +88,9 @@ class GlobalPopups { return; } - await showDialog( + await showAdaptiveDialog( context: _context, - builder: (final context) => AlertDialog( - title: Text(NeonLocalizations.of(context).nextPushSupported), - content: Text(NeonLocalizations.of(context).nextPushSupportedText), - actions: [ - OutlinedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NeonLocalizations.of(context).actionNo), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - launchUrlString( - 'https://f-droid.org/packages/$unifiedPushNextPushID', - mode: LaunchMode.externalApplication, - ); - }, - child: Text(NeonLocalizations.of(context).nextPushSupportedInstall), - ), - ], - ), + builder: (final context) => const NeonUnifiedPushDialog(), ); }), ]); diff --git a/packages/neon_framework/lib/src/utils/rename_dialog.dart b/packages/neon_framework/lib/src/utils/rename_dialog.dart deleted file mode 100644 index 9854cde975a..00000000000 --- a/packages/neon_framework/lib/src/utils/rename_dialog.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon_framework/src/utils/validators.dart'; -import 'package:neon_framework/src/widgets/dialog.dart'; - -/// Shows a dialog asking the user to rename the thing stated in the [title]. -/// -/// [value] describes the current name of the thing before renaming. -Future showRenameDialog({ - required final BuildContext context, - required final String title, - required final String value, - final Key? key, -}) async => - showDialog( - context: context, - builder: (final context) => _RenameDialog( - title: title, - value: value, - key: key, - ), - ); - -class _RenameDialog extends StatefulWidget { - const _RenameDialog({ - required this.title, - required this.value, - super.key, - }); - - final String title; - final String value; - - @override - State<_RenameDialog> createState() => _RenameDialogState(); -} - -class _RenameDialogState extends State<_RenameDialog> { - final formKey = GlobalKey(); - - final controller = TextEditingController(); - - @override - void initState() { - controller.text = widget.value; - super.initState(); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop(controller.text); - } - } - - @override - Widget build(final BuildContext context) => NeonDialog( - title: Text(widget.title), - children: [ - Form( - key: formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - TextFormField( - autofocus: true, - controller: controller, - validator: (final input) => validateNotEmpty(context, input), - onFieldSubmitted: (final _) { - submit(); - }, - ), - ElevatedButton( - onPressed: submit, - child: Text(widget.title), - ), - ], - ), - ), - ], - ); -} diff --git a/packages/neon_framework/lib/src/widgets/account_selection_dialog.dart b/packages/neon_framework/lib/src/widgets/account_selection_dialog.dart deleted file mode 100644 index 3df48fe75d5..00000000000 --- a/packages/neon_framework/lib/src/widgets/account_selection_dialog.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; -import 'package:neon_framework/src/blocs/accounts.dart'; -import 'package:neon_framework/src/theme/dialog.dart'; -import 'package:neon_framework/src/utils/provider.dart'; -import 'package:neon_framework/src/widgets/account_tile.dart'; - -@internal -class NeonAccountSelectionDialog extends StatelessWidget { - const NeonAccountSelectionDialog({ - this.highlightActiveAccount = false, - this.children, - super.key, - }); - - final bool highlightActiveAccount; - final List? children; - - @override - Widget build(final BuildContext context) { - final accountsBloc = NeonProvider.of(context); - final accounts = accountsBloc.accounts.value; - final activeAccount = accountsBloc.activeAccount.value!; - - final sortedAccounts = List.of(accounts) - ..removeWhere((final account) => account.id == activeAccount.id) - ..insert(0, activeAccount); - - final tiles = sortedAccounts - .map( - (final account) => NeonAccountTile( - account: account, - trailing: highlightActiveAccount && account.id == activeAccount.id ? const Icon(Icons.check_circle) : null, - onTap: () { - Navigator.of(context).pop(account); - }, - ), - ) - .toList(); - if (highlightActiveAccount && accounts.length > 1) { - tiles.insert(1, const Divider()); - } - - final body = SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ...tiles, - ...?children, - ], - ), - ); - - return Dialog( - child: IntrinsicHeight( - child: Container( - padding: const EdgeInsets.all(24), - constraints: NeonDialogTheme.of(context).constraints, - child: body, - ), - ), - ); - } -} diff --git a/packages/neon_framework/lib/src/widgets/account_switcher_button.dart b/packages/neon_framework/lib/src/widgets/account_switcher_button.dart index 27bbefffc9d..7b5f557ea81 100644 --- a/packages/neon_framework/lib/src/widgets/account_switcher_button.dart +++ b/packages/neon_framework/lib/src/widgets/account_switcher_button.dart @@ -6,8 +6,8 @@ import 'package:neon_framework/src/models/account.dart'; import 'package:neon_framework/src/pages/settings.dart'; import 'package:neon_framework/src/router.dart'; import 'package:neon_framework/src/utils/provider.dart'; -import 'package:neon_framework/src/widgets/account_selection_dialog.dart'; import 'package:neon_framework/src/widgets/adaptive_widgets/list_tile.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; import 'package:neon_framework/src/widgets/user_avatar.dart'; @internal diff --git a/packages/neon_framework/lib/src/widgets/dialog.dart b/packages/neon_framework/lib/src/widgets/dialog.dart index 1c6be14ad18..9caaddbbd37 100644 --- a/packages/neon_framework/lib/src/widgets/dialog.dart +++ b/packages/neon_framework/lib/src/widgets/dialog.dart @@ -1,33 +1,525 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; +import 'package:neon_framework/blocs.dart'; +import 'package:neon_framework/src/blocs/accounts.dart'; +import 'package:neon_framework/src/theme/dialog.dart'; +import 'package:neon_framework/src/utils/global_options.dart'; +import 'package:neon_framework/src/widgets/account_tile.dart'; +import 'package:neon_framework/theme.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:url_launcher/url_launcher_string.dart'; -/// A Neon material design dialog based on [SimpleDialog]. +/// An button typically used in an [AlertDialog.adaptive]. +/// +/// It adaptively creates an [CupertinoDialogAction] based on the closest +/// [ThemeData.platform]. + +class NeonDialogAction extends StatelessWidget { + /// Creates a new adaptive Neon dialog action. + const NeonDialogAction({ + required this.onPressed, + required this.child, + this.isDefaultAction = false, + this.isDestructiveAction = false, + super.key, + }); + + /// The callback that is called when the button is tapped or otherwise + /// activated. + /// + /// If this is set to null, the button will be disabled. + final VoidCallback? onPressed; + + /// The widget below this widget in the tree. + /// + /// Typically a [Text] widget. + final Widget child; + + /// Set to true if button is the default choice in the dialog. + /// + /// Default buttons have higher emphasis. Similar to + /// [CupertinoDialogAction.isDefaultAction]. More than one action can have + /// this attribute set to true in the same [Dialog]. + /// + /// This parameters defaults to false and cannot be null. + final bool isDefaultAction; + + /// Whether this action destroys an object. + /// + /// For example, an action that deletes an email is destructive. + /// + /// Defaults to false and cannot be null. + final bool isDestructiveAction; + + @override + Widget build(final BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + switch (theme.platform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + if (isDestructiveAction) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: colorScheme.errorContainer, + foregroundColor: colorScheme.onErrorContainer, + ), + onPressed: onPressed, + child: child, + ); + } + + if (isDefaultAction) { + return ElevatedButton(onPressed: onPressed, child: child); + } + + return OutlinedButton(onPressed: onPressed, child: child); + + case TargetPlatform.iOS: + case TargetPlatform.macOS: + return CupertinoDialogAction( + onPressed: onPressed, + isDefaultAction: isDefaultAction, + isDestructiveAction: isDestructiveAction, + child: child, + ); + } + } +} + +/// A Neon design dialog based on [AlertDialog.adaptive]. +/// +/// THis widget enforces the closest [NeonDialogTheme] and constraints the +/// [content] width accordingly. The [title] should never be larger than the +/// [NeonDialogTheme.constraints] and it it up to the caller to handle this. class NeonDialog extends StatelessWidget { /// Creates a Neon dialog. /// /// Typically used in conjunction with [showDialog]. const NeonDialog({ + this.icon, this.title, - this.children, + this.content, + this.actions, + this.automaticallyShowCancel = true, super.key, }); + /// {@template NeonDialog.icon} + /// An optional icon to display at the top of the dialog. + /// + /// Typically, an [Icon] widget. Providing an icon centers the [title]'s text. + /// {@endtemplate} + final Widget? icon; + /// The (optional) title of the dialog is displayed in a large font at the top /// of the dialog. /// + /// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat. + /// /// Typically a [Text] widget. final Widget? title; - /// The (optional) content of the dialog is displayed in a - /// [SingleChildScrollView] underneath the title. + /// {@template NeonDialog.content} + /// The (optional) content of the dialog is displayed in the center of the + /// dialog in a lighter font. + /// + /// Typically this is a [SingleChildScrollView] that contains the dialog's + /// message. As noted in the [AlertDialog] documentation, it's important + /// to use a [SingleChildScrollView] if there's any risk that the content + /// will not fit, as the contents will otherwise overflow the dialog. + /// + /// The horizontal dimension of this widget is constrained by the closest + /// [NeonDialogTheme.constraints]. + /// {@endtemplate} + final Widget? content; + + /// The (optional) set of actions that are displayed at the bottom of the + /// dialog with an [OverflowBar]. + /// + /// Typically this is a list of [NeonDialogAction] widgets. It is recommended + /// to set the [Text.textAlign] to [TextAlign.end] for the [Text] within the + /// [TextButton], so that buttons whose labels wrap to an extra line align + /// with the overall [OverflowBar]'s alignment within the dialog. + /// + /// If the [title] is not null but the [content] _is_ null, then an extra 20 + /// pixels of padding is added above the [OverflowBar] to separate the [title] + /// from the [actions]. + final List? actions; + + /// Whether to automatically show a cancel button when only less than two + /// actions are supplied. + /// + /// This is needed for the ios where dialogs are not dismissible by tapping + /// outside their boundary. + /// + /// Defaults to `true`. + final bool automaticallyShowCancel; + + @override + Widget build(final BuildContext context) { + final theme = Theme.of(context); + final dialogTheme = NeonDialogTheme.of(context); + + var content = this.content; + if (content != null) { + content = ConstrainedBox( + constraints: dialogTheme.constraints, + child: content, + ); + } + + final needsCancelAction = automaticallyShowCancel && + (actions == null || actions!.length <= 1) && + (theme.platform == TargetPlatform.iOS || theme.platform == TargetPlatform.macOS); + + return AlertDialog.adaptive( + icon: icon, + title: title, + content: content, + actions: [ + if (needsCancelAction) + NeonDialogAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + NeonLocalizations.of(context).actionCancel, + textAlign: TextAlign.end, + ), + ), + ...?actions, + ], + ); + } +} + +/// A [NeonDialog] with predefined `actions` to confirm or decline. +class NeonConfirmationDialog extends StatelessWidget { + /// Creates a new confirmation dialog. + const NeonConfirmationDialog({ + required this.title, + this.content, + this.icon, + this.confirmAction, + this.declineAction, + this.isDestructive = true, + super.key, + }); + + /// The title of the dialog is displayed in a large font at the top of the + /// dialog. + /// + /// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat + /// and the text does not overflow. + final String title; + + /// {@macro NeonDialog.icon} + final Widget? icon; + + /// {@macro NeonDialog.content} + final Widget? content; + + /// An optional override for the confirming action. + /// + /// It is advised to wrap the action in a [Builder] to retain an up to date + /// `context` for the Navigator. + /// + /// Typically this is a [NeonDialogAction] widget. + final Widget? confirmAction; + + /// An optional override for the declining action. + /// + /// It is advised to wrap the action in a [Builder] to retain an up to date + /// `context` for the Navigator. + /// + /// Typically this is a [NeonDialogAction] widget. + final Widget? declineAction; + + /// Whether confirming this dialog destroys an object. /// - /// Typically a list of [SimpleDialogOption]s. + /// For example, a warning dialog that when accepted deletes an email is + /// considered destructive. + /// This value will set the default confirming action to being destructive. + /// + /// Defaults to true and cannot be null. + final bool isDestructive; + + @override + Widget build(final BuildContext context) { + final confirm = confirmAction ?? + NeonDialogAction( + isDestructiveAction: isDestructive, + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text( + NeonLocalizations.of(context).actionContinue, + textAlign: TextAlign.end, + ), + ); + + final decline = declineAction ?? + NeonDialogAction( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + NeonLocalizations.of(context).actionCancel, + textAlign: TextAlign.end, + ), + ); + + return NeonDialog( + icon: icon, + title: Text(title), + content: content, + actions: [ + decline, + confirm, + ], + ); + } +} + +/// A [NeonDialog] that shows for renaming an object. +/// +/// Use `showRenameDialog` to display this dialog. +/// +/// When submitted the new value will be popped as a `String`. +/// If the new value is equal to the provided one `null` will be popped. +class NeonRenameDialog extends StatefulWidget { + /// Creates a new Neon rename dialog. + const NeonRenameDialog({ + required this.title, + required this.value, + super.key, + }); + + /// The title of the dialog. + final String title; + + /// The initial value of the rename field. + /// + /// This is the current name of the object to be renamed. + final String value; + + @override + State createState() => _NeonRenameDialogState(); +} + +class _NeonRenameDialogState extends State { + final formKey = GlobalKey(); + final controller = TextEditingController(); + + @override + void initState() { + controller.text = widget.value; + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void submit() { + if (!formKey.currentState!.validate()) { + return; + } + + if (controller.text != widget.value) { + Navigator.of(context).pop(controller.text); + } else { + Navigator.of(context).pop(); + } + } + + @override + Widget build(final BuildContext context) { + final content = Material( + child: TextFormField( + autofocus: true, + controller: controller, + validator: (final input) => validateNotEmpty(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + ), + ); + + return NeonDialog( + title: Text(widget.title), + content: Form(key: formKey, child: content), + actions: [ + NeonDialogAction( + isDefaultAction: true, + onPressed: submit, + child: Text( + widget.title, + textAlign: TextAlign.end, + ), + ), + ], + ); + } +} + +/// A [NeonDialog] that informs the user about an error. +/// +/// Use `showErrorDialog` to display this dialog. +class NeonErrorDialog extends StatelessWidget { + /// Creates a new error dialog. + const NeonErrorDialog({ + required this.content, + this.title, + super.key, + }); + + /// The (optional) title for the dialog. + /// + /// Defaults to [NeonLocalizations.errorDialog]. + final String? title; + + /// The content of the dialog. + final String content; + + @override + Widget build(final BuildContext context) { + final title = this.title ?? NeonLocalizations.of(context).errorDialog; + + final closeAction = NeonDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text( + NeonLocalizations.of(context).actionClose, + textAlign: TextAlign.end, + ), + ); + + return NeonDialog( + automaticallyShowCancel: false, + icon: const Icon(Icons.error), + title: Text(title), + content: Text(content), + actions: [ + closeAction, + ], + ); + } +} + +/// Account selection dialog. +/// +/// Displays a list of all logged in accounts. +/// +/// When one is selected the dialog gets pooped with the selected `Account`. +@internal +class NeonAccountSelectionDialog extends StatelessWidget { + /// Creates a new account selection dialog. + const NeonAccountSelectionDialog({ + this.highlightActiveAccount = false, + this.children, + super.key, + }); + + /// Whether the selected account is highlighted with a leading check icon. + final bool highlightActiveAccount; + + /// The (optional) trailing children of this dialog. final List? children; @override - Widget build(final BuildContext context) => SimpleDialog( - titlePadding: const EdgeInsets.all(10), - contentPadding: const EdgeInsets.all(10), - title: title, - children: children, + Widget build(final BuildContext context) { + final dialogTheme = NeonDialogTheme.of(context); + final accountsBloc = NeonProvider.of(context); + final accounts = accountsBloc.accounts.value; + final activeAccount = accountsBloc.activeAccount.value!; + + final sortedAccounts = List.of(accounts) + ..removeWhere((final account) => account.id == activeAccount.id) + ..insert(0, activeAccount); + + final tiles = sortedAccounts + .map( + (final account) => NeonAccountTile( + account: account, + trailing: highlightActiveAccount && account.id == activeAccount.id ? const Icon(Icons.check_circle) : null, + onTap: () { + Navigator.of(context).pop(account); + }, + ), + ) + .toList(); + if (highlightActiveAccount && accounts.length > 1) { + tiles.insert(1, const Divider()); + } + + final body = SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...tiles, + ...?children, + ], + ), + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: dialogTheme.padding, + constraints: dialogTheme.constraints, + child: body, + ), + ), + ); + } +} + +/// A [NeonDialog] to inform the user about the UnifiedPush feature of neon. +@internal +class NeonUnifiedPushDialog extends StatelessWidget { + /// Creates a new UnifiedPush dialog. + const NeonUnifiedPushDialog({ + super.key, + }); + + @override + Widget build(final BuildContext context) => NeonDialog( + title: Text(NeonLocalizations.of(context).nextPushSupported), + content: Text(NeonLocalizations.of(context).nextPushSupportedText), + actions: [ + NeonDialogAction( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + NeonLocalizations.of(context).actionCancel, + textAlign: TextAlign.end, + ), + ), + NeonDialogAction( + isDefaultAction: true, + onPressed: () async { + Navigator.pop(context); + await launchUrlString( + 'https://f-droid.org/packages/$unifiedPushNextPushID', + mode: LaunchMode.externalApplication, + ); + }, + child: Text( + NeonLocalizations.of(context).nextPushSupportedInstall, + textAlign: TextAlign.end, + ), + ), + ], ); } diff --git a/packages/neon_framework/lib/utils.dart b/packages/neon_framework/lib/utils.dart index ce3c6634747..a1d70de16fe 100644 --- a/packages/neon_framework/lib/utils.dart +++ b/packages/neon_framework/lib/utils.dart @@ -1,10 +1,9 @@ export 'package:neon_framework/l10n/localizations.dart'; export 'package:neon_framework/src/utils/app_route.dart'; -export 'package:neon_framework/src/utils/confirmation_dialog.dart'; +export 'package:neon_framework/src/utils/dialog.dart'; export 'package:neon_framework/src/utils/exceptions.dart'; export 'package:neon_framework/src/utils/findable.dart'; export 'package:neon_framework/src/utils/hex_color.dart'; export 'package:neon_framework/src/utils/provider.dart'; -export 'package:neon_framework/src/utils/rename_dialog.dart'; export 'package:neon_framework/src/utils/request_manager.dart' hide Cache; export 'package:neon_framework/src/utils/validators.dart'; diff --git a/packages/neon_framework/lib/widgets.dart b/packages/neon_framework/lib/widgets.dart index 7588cc3f26e..da828ef4f66 100644 --- a/packages/neon_framework/lib/widgets.dart +++ b/packages/neon_framework/lib/widgets.dart @@ -1,5 +1,5 @@ export 'package:neon_framework/src/widgets/custom_background.dart'; -export 'package:neon_framework/src/widgets/dialog.dart'; +export 'package:neon_framework/src/widgets/dialog.dart' hide NeonAccountSelectionDialog, NeonUnifiedPushDialog; export 'package:neon_framework/src/widgets/error.dart'; export 'package:neon_framework/src/widgets/image.dart'; export 'package:neon_framework/src/widgets/linear_progress_indicator.dart'; diff --git a/packages/neon_framework/test/dialog_test.dart b/packages/neon_framework/test/dialog_test.dart new file mode 100644 index 00000000000..53ccf89586c --- /dev/null +++ b/packages/neon_framework/test/dialog_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:neon_framework/l10n/localizations_en.dart'; +import 'package:neon_framework/src/theme/theme.dart'; +import 'package:neon_framework/src/widgets/dialog.dart'; +import 'package:neon_framework/utils.dart'; + +Widget wrapDialog(final Widget dialog, [final TargetPlatform platform = TargetPlatform.android]) { + final theme = AppTheme.test(platform: platform); + const locale = Locale('en'); + + return MaterialApp( + theme: theme.lightTheme, + localizationsDelegates: NeonLocalizations.localizationsDelegates, + supportedLocales: NeonLocalizations.supportedLocales, + locale: locale, + home: dialog, + ); +} + +void main() { + group('dialog', () { + group('NeonConfirmationDialog', () { + testWidgets('NeonConfirmationDialog widget', (final widgetTester) async { + const title = 'My Title'; + var dialog = const NeonConfirmationDialog(title: title); + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.text(title), findsOneWidget); + expect(find.byType(NeonDialogAction), findsExactly(2)); + + // Not true on cupertino platforms + expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.byType(ElevatedButton), findsOneWidget); + + dialog = const NeonConfirmationDialog( + title: title, + isDestructive: false, + ); + + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.byType(NeonDialogAction), findsExactly(2)); + expect(find.byType(OutlinedButton), findsExactly(2)); + + const icon = Icon(Icons.error); + const content = SizedBox(key: Key('content')); + const confirmAction = SizedBox(key: Key('confirmAction')); + const declineAction = SizedBox(key: Key('declineAction')); + dialog = const NeonConfirmationDialog( + title: title, + icon: icon, + content: content, + confirmAction: confirmAction, + declineAction: declineAction, + ); + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.byIcon(Icons.error), findsOneWidget); + expect(find.byKey(const Key('content')), findsOneWidget); + expect(find.byKey(const Key('confirmAction')), findsOneWidget); + expect(find.byKey(const Key('declineAction')), findsOneWidget); + }); + + testWidgets('NeonConfirmationDialog actions', (final widgetTester) async { + const title = 'My Title'; + await widgetTester.pumpWidget(wrapDialog(const Placeholder())); + final context = widgetTester.element(find.byType(Placeholder)); + + // confirm + var result = showConfirmationDialog(context: context, title: title); + await widgetTester.pumpAndSettle(); + await widgetTester.tap(find.text(NeonLocalizationsEn().actionContinue)); + expect(await result, isTrue); + + // decline + result = showConfirmationDialog(context: context, title: title); + await widgetTester.pumpAndSettle(); + await widgetTester.tap(find.text(NeonLocalizationsEn().actionCancel)); + expect(await result, isFalse); + + // cancel by tapping outside + result = showConfirmationDialog(context: context, title: title); + await widgetTester.pumpAndSettle(); + await widgetTester.tapAt(Offset.zero); + expect(await result, isFalse); + }); + }); + + group('NeonRenameDialog', () { + testWidgets('NeonRenameDialog widget', (final widgetTester) async { + const title = 'My Title'; + const value = 'My value'; + const dialog = NeonRenameDialog(title: title, value: value); + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.text(title), findsExactly(2), reason: 'The title is also used for the confirmation button'); + expect(find.text(value), findsOneWidget); + expect(find.byType(TextFormField), findsOneWidget); + }); + + testWidgets('NeonRenameDialog actions', (final widgetTester) async { + const title = 'My Title'; + const value = 'My value'; + await widgetTester.pumpWidget(wrapDialog(const Placeholder())); + final context = widgetTester.element(find.byType(Placeholder)); + + // Equal value should not submit + var result = showRenameDialog(context: context, title: title, initialValue: value); + await widgetTester.pumpAndSettle(); + await widgetTester.enterText(find.byType(TextFormField), value); + await widgetTester.tap(find.byType(NeonDialogAction)); + expect(await result, isNull); + + // Empty value should not submit + result = showRenameDialog(context: context, title: title, initialValue: value); + await widgetTester.pumpAndSettle(); + await widgetTester.enterText(find.byType(TextFormField), ''); + await widgetTester.tap(find.byType(NeonDialogAction)); + + // Different value should submit + await widgetTester.enterText(find.byType(TextFormField), 'My new value'); + await widgetTester.tap(find.byType(NeonDialogAction)); + expect(await result, equals('My new value')); + + // Submit via keyboard + result = showRenameDialog(context: context, title: title, initialValue: value); + await widgetTester.pumpAndSettle(); + await widgetTester.enterText(find.byType(TextFormField), 'My new value'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.tap(find.byType(NeonDialogAction)); + expect(await result, equals('My new value')); + }); + }); + + group('NeonErrorDialog', () { + testWidgets('NeonErrorDialog widget', (final widgetTester) async { + const title = 'My Title'; + const content = 'My content'; + var dialog = const NeonErrorDialog(content: content, title: title); + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.byIcon(Icons.error), findsOneWidget); + expect(find.text(title), findsOneWidget); + expect(find.text(content), findsOneWidget); + expect(find.byType(NeonDialogAction), findsOneWidget); + + dialog = const NeonErrorDialog(content: content); + await widgetTester.pumpWidget(wrapDialog(dialog)); + + expect(find.text(NeonLocalizationsEn().errorDialog), findsOneWidget); + }); + + testWidgets('NeonErrorDialog actions', (final widgetTester) async { + const content = 'My content'; + await widgetTester.pumpWidget(wrapDialog(const Placeholder())); + final context = widgetTester.element(find.byType(Placeholder)); + + final result = showErrorDialog(context: context, message: content); + await widgetTester.pumpAndSettle(); + await widgetTester.tap(find.text(NeonLocalizationsEn().actionClose)); + await result; + }); + }); + + testWidgets('UnimplementedDialog', (final widgetTester) async { + const title = 'My Title'; + await widgetTester.pumpWidget(wrapDialog(const Placeholder())); + final context = widgetTester.element(find.byType(Placeholder)); + + final result = showUnimplementedDialog(context: context, title: title); + await widgetTester.pumpAndSettle(); + await widgetTester.tap(find.text(NeonLocalizationsEn().actionClose)); + await result; + }); + + testWidgets('NeonDialog', (final widgetTester) async { + var dialog = const NeonDialog( + actions: [], + ); + await widgetTester.pumpWidget(wrapDialog(dialog, TargetPlatform.macOS)); + expect( + find.byType(NeonDialogAction), + findsOneWidget, + reason: 'Dialogs can not be dismissed on cupertino platforms. Expecting a fallback action.', + ); + + dialog = const NeonDialog( + automaticallyShowCancel: false, + actions: [], + ); + await widgetTester.pumpWidget(wrapDialog(dialog, TargetPlatform.macOS)); + expect(find.byType(NeonDialogAction), findsNothing); + }); + }); +}