diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index cfc0f28e21a..a542875506a 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/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/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index b588aaf2a57..4a0793ccf78 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/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/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index e8df967a511..18183d59b71 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/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/neon/lib/src/pages/account_settings.dart b/packages/neon/neon/lib/src/pages/account_settings.dart index 77f9868b97e..52360c5d32a 100644 --- a/packages/neon/neon/lib/src/pages/account_settings.dart +++ b/packages/neon/neon/lib/src/pages/account_settings.dart @@ -13,7 +13,7 @@ import 'package:neon/src/settings/widgets/settings_category.dart'; import 'package:neon/src/settings/widgets/settings_list.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/adaptive.dart'; -import 'package:neon/src/utils/dialog.dart'; +import 'package:neon/src/widgets/dialog.dart'; import 'package:neon/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<bool>( + 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<bool>( + 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/neon/lib/src/pages/home.dart b/packages/neon/neon/lib/src/pages/home.dart index c82cd0eb0b8..951dc7de076 100644 --- a/packages/neon/neon/lib/src/pages/home.dart +++ b/packages/neon/neon/lib/src/pages/home.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; -import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/bloc/result.dart'; import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/apps.dart'; @@ -11,11 +10,11 @@ import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/global_options.dart' as global_options; import 'package:neon/src/utils/global_popups.dart'; -import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/widgets/app_bar.dart'; import 'package:neon/src/widgets/drawer.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/unified_search_results.dart'; +import 'package:neon/utils.dart'; import 'package:nextcloud/core.dart' as core; import 'package:provider/provider.dart'; @@ -68,7 +67,7 @@ class _HomePageState extends State<HomePage> { } final message = l10n.errorUnsupportedAppVersions(buffer.toString()); - unawaited(_showProblem(message)); + unawaited(showErrorDialog(context: context, message: message)); }); GlobalPopups().register(context); @@ -86,10 +85,10 @@ class _HomePageState extends State<HomePage> { Future<void> _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()); @@ -100,29 +99,6 @@ class _HomePageState extends State<HomePage> { } } - Future<void> _showProblem(final String title) async { - final colorScheme = Theme.of(context).colorScheme; - - await showDialog<void>( - 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/neon/lib/src/pages/nextcloud_app_settings.dart b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart index a00b29a47b0..f4dccba3670 100644 --- a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart +++ b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart @@ -7,7 +7,7 @@ import 'package:neon/src/settings/widgets/option_settings_tile.dart'; import 'package:neon/src/settings/widgets/settings_category.dart'; import 'package:neon/src/settings/widgets/settings_list.dart'; import 'package:neon/src/theme/dialog.dart'; -import 'package:neon/src/utils/dialog.dart'; +import 'package:neon/src/widgets/dialog.dart'; @internal class NextcloudAppSettingsPage extends StatelessWidget { @@ -25,10 +25,19 @@ class NextcloudAppSettingsPage 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<bool>( + 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/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index f92703f5513..017edbfc93e 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -19,10 +19,10 @@ import 'package:neon/src/settings/widgets/text_settings_tile.dart'; import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/adaptive.dart'; -import 'package:neon/src/utils/dialog.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/save_file.dart'; +import 'package:neon/src/widgets/dialog.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -96,7 +96,18 @@ class _SettingsPageState extends State<SettingsPage> { 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<bool>( + 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/neon/lib/src/theme/dialog.dart b/packages/neon/neon/lib/src/theme/dialog.dart index 8273d98176b..335042abc4b 100644 --- a/packages/neon/neon/lib/src/theme/dialog.dart +++ b/packages/neon/neon/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/neon/lib/src/utils/confirmation_dialog.dart b/packages/neon/neon/lib/src/utils/confirmation_dialog.dart deleted file mode 100644 index 944b994d4b9..00000000000 --- a/packages/neon/neon/lib/src/utils/confirmation_dialog.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:neon/l10n/localizations.dart'; -import 'package:neon/src/theme/colors.dart'; - -Future<bool> showConfirmationDialog(final BuildContext context, final String title) async => - await showDialog<bool>( - 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/neon/lib/src/utils/dialog.dart b/packages/neon/neon/lib/src/utils/dialog.dart index 35275f32406..82aa0851143 100644 --- a/packages/neon/neon/lib/src/utils/dialog.dart +++ b/packages/neon/neon/lib/src/utils/dialog.dart @@ -1,27 +1,69 @@ import 'package:flutter/material.dart'; - +import 'package:neon/l10n/localizations.dart'; import 'package:neon/src/widgets/dialog.dart'; -Future<bool> showConfirmationDialog(final BuildContext context, final String title) async => - await showDialog<bool>( +/// Displays a simple [NeonConfirmationDialog] with the given [title]. +/// +/// Returns a future whether the action has been accepted. +Future<bool> showConfirmationDialog({ + required final BuildContext context, + required final String title, +}) async => + await showAdaptiveDialog<bool>( context: context, - builder: (final context) => NeonConfirmationDialog( - title: title, - ), + 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<String?> showRenameDialog({ required final BuildContext context, required final String title, - required final String value, - final Key? key, + required final String initialValue, }) async => - showDialog<String?>( + showAdaptiveDialog<String?>( context: context, - builder: (final context) => RenameDialog( + builder: (final context) => NeonRenameDialog( title: title, - value: value, - key: key, + value: initialValue, + ), + ); + +/// Displays a [NeonErrorDialog] with the given [message]. +Future<void> showErrorDialog({ + required final BuildContext context, + required final String message, + final String? title, +}) => + showAdaptiveDialog<void>( + 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<void> showUnimplementedDialog({ + required final BuildContext context, + required final String title, +}) => + showAdaptiveDialog<void>( + 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/neon/lib/src/utils/global_popups.dart b/packages/neon/neon/lib/src/utils/global_popups.dart index 897e28f5a12..945765f3bd9 100644 --- a/packages/neon/neon/lib/src/utils/global_popups.dart +++ b/packages/neon/neon/lib/src/utils/global_popups.dart @@ -10,7 +10,7 @@ import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; +import 'package:neon/src/widgets/dialog.dart'; /// Singleton class managing global popups. @internal @@ -88,30 +88,9 @@ class GlobalPopups { return; } - await showDialog<void>( + await showAdaptiveDialog<void>( 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/neon/lib/src/widgets/dialog.dart b/packages/neon/neon/lib/src/widgets/dialog.dart index 3fc96406942..01a65c6edd2 100644 --- a/packages/neon/neon/lib/src/widgets/dialog.dart +++ b/packages/neon/neon/lib/src/widgets/dialog.dart @@ -1,77 +1,283 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; -import 'package:neon/blocs.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/utils/global_options.dart'; +import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/utils/validators.dart'; import 'package:neon/src/widgets/account_tile.dart'; -import 'package:neon/theme.dart'; -import 'package:neon/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 a list of [SimpleDialogOption]s. - final List<Widget>? children; + /// 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<Widget>? 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) => SimpleDialog( - titlePadding: const EdgeInsets.all(10), - contentPadding: const EdgeInsets.all(10), - title: title, - children: children, + 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. + /// + /// 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 = 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), - ); + final confirm = confirmAction ?? + NeonDialogAction( + isDestructiveAction: isDestructive, + onPressed: () { + Navigator.of(context).pop(true); + }, + child: Text( + NeonLocalizations.of(context).actionContinue, + textAlign: TextAlign.end, + ), + ); - final decline = 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), - ); + final decline = declineAction ?? + NeonDialogAction( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + NeonLocalizations.of(context).actionCancel, + textAlign: TextAlign.end, + ), + ); - return AlertDialog( + return NeonDialog( + icon: icon, title: Text(title), - actionsAlignment: MainAxisAlignment.spaceEvenly, + content: content, actions: [ decline, confirm, @@ -80,23 +286,33 @@ class NeonConfirmationDialog extends StatelessWidget { } } -class RenameDialog extends StatefulWidget { - const RenameDialog({ +/// 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`. +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<RenameDialog> createState() => _RenameDialogState(); + State<NeonRenameDialog> createState() => _NeonRenameDialogState(); } -class _RenameDialogState extends State<RenameDialog> { +class _NeonRenameDialogState extends State<NeonRenameDialog> { final formKey = GlobalKey<FormState>(); - final controller = TextEditingController(); @override @@ -118,42 +334,99 @@ class _RenameDialogState extends State<RenameDialog> { } @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), - ), - ], - ), + 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<Widget>? children; @override @@ -195,7 +468,7 @@ class NeonAccountSelectionDialog extends StatelessWidget { return Dialog( child: IntrinsicHeight( child: Container( - padding: const EdgeInsets.all(24), + padding: dialogTheme.padding, constraints: dialogTheme.constraints, child: body, ), @@ -203,3 +476,43 @@ class NeonAccountSelectionDialog extends StatelessWidget { ); } } + +/// 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/neon/lib/widgets.dart b/packages/neon/neon/lib/widgets.dart index 523fd2c7e72..c3820cad021 100644 --- a/packages/neon/neon/lib/widgets.dart +++ b/packages/neon/neon/lib/widgets.dart @@ -1,4 +1,4 @@ -export 'package:neon/src/widgets/dialog.dart' hide NeonAccountSelectionDialog; +export 'package:neon/src/widgets/dialog.dart' show NeonConfirmationDialog, NeonDialog, NeonDialogAction; export 'package:neon/src/widgets/error.dart'; export 'package:neon/src/widgets/image.dart'; export 'package:neon/src/widgets/linear_progress_indicator.dart'; 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<Locale> supportedLocales = <Locale>[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/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index f2eda4d94d0..025e2737caa 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -42,7 +42,7 @@ import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/routes.dart'; -import 'package:neon_files/widgets/dialog.dart'; +import 'package:neon_files/utils/dialog.dart'; import 'package:neon_files/widgets/file_list_tile.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; diff --git a/packages/neon/neon_files/lib/pages/details.dart b/packages/neon/neon_files/lib/pages/details.dart index 36d50c1f9bc..a8d3c8c54ac 100644 --- a/packages/neon/neon_files/lib/pages/details.dart +++ b/packages/neon/neon_files/lib/pages/details.dart @@ -56,8 +56,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/pages/main.dart b/packages/neon/neon_files/lib/pages/main.dart index 4e55a0c0724..37b3579d3b6 100644 --- a/packages/neon/neon_files/lib/pages/main.dart +++ b/packages/neon/neon_files/lib/pages/main.dart @@ -29,15 +29,7 @@ class _FilesMainPageState extends State<FilesMainPage> { filesBloc: bloc, ), floatingActionButton: FloatingActionButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (final context) => FilesChooseCreateDialog( - bloc: bloc, - basePath: bloc.browser.path.value, - ), - ); - }, + onPressed: () async => showFilesCreateModal(context), tooltip: FilesLocalizations.of(context).uploadFiles, child: const Icon(Icons.add), ), diff --git a/packages/neon/neon_files/lib/utils/dialog.dart b/packages/neon/neon_files/lib/utils/dialog.dart new file mode 100644 index 00000000000..5ac349d78c5 --- /dev/null +++ b/packages/neon/neon_files/lib/utils/dialog.dart @@ -0,0 +1,128 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:neon/utils.dart'; +import 'package:neon/widgets.dart'; +import 'package:neon_files/l10n/localizations.dart'; +import 'package:neon_files/neon_files.dart'; +import 'package:neon_files/widgets/dialog.dart'; + +/// Displays a [FilesCreateFolderDialog] for creating a new folder. +/// +/// Returns a future with the folder name split by `/`. +Future<String?> showFolderCreateDialog({ + required final BuildContext context, +}) => + showAdaptiveDialog<String>( + 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<bool> showDownloadConfirmationDialog( + final BuildContext context, + final int warningSize, + final int actualSize, +) async => + await showAdaptiveDialog<bool>( + 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<bool> showUploadConfirmationDialog( + final BuildContext context, + final int warningSize, + final int actualSize, +) async => + await showAdaptiveDialog<bool>( + 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<List<String>?> showChooseFolderDialog(final BuildContext context, final FileDetails details) async { + final bloc = NeonProvider.of<FilesBloc>(context); + + final originalPath = details.path; + final b = bloc.getNewFilesBrowserBloc(initialPath: originalPath); + final result = await showDialog<List<String>>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: bloc, + originalPath: originalPath, + ), + ); + 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<bool> showDeleteConfirmationDialog(final BuildContext context, final FileDetails details) async => + await showAdaptiveDialog<bool>( + 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<void> showFilesCreateModal(final BuildContext context) { + final theme = Theme.of(context); + final bloc = NeonProvider.of<FilesBloc>(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/widgets/actions.dart b/packages/neon/neon_files/lib/widgets/actions.dart index 2547d70b690..6f777111945 100644 --- a/packages/neon/neon_files/lib/widgets/actions.dart +++ b/packages/neon/neon_files/lib/widgets/actions.dart @@ -1,10 +1,9 @@ -import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:neon/platform.dart'; import 'package:neon/utils.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/neon_files.dart'; -import 'package:neon_files/widgets/dialog.dart'; +import 'package:neon_files/utils/dialog.dart'; class FileActions extends StatelessWidget { const FileActions({ @@ -44,7 +43,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.path, result); @@ -53,17 +52,8 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - final originalPath = details.path.sublist(0, details.path.length - 1); - final b = bloc.getNewFilesBrowserBloc(initialPath: originalPath); - final result = await showDialog<List<String>?>( - 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.path, result..add(details.name)); } @@ -71,17 +61,8 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - final originalPath = details.path.sublist(0, details.path.length - 1); - final b = bloc.getNewFilesBrowserBloc(initialPath: originalPath); - final result = await showDialog<List<String>?>( - 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.path, result..add(details.name)); } @@ -91,13 +72,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; } } @@ -106,12 +83,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.path); } } @@ -120,13 +93,12 @@ class FileActions extends StatelessWidget { @override Widget build(final BuildContext context) => PopupMenuButton<FilesFileAction>( 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( @@ -135,7 +107,7 @@ class FileActions extends StatelessWidget { : FilesLocalizations.of(context).addToFavorites, ), ), - ], + PopupMenuItem( value: FilesFileAction.details, child: Text(FilesLocalizations.of(context).details), @@ -153,12 +125,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/widgets/dialog.dart b/packages/neon/neon_files/lib/widgets/dialog.dart index bdec62ce3f6..3daabc1ef1e 100644 --- a/packages/neon/neon_files/lib/widgets/dialog.dart +++ b/packages/neon/neon_files/lib/widgets/dialog.dart @@ -3,37 +3,54 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:filesize/filesize.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/platform.dart'; +import 'package:neon/theme.dart'; import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/neon_files.dart'; +import 'package:neon_files/utils/dialog.dart'; import 'package:path/path.dart' as p; -class FilesChooseCreateDialog extends StatefulWidget { - const FilesChooseCreateDialog({ +/// 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, - required this.basePath, super.key, }); + /// The bloc of the flies client. final FilesBloc bloc; - final List<String> basePath; @override - State<FilesChooseCreateDialog> createState() => _FilesChooseCreateDialogState(); + State<FilesChooseCreateModal> createState() => _FilesChooseCreateModalState(); } -class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> { +class _FilesChooseCreateModalState extends State<FilesChooseCreateModal> { + late List<String> basePath; + + @override + void initState() { + basePath = widget.bloc.browser.path.value; + + super.initState(); + } + Future<void> 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!)); @@ -46,92 +63,147 @@ class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> { if (sizeWarning != null) { final stat = file.statSync(); if (stat.size > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).uploadConfirmSizeWarning( - filesize(sizeWarning), - filesize(stat.size), - ), - ))) { + final result = await showUploadConfirmationDialog(context, sizeWarning, stat.size); + + if (!result) { return; } } } - widget.bloc.uploadFile([...widget.basePath, p.basename(file.path)], file.path); + widget.bloc.uploadFile([...basePath, 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) => 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); + Widget build(final BuildContext context) { + final theme = Theme.of(context); + final title = FilesLocalizations.of(context).filesChooseCreate; - if (mounted) { - Navigator.of(context).pop(); - } - }, + 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, ), - ListTile( - leading: Icon( - MdiIcons.fileImagePlus, - color: Theme.of(context).colorScheme.primary, - ), - title: Text(FilesLocalizations.of(context).uploadImages), - onTap: () async { - await uploadFromPick(FileType.image); + message: Text(FilesLocalizations.of(context).uploadCamera), + onPressed: () async { + Navigator.of(context).pop(); - 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, + 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([...basePath, ...result.split('/')]); + } + }, + ), + ]; + + 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, + ], ), - title: Text(FilesLocalizations.of(context).folderCreate), - onTap: () async { - Navigator.of(context).pop(); - - final result = await showDialog<List<String>>( - context: context, - builder: (final context) => const FilesCreateFolderDialog(), - ); - if (result != null) { - widget.bloc.browser.createFolder([...widget.basePath, ...result]); - } - }, ), - ], - ); + ); + + 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, @@ -142,61 +214,68 @@ class FilesChooseFolderDialog extends StatelessWidget { final FilesBrowserBloc bloc; final FilesBloc filesBloc; + /// The initial path to start at. final List<String> 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<List<String>>( - stream: bloc.path, - builder: (final context, final pathSnapshot) => pathSnapshot.hasData - ? Container( - margin: const EdgeInsets.all(10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton( - onPressed: () async { - final result = await showDialog<List<String>>( - context: context, - builder: (final context) => const FilesCreateFolderDialog(), - ); - if (result != null) { - bloc.createFolder([...pathSnapshot.requireData, ...result]); - } - }, - child: Text(FilesLocalizations.of(context).folderCreate), - ), - ElevatedButton( - onPressed: !(const ListEquality<String>().equals(originalPath, pathSnapshot.data)) - ? () => Navigator.of(context).pop(pathSnapshot.data) - : null, - child: Text(FilesLocalizations.of(context).folderChoose), - ), - ], - ), - ) - : const SizedBox(), + Widget build(final BuildContext context) { + final dialogTheme = NeonDialogTheme.of(context); + + return StreamBuilder<List<String>>( + stream: bloc.path, + builder: (final context, final pathSnapshot) { + final actions = [ + OutlinedButton( + onPressed: () async { + final result = await showFolderCreateDialog(context: context); + + if (result != null) { + bloc.createFolder([...pathSnapshot.requireData, ...result.split('/')]); + } + }, + child: Text( + FilesLocalizations.of(context).folderCreate, + textAlign: TextAlign.end, + ), + ), + ElevatedButton( + onPressed: !(const ListEquality<String>().equals(originalPath, pathSnapshot.data)) + ? () => Navigator.of(context).pop(pathSnapshot.data) + : null, + 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, + mode: FilesBrowserMode.selectDirectory, ), - ], + ), ), - ), - ); + actions: pathSnapshot.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, }); @@ -207,7 +286,6 @@ class FilesCreateFolderDialog extends StatefulWidget { class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> { final formKey = GlobalKey<FormState>(); - final controller = TextEditingController(); @override @@ -218,37 +296,42 @@ class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> { void submit() { if (formKey.currentState!.validate()) { - Navigator.of(context).pop(controller.text.split('/')); + 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), - ), - ], - ), + 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/widgets/file_list_tile.dart b/packages/neon/neon_files/lib/widgets/file_list_tile.dart index 85426f9fec5..f609c39de87 100644 --- a/packages/neon/neon_files/lib/widgets/file_list_tile.dart +++ b/packages/neon/neon_files/lib/widgets/file_list_tile.dart @@ -2,10 +2,9 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:neon/theme.dart'; -import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; -import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/neon_files.dart'; +import 'package:neon_files/utils/dialog.dart'; import 'package:neon_files/widgets/actions.dart'; class FileListTile extends StatelessWidget { @@ -28,13 +27,9 @@ class FileListTile extends StatelessWidget { } else if (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; } } 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<Locale> supportedLocales = <Locale>[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/neon_news.dart b/packages/neon/neon_news/lib/neon_news.dart index 8b292e6b9f7..8df743dd4b2 100644 --- a/packages/neon/neon_news/lib/neon_news.dart +++ b/packages/neon/neon_news/lib/neon_news.dart @@ -43,6 +43,7 @@ import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/routes.dart'; +import 'package:neon_news/utils/dialog.dart'; import 'package:neon_news/widgets/dialog.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/news.dart' as news; 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..ea5ec88e98e --- /dev/null +++ b/packages/neon/neon_news/lib/utils/dialog.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:neon/utils.dart'; +import 'package:neon/widgets.dart'; +import 'package:neon_news/l10n/localizations.dart'; +import 'package:neon_news/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<bool> showDeleteFeedDialog(final BuildContext context, final Feed feed) async { + final content = NewsLocalizations.of(context).feedRemoveConfirm(feed.title); + + final result = await showAdaptiveDialog<bool>( + 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<String?> showFolderCreateDialog({ + required final BuildContext context, +}) => + showAdaptiveDialog<String>( + context: context, + builder: (final context) => const NewsCreateFolderDialog(), + ); + +/// Displays a [NeonConfirmationDialog] for deleting a folder. +/// +/// Returns a future whether the action has been accepted. +Future<bool> showFolderDeleteDialog({ + required final BuildContext context, + required final String folderName, +}) async { + final content = NewsLocalizations.of(context).folderDeleteConfirm(folderName); + + final result = await showAdaptiveDialog<bool>( + 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<String?> 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/lib/widgets/dialog.dart b/packages/neon/neon_news/lib/widgets/dialog.dart index b1cdce0a14b..5dd0a589700 100644 --- a/packages/neon/neon_news/lib/widgets/dialog.dart +++ b/packages/neon/neon_news/lib/widgets/dialog.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:neon/blocs.dart'; @@ -9,14 +10,21 @@ import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/neon_news.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 @@ -58,146 +66,114 @@ class _NewsAddFeedDialogState extends State<NewsAddFeedDialog> { } @override - Widget build(final BuildContext context) => ResultBuilder<List<news.Folder>>.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), - ), - ], - ), - ), - ], + Widget build(final BuildContext context) { + final urlField = Form( + key: formKey, + child: TextFormField( + autofocus: true, + controller: controller, + decoration: const InputDecoration( + hintText: 'https://...', ), - ); -} - -class NewsCreateFolderDialog extends StatefulWidget { - const NewsCreateFolderDialog({ - super.key, - }); - - @override - State<NewsCreateFolderDialog> createState() => _NewsCreateFolderDialogState(); -} - -class _NewsCreateFolderDialogState extends State<NewsCreateFolderDialog> { - final formKey = GlobalKey<FormState>(); - - final controller = TextEditingController(); + keyboardType: TextInputType.url, + validator: (final input) => validateHttpUrl(context, input), + onFieldSubmitted: (final _) { + submit(); + }, + autofillHints: const [AutofillHints.url], + ), + ); - @override - void dispose() { - controller.dispose(); - super.dispose(); - } + final folderSelector = ResultBuilder<List<news.Folder>>.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, + ), + ); + } - void submit() { - if (formKey.currentState!.validate()) { - Navigator.of(context).pop(controller.text); - } - } + return NewsFolderSelect( + folders: folders.requireData, + value: folder, + onChanged: (final f) { + setState(() { + folder = f; + }); + }, + ); + }, + ); - @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), - ), - ], - ), + 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, ), - ], - ); + ), + ], + ); + } } -class NewsFeedShowURLDialog extends StatefulWidget { +/// 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 - State<NewsFeedShowURLDialog> createState() => _NewsFeedShowURLDialogState(); -} - -class _NewsFeedShowURLDialogState extends State<NewsFeedShowURLDialog> { - @override - Widget build(final BuildContext context) => AlertDialog( - title: Text(widget.feed.url), + Widget build(final BuildContext context) => NeonDialog( + title: Text(feed.url), actions: [ - ElevatedButton( + 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: widget.feed.url, + text: feed.url, ), ); - if (mounted) { + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(NewsLocalizations.of(context).feedCopiedURL), @@ -206,19 +182,16 @@ class _NewsFeedShowURLDialogState extends State<NewsFeedShowURLDialog> { Navigator.of(context).pop(); } }, - child: Text(NewsLocalizations.of(context).feedCopyURL), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NewsLocalizations.of(context).actionClose), + child: Text( + NewsLocalizations.of(context).feedCopyURL, + textAlign: TextAlign.end, + ), ), ], ); } -class NewsFeedUpdateErrorDialog extends StatefulWidget { +class NewsFeedUpdateErrorDialog extends StatelessWidget { const NewsFeedUpdateErrorDialog({ required this.feed, super.key, @@ -227,22 +200,27 @@ class NewsFeedUpdateErrorDialog extends StatefulWidget { final news.Feed feed; @override - State<NewsFeedUpdateErrorDialog> createState() => _NewsFeedUpdateErrorDialogState(); -} - -class _NewsFeedUpdateErrorDialogState extends State<NewsFeedUpdateErrorDialog> { - @override - Widget build(final BuildContext context) => AlertDialog( - title: Text(widget.feed.lastUpdateError!), + Widget build(final BuildContext context) => NeonDialog( + title: Text(feed.lastUpdateError!), actions: [ - ElevatedButton( + 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: widget.feed.lastUpdateError!, + text: feed.lastUpdateError!, ), ); - if (mounted) { + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), @@ -251,26 +229,30 @@ class _NewsFeedUpdateErrorDialogState extends State<NewsFeedUpdateErrorDialog> { Navigator.of(context).pop(); } }, - child: Text(NewsLocalizations.of(context).feedCopyErrorMessage), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: Text(NewsLocalizations.of(context).actionClose), + 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<news.Folder> folders; + + /// The feed to move. final news.Feed feed; @override @@ -284,37 +266,110 @@ class _NewsMoveFeedDialogState extends State<NewsMoveFeedDialog> { void submit() { if (formKey.currentState!.validate()) { - Navigator.of(context).pop([folder?.id]); + 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), - children: [ - Form( + content: Material( + child: 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), - ), - ], + 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<NewsCreateFolderDialog> createState() => _NewsCreateFolderDialogState(); +} + +class _NewsCreateFolderDialogState extends State<NewsCreateFolderDialog> { + final formKey = GlobalKey<FormState>(); + 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/widgets/feed_floating_action_button.dart b/packages/neon/neon_news/lib/widgets/feed_floating_action_button.dart index 79a8293ce57..fd62575fb0b 100644 --- a/packages/neon/neon_news/lib/widgets/feed_floating_action_button.dart +++ b/packages/neon/neon_news/lib/widgets/feed_floating_action_button.dart @@ -13,7 +13,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/widgets/feeds_view.dart b/packages/neon/neon_news/lib/widgets/feeds_view.dart index f36b6672e56..0f85ff30c5e 100644 --- a/packages/neon/neon_news/lib/widgets/feeds_view.dart +++ b/packages/neon/neon_news/lib/widgets/feeds_view.dart @@ -57,16 +57,14 @@ class NewsFeedsView extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (feed.updateErrorCount > 0) ...[ + if (feed.updateErrorCount > 0) IconButton( - onPressed: () async { - await showDialog<void>( - context: context, - builder: (final context) => NewsFeedUpdateErrorDialog( - feed: feed, - ), - ); - }, + onPressed: () async => showAdaptiveDialog<void>( + context: context, + builder: (final context) => NewsFeedUpdateErrorDialog( + feed: feed, + ), + ), tooltip: NewsLocalizations.of(context).feedShowErrorMessage, iconSize: 30, icon: Text( @@ -76,7 +74,6 @@ class NewsFeedsView extends StatelessWidget { ), ), ), - ], PopupMenuButton<NewsFeedAction>( itemBuilder: (final context) => [ PopupMenuItem( @@ -91,17 +88,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<void>( + await showAdaptiveDialog<void>( context: context, builder: (final context) => NewsFeedShowURLDialog( feed: feed, @@ -111,10 +107,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: @@ -124,7 +119,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); @@ -133,15 +128,15 @@ class NewsFeedsView extends StatelessWidget { if (!context.mounted) { return; } - final result = await showDialog<List<int?>>( + final result = await showAdaptiveDialog<int?>( context: context, builder: (final context) => NewsMoveFeedDialog( folders: folders, feed: feed, ), ); - if (result != null) { - bloc.moveFeed(feed.id, result[0]); + if (result != feed.folderId) { + bloc.moveFeed(feed.id, result); } } }, diff --git a/packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart b/packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart index cfb99bf8d09..867ca3c0a23 100644 --- a/packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart +++ b/packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart @@ -11,10 +11,8 @@ class NewsFolderFloatingActionButton extends StatelessWidget { @override Widget build(final BuildContext context) => FloatingActionButton( onPressed: () async { - final result = await showDialog<String>( - 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/widgets/folders_view.dart b/packages/neon/neon_news/lib/widgets/folders_view.dart index c23425e274d..78ee308326d 100644 --- a/packages/neon/neon_news/lib/widgets/folders_view.dart +++ b/packages/neon/neon_news/lib/widgets/folders_view.dart @@ -88,21 +88,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/pubspec.yaml b/packages/neon/neon_news/pubspec.yaml index 8917a777f3b..ef1fe7b03ae 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/pages/note.dart b/packages/neon/neon_notes/lib/pages/note.dart index cb99dd82c43..ec0169d7fa6 100644 --- a/packages/neon/neon_notes/lib/pages/note.dart +++ b/packages/neon/neon_notes/lib/pages/note.dart @@ -120,7 +120,7 @@ class _NotesNotePageState extends State<NotesNotePage> { return IconButton( onPressed: () async { - final result = await showDialog<String>( + final result = await showAdaptiveDialog<String>( context: context, builder: (final context) => NotesSelectCategoryDialog( bloc: widget.notesBloc, diff --git a/packages/neon/neon_notes/lib/widgets/dialog.dart b/packages/neon/neon_notes/lib/widgets/dialog.dart index 90de24c4427..5e29b8a0214 100644 --- a/packages/neon/neon_notes/lib/widgets/dialog.dart +++ b/packages/neon/neon_notes/lib/widgets/dialog.dart @@ -6,15 +6,20 @@ import 'package:neon_notes/l10n/localizations.dart'; import 'package:neon_notes/neon_notes.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.category, + this.initialCategory, super.key, }); + /// The active notes bloc. final NotesBloc bloc; - final String? category; + + /// The initial category of the note. + final String? initialCategory; @override State<NotesCreateNoteDialog> createState() => _NotesCreateNoteDialogState(); @@ -33,74 +38,96 @@ class _NotesCreateNoteDialogState extends State<NotesCreateNoteDialog> { void submit() { if (formKey.currentState!.validate()) { - Navigator.of(context).pop((controller.text, widget.category ?? selectedCategory)); + Navigator.of(context).pop((controller.text, widget.initialCategory ?? selectedCategory)); } } @override - Widget build(final BuildContext context) => ResultBuilder<List<notes.Note>>.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), - ), - ], - ), + 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<List<notes.Note>>.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 @@ -119,45 +146,55 @@ class _NotesSelectCategoryDialogState extends State<NotesSelectCategoryDialog> { } @override - Widget build(final BuildContext context) => ResultBuilder<List<notes.Note>>.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), - ), - ], - ), + Widget build(final BuildContext context) { + final folderSelector = ResultBuilder<List<notes.Note>>.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/widgets/notes_floating_action_button.dart b/packages/neon/neon_notes/lib/widgets/notes_floating_action_button.dart index 975daee452b..79b1ea584fa 100644 --- a/packages/neon/neon_notes/lib/widgets/notes_floating_action_button.dart +++ b/packages/neon/neon_notes/lib/widgets/notes_floating_action_button.dart @@ -13,11 +13,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/widgets/notes_view.dart b/packages/neon/neon_notes/lib/widgets/notes_view.dart index 7225ed1771f..2231472a49f 100644 --- a/packages/neon/neon_notes/lib/widgets/notes_view.dart +++ b/packages/neon/neon_notes/lib/widgets/notes_view.dart @@ -90,8 +90,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<Locale> supportedLocales = <Locale>[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/pages/main.dart b/packages/neon/neon_notifications/lib/pages/main.dart index 7783f2d3af2..426a456ab29 100644 --- a/packages/neon/neon_notifications/lib/pages/main.dart +++ b/packages/neon/neon_notifications/lib/pages/main.dart @@ -96,25 +96,9 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> { final accountsBloc = NeonProvider.of<AccountsBloc>(context); await accountsBloc.activeAppsBloc.setActiveApp(app.id); } else { - final colorScheme = Theme.of(context).colorScheme; - - await showDialog<void>( + 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, ); } },