Browse Source

feat(neon,neon_news,neon_files,neon_notes,neon_notifications): make dialog adaptive

Signed-off-by: Nikolas Rimikis <leptopoda@users.noreply.github.com>
pull/998/head
Nikolas Rimikis 1 year ago
parent
commit
280c64e415
No known key found for this signature in database
GPG Key ID: 85ED1DE9786A4FF2
  1. 6
      packages/neon/neon/lib/l10n/en.arb
  2. 36
      packages/neon/neon/lib/l10n/localizations.dart
  3. 19
      packages/neon/neon/lib/l10n/localizations_en.dart
  4. 36
      packages/neon/neon/lib/src/pages/account_settings.dart
  5. 34
      packages/neon/neon/lib/src/pages/home.dart
  6. 19
      packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart
  7. 15
      packages/neon/neon/lib/src/pages/settings.dart
  8. 17
      packages/neon/neon/lib/src/theme/dialog.dart
  9. 35
      packages/neon/neon/lib/src/utils/confirmation_dialog.dart
  10. 66
      packages/neon/neon/lib/src/utils/dialog.dart
  11. 27
      packages/neon/neon/lib/src/utils/global_popups.dart
  12. 445
      packages/neon/neon/lib/src/widgets/dialog.dart
  13. 2
      packages/neon/neon/lib/widgets.dart
  14. 4
      packages/neon/neon_files/lib/l10n/en.arb
  15. 24
      packages/neon/neon_files/lib/l10n/localizations.dart
  16. 12
      packages/neon/neon_files/lib/l10n/localizations_en.dart
  17. 2
      packages/neon/neon_files/lib/neon_files.dart
  18. 4
      packages/neon/neon_files/lib/pages/details.dart
  19. 10
      packages/neon/neon_files/lib/pages/main.dart
  20. 129
      packages/neon/neon_files/lib/utils/dialog.dart
  21. 60
      packages/neon/neon_files/lib/widgets/actions.dart
  22. 388
      packages/neon/neon_files/lib/widgets/dialog.dart
  23. 13
      packages/neon/neon_files/lib/widgets/file_list_tile.dart
  24. 3
      packages/neon/neon_news/lib/l10n/en.arb
  25. 18
      packages/neon/neon_news/lib/l10n/localizations.dart
  26. 9
      packages/neon/neon_news/lib/l10n/localizations_en.dart
  27. 1
      packages/neon/neon_news/lib/neon_news.dart
  28. 67
      packages/neon/neon_news/lib/utils/dialog.dart
  29. 387
      packages/neon/neon_news/lib/widgets/dialog.dart
  30. 2
      packages/neon/neon_news/lib/widgets/feed_floating_action_button.dart
  31. 37
      packages/neon/neon_news/lib/widgets/feeds_view.dart
  32. 6
      packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart
  33. 12
      packages/neon/neon_news/lib/widgets/folders_view.dart
  34. 1
      packages/neon/neon_news/pubspec.yaml
  35. 2
      packages/neon/neon_notes/lib/pages/note.dart
  36. 221
      packages/neon/neon_notes/lib/widgets/dialog.dart
  37. 4
      packages/neon/neon_notes/lib/widgets/notes_floating_action_button.dart
  38. 4
      packages/neon/neon_notes/lib/widgets/notes_view.dart
  39. 1
      packages/neon/neon_notifications/lib/l10n/en.arb
  40. 6
      packages/neon/neon_notifications/lib/l10n/localizations.dart
  41. 3
      packages/neon/neon_notifications/lib/l10n/localizations_en.dart
  42. 20
      packages/neon/neon_notifications/lib/pages/main.dart

6
packages/neon/neon/lib/l10n/en.arb

@ -77,6 +77,7 @@
} }
} }
}, },
"errorDialog": "An error has occurred",
"actionYes": "Yes", "actionYes": "Yes",
"actionNo": "No", "actionNo": "No",
"actionClose": "Close", "actionClose": "Close",
@ -84,6 +85,7 @@
"actionShowSlashHide": "Show/Hide", "actionShowSlashHide": "Show/Hide",
"actionExit": "Exit", "actionExit": "Exit",
"actionContinue": "Continue", "actionContinue": "Continue",
"actionCancel": "Cancel",
"firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications",
"nextPushSupported": "NextPush is supported!", "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.", "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", "settingsAccountManage": "Manage accounts",
"settingsExport": "Export settings", "settingsExport": "Export settings",
"settingsImport": "Import settings", "settingsImport": "Import settings",
"settingsReset": "Reset settings?",
"settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)", "settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)",
"settingsResetAll": "Reset all settings", "settingsResetAll": "Reset all settings",
"settingsResetAllConfirmation": "Do you want to 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": "Reset all settings for {name}",
"@settingsResetFor": { "@settingsResetFor": {
"placeholders": { "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": "Do you want to reset all settings for {name}?",
"@settingsResetForConfirmation": { "@settingsResetForConfirmation": {
"placeholders": { "placeholders": {

36
packages/neon/neon/lib/l10n/localizations.dart

@ -269,6 +269,12 @@ abstract class NeonLocalizations {
/// **'Route not found: {route}'** /// **'Route not found: {route}'**
String errorRouteNotFound(String 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. /// No description provided for @actionYes.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -311,6 +317,12 @@ abstract class NeonLocalizations {
/// **'Continue'** /// **'Continue'**
String get actionContinue; String get actionContinue;
/// No description provided for @actionCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get actionCancel;
/// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -389,6 +401,12 @@ abstract class NeonLocalizations {
/// **'Import settings'** /// **'Import settings'**
String get settingsImport; String get settingsImport;
/// No description provided for @settingsReset.
///
/// In en, this message translates to:
/// **'Reset settings?'**
String get settingsReset;
/// No description provided for @settingsImportWrongFileExtension. /// No description provided for @settingsImportWrongFileExtension.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -407,12 +425,30 @@ abstract class NeonLocalizations {
/// **'Do you want to reset all settings?'** /// **'Do you want to reset all settings?'**
String get settingsResetAllConfirmation; 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. /// No description provided for @settingsResetFor.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reset all settings for {name}'** /// **'Reset all settings for {name}'**
String settingsResetFor(String 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. /// No description provided for @settingsResetForConfirmation.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

19
packages/neon/neon/lib/l10n/localizations_en.dart

@ -126,6 +126,9 @@ class NeonLocalizationsEn extends NeonLocalizations {
return 'Route not found: $route'; return 'Route not found: $route';
} }
@override
String get errorDialog => 'An error has occurred';
@override @override
String get actionYes => 'Yes'; String get actionYes => 'Yes';
@ -147,6 +150,9 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get actionContinue => 'Continue'; String get actionContinue => 'Continue';
@override
String get actionCancel => 'Cancel';
@override @override
String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications';
@ -187,6 +193,9 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get settingsImport => 'Import settings'; String get settingsImport => 'Import settings';
@override
String get settingsReset => 'Reset settings?';
@override @override
String get settingsImportWrongFileExtension => 'Settings import has wrong file extension (has to be .json.base64)'; String get settingsImportWrongFileExtension => 'Settings import has wrong file extension (has to be .json.base64)';
@ -196,11 +205,21 @@ class NeonLocalizationsEn extends NeonLocalizations {
@override @override
String get settingsResetAllConfirmation => 'Do you want to reset all settings?'; 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 @override
String settingsResetFor(String name) { String settingsResetFor(String name) {
return 'Reset all settings for $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 @override
String settingsResetForConfirmation(String name) { String settingsResetForConfirmation(String name) {
return 'Do you want to reset all settings for $name?'; return 'Do you want to reset all settings for $name?';

36
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/settings/widgets/settings_list.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/adaptive.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:neon/src/widgets/error.dart';
import 'package:nextcloud/provisioning_api.dart' as provisioning_api; import 'package:nextcloud/provisioning_api.dart' as provisioning_api;
@ -46,15 +46,23 @@ class AccountSettingsPage extends StatelessWidget {
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () async {
if (await showConfirmationDialog( final decision = await showAdaptiveDialog<bool>(
context, context: context,
NeonLocalizations.of(context).accountOptionsRemoveConfirm(account.humanReadableID), 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; final isActive = bloc.activeAccount.valueOrNull == account;
options.reset();
bloc.removeAccount(account); bloc.removeAccount(account);
// ignore: use_build_context_synchronously
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
@ -71,10 +79,18 @@ class AccountSettingsPage extends StatelessWidget {
), ),
IconButton( IconButton(
onPressed: () async { onPressed: () async {
if (await showConfirmationDialog( final content =
context, '${NeonLocalizations.of(context).settingsResetForConfirmation(name)} ${NeonLocalizations.of(context).settingsResetForExplanation}';
NeonLocalizations.of(context).settingsResetForConfirmation(name), 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(); options.reset();
} }
}, },

34
packages/neon/neon/lib/src/pages/home.dart

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/result.dart'; import 'package:neon/src/bloc/result.dart';
import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/apps.dart'; import 'package:neon/src/blocs/apps.dart';
@ -10,11 +9,11 @@ import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/utils/global_options.dart' as global_options; import 'package:neon/src/utils/global_options.dart' as global_options;
import 'package:neon/src/utils/global_popups.dart'; 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/app_bar.dart';
import 'package:neon/src/widgets/drawer.dart'; import 'package:neon/src/widgets/drawer.dart';
import 'package:neon/src/widgets/error.dart'; import 'package:neon/src/widgets/error.dart';
import 'package:neon/src/widgets/unified_search_results.dart'; import 'package:neon/src/widgets/unified_search_results.dart';
import 'package:neon/utils.dart';
import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/core.dart' as core;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -65,7 +64,7 @@ class _HomePageState extends State<HomePage> {
} }
final message = l10n.errorUnsupportedAppVersions(buffer.toString()); final message = l10n.errorUnsupportedAppVersions(buffer.toString());
unawaited(_showProblem(message)); unawaited(showErrorDialog(context: context, message: message));
}); });
GlobalPopups().register(context); GlobalPopups().register(context);
@ -83,10 +82,10 @@ class _HomePageState extends State<HomePage> {
Future<void> _checkMaintenanceMode() async { Future<void> _checkMaintenanceMode() async {
try { try {
final status = await _account.client.core.getStatus(); final status = await _account.client.core.getStatus();
if (status.body.maintenance && mounted) { if (status.body.maintenance && mounted) {
await _showProblem( final message = NeonLocalizations.of(context).errorServerInMaintenanceMode;
NeonLocalizations.of(context).errorServerInMaintenanceMode, await showErrorDialog(context: context, message: message);
);
} }
} catch (e, s) { } catch (e, s) {
debugPrint(e.toString()); debugPrint(e.toString());
@ -97,29 +96,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 @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
const drawer = NeonDrawer(); const drawer = NeonDrawer();

19
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_category.dart';
import 'package:neon/src/settings/widgets/settings_list.dart'; import 'package:neon/src/settings/widgets/settings_list.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/dialog.dart'; import 'package:neon/src/widgets/dialog.dart';
@internal @internal
class NextcloudAppSettingsPage extends StatelessWidget { class NextcloudAppSettingsPage extends StatelessWidget {
@ -25,10 +25,19 @@ class NextcloudAppSettingsPage extends StatelessWidget {
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { onPressed: () async {
if (await showConfirmationDialog( final content =
context, '${NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context))} ${NeonLocalizations.of(context).settingsResetForClientExplanation}';
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)),
)) { 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(); appImplementation.options.reset();
} }
}, },

15
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/branding.dart';
import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/adaptive.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/global_options.dart';
import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/utils/save_file.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:neon/src/widgets/error.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@ -96,7 +96,18 @@ class _SettingsPageState extends State<SettingsPage> {
actions: [ actions: [
IconButton( IconButton(
onPressed: () async { 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(); globalOptions.reset();
for (final appImplementation in appImplementations) { for (final appImplementation in appImplementations) {

17
packages/neon/neon/lib/src/theme/dialog.dart

@ -15,6 +15,7 @@ class NeonDialogTheme {
minWidth: 280, minWidth: 280,
maxWidth: 560, maxWidth: 560,
), ),
this.padding = const EdgeInsets.all(24),
}); });
/// Used to configure the [BoxConstraints] for the [NeonDialog] widget. /// 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). /// By default it follows the default [m3 dialog specification](https://m3.material.io/components/dialogs/specs).
final BoxConstraints constraints; 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 /// Creates a copy of this object but with the given fields replaced with the
/// new values. /// new values.
NeonDialogTheme copyWith({ NeonDialogTheme copyWith({
final BoxConstraints? constraints, final BoxConstraints? constraints,
final EdgeInsets? padding,
}) => }) =>
NeonDialogTheme( NeonDialogTheme(
constraints: constraints ?? this.constraints, constraints: constraints ?? this.constraints,
padding: padding ?? this.padding,
); );
/// The data from the closest [NeonDialogTheme] instance given the build context. /// The data from the closest [NeonDialogTheme] instance given the build context.
@ -45,11 +54,15 @@ class NeonDialogTheme {
} }
return NeonDialogTheme( return NeonDialogTheme(
constraints: BoxConstraints.lerp(a.constraints, b.constraints, t)!, constraints: BoxConstraints.lerp(a.constraints, b.constraints, t)!,
padding: EdgeInsets.lerp(a.padding, b.padding, t)!,
); );
} }
@override @override
int get hashCode => constraints.hashCode; int get hashCode => Object.hashAll([
constraints,
padding,
]);
@override @override
bool operator ==(final Object other) { bool operator ==(final Object other) {
@ -57,6 +70,6 @@ class NeonDialogTheme {
return true; return true;
} }
return other is NeonDialogTheme && other.constraints == constraints; return other is NeonDialogTheme && other.constraints == constraints && other.padding == padding;
} }
} }

35
packages/neon/neon/lib/src/utils/confirmation_dialog.dart

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

66
packages/neon/neon/lib/src/utils/dialog.dart

@ -1,27 +1,69 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/widgets/dialog.dart'; import 'package:neon/src/widgets/dialog.dart';
Future<bool> showConfirmationDialog(final BuildContext context, final String title) async => /// Displays a simple [NeonConfirmationDialog] with the given [title].
await showDialog<bool>( ///
/// 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, context: context,
builder: (final context) => NeonConfirmationDialog( builder: (final context) => NeonConfirmationDialog(title: title),
title: title,
),
) ?? ) ??
false; false;
/// Displays a [NeonRenameDialog] with the given [title] and [initialValue].
///
/// Returns a future with the new name of name.
Future<String?> showRenameDialog({ Future<String?> showRenameDialog({
required final BuildContext context, required final BuildContext context,
required final String title, required final String title,
required final String value, required final String initialValue,
final Key? key,
}) async => }) async =>
showDialog<String?>( showAdaptiveDialog<String?>(
context: context, context: context,
builder: (final context) => RenameDialog( builder: (final context) => NeonRenameDialog(
title: title, title: title,
value: value, value: initialValue,
key: key, ),
);
/// 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,
),
),
],
), ),
); );

27
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/router.dart';
import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.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. /// Singleton class managing global popups.
@internal @internal
@ -88,30 +88,9 @@ class GlobalPopups {
return; return;
} }
await showDialog<void>( await showAdaptiveDialog<void>(
context: _context, context: _context,
builder: (final context) => AlertDialog( builder: (final context) => const NeonUnifiedPushDialog(),
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),
),
],
),
); );
}), }),
]); ]);

445
packages/neon/neon/lib/src/widgets/dialog.dart

@ -1,77 +1,283 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:meta/meta.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/src/widgets/account_tile.dart';
import 'package:neon/theme.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:neon/utils.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 { class NeonDialog extends StatelessWidget {
/// Creates a Neon dialog. /// Creates a Neon dialog.
/// ///
/// Typically used in conjunction with [showDialog]. /// Typically used in conjunction with [showDialog].
const NeonDialog({ const NeonDialog({
this.icon,
this.title, this.title,
this.children, this.content,
this.actions,
this.automaticallyShowCancel = true,
super.key, 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 /// The (optional) title of the dialog is displayed in a large font at the top
/// of the dialog. /// of the dialog.
/// ///
/// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat.
///
/// Typically a [Text] widget. /// Typically a [Text] widget.
final Widget? title; final Widget? title;
/// The (optional) content of the dialog is displayed in a /// {@template NeonDialog.content}
/// [SingleChildScrollView] underneath the title. /// The (optional) content of the dialog is displayed in the center of the
/// dialog in a lighter font.
/// ///
/// Typically a list of [SimpleDialogOption]s. /// Typically this is a [SingleChildScrollView] that contains the dialog's
final List<Widget>? children; /// 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 @override
Widget build(final BuildContext context) => SimpleDialog( Widget build(final BuildContext context) {
titlePadding: const EdgeInsets.all(10), final theme = Theme.of(context);
contentPadding: const EdgeInsets.all(10), final dialogTheme = NeonDialogTheme.of(context);
title: title,
children: children, 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 { class NeonConfirmationDialog extends StatelessWidget {
/// Creates a new confirmation dialog.
const NeonConfirmationDialog({ const NeonConfirmationDialog({
required this.title, required this.title,
this.content,
this.icon,
this.confirmAction,
this.declineAction,
this.isDestructive = true,
super.key, 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; 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 @override
Widget build(final BuildContext context) { Widget build(final BuildContext context) {
final confirm = ElevatedButton( final confirm = confirmAction ??
style: ElevatedButton.styleFrom( NeonDialogAction(
backgroundColor: NcColors.accept, isDestructiveAction: isDestructive,
foregroundColor: Theme.of(context).colorScheme.onPrimary, onPressed: () {
), Navigator.of(context).pop(true);
onPressed: () { },
Navigator.of(context).pop(true); child: Text(
}, NeonLocalizations.of(context).actionContinue,
child: Text(NeonLocalizations.of(context).actionYes), textAlign: TextAlign.end,
); ),
);
final decline = ElevatedButton( final decline = declineAction ??
style: ElevatedButton.styleFrom( NeonDialogAction(
backgroundColor: NcColors.decline, onPressed: () {
foregroundColor: Theme.of(context).colorScheme.onPrimary, Navigator.of(context).pop(false);
), },
onPressed: () { child: Text(
Navigator.of(context).pop(false); NeonLocalizations.of(context).actionCancel,
}, textAlign: TextAlign.end,
child: Text(NeonLocalizations.of(context).actionNo), ),
); );
return AlertDialog( return NeonDialog(
icon: icon,
title: Text(title), title: Text(title),
actionsAlignment: MainAxisAlignment.spaceEvenly, content: content,
actions: [ actions: [
decline, decline,
confirm, confirm,
@ -80,23 +286,33 @@ class NeonConfirmationDialog extends StatelessWidget {
} }
} }
class RenameDialog extends StatefulWidget { /// A [NeonDialog] that shows for renaming an object.
const RenameDialog({ ///
/// 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.title,
required this.value, required this.value,
super.key, super.key,
}); });
/// The title of the dialog.
final String title; final String title;
/// The initial value of the rename field.
///
/// This is the current name of the object to be renamed.
final String value; final String value;
@override @override
State<RenameDialog> createState() => _RenameDialogState(); State<NeonRenameDialog> createState() => _NeonRenameDialogState();
} }
class _RenameDialogState extends State<RenameDialog> { class _NeonRenameDialogState extends State<NeonRenameDialog> {
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final controller = TextEditingController(); final controller = TextEditingController();
@override @override
@ -118,42 +334,99 @@ class _RenameDialogState extends State<RenameDialog> {
} }
@override @override
Widget build(final BuildContext context) => NeonDialog( Widget build(final BuildContext context) {
title: Text(widget.title), final content = Material(
children: [ child: TextFormField(
Form( autofocus: true,
key: formKey, controller: controller,
child: Column( validator: (final input) => validateNotEmpty(context, input),
crossAxisAlignment: CrossAxisAlignment.end, onFieldSubmitted: (final _) {
children: [ submit();
TextFormField( },
autofocus: true, ),
controller: controller, );
validator: (final input) => validateNotEmpty(context, input),
onFieldSubmitted: (final _) { return NeonDialog(
submit(); title: Text(widget.title),
}, content: Form(key: formKey, child: content),
), actions: [
ElevatedButton( NeonDialogAction(
onPressed: submit, isDefaultAction: true,
child: Text(widget.title), 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 @internal
class NeonAccountSelectionDialog extends StatelessWidget { class NeonAccountSelectionDialog extends StatelessWidget {
/// Creates a new account selection dialog.
const NeonAccountSelectionDialog({ const NeonAccountSelectionDialog({
this.highlightActiveAccount = false, this.highlightActiveAccount = false,
this.children, this.children,
super.key, super.key,
}); });
/// Whether the selected account is highlighted with a leading check icon.
final bool highlightActiveAccount; final bool highlightActiveAccount;
/// The (optional) trailing children of this dialog.
final List<Widget>? children; final List<Widget>? children;
@override @override
@ -195,7 +468,7 @@ class NeonAccountSelectionDialog extends StatelessWidget {
return Dialog( return Dialog(
child: IntrinsicHeight( child: IntrinsicHeight(
child: Container( child: Container(
padding: const EdgeInsets.all(24), padding: dialogTheme.padding,
constraints: dialogTheme.constraints, constraints: dialogTheme.constraints,
child: body, 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,
),
),
],
);
}

2
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/error.dart';
export 'package:neon/src/widgets/image.dart'; export 'package:neon/src/widgets/image.dart';
export 'package:neon/src/widgets/linear_progress_indicator.dart'; export 'package:neon/src/widgets/linear_progress_indicator.dart';

4
packages/neon/neon_files/lib/l10n/en.arb

@ -1,7 +1,5 @@
{ {
"@@locale": "en", "@@locale": "en",
"actionYes": "Yes",
"actionNo": "No",
"actionDelete": "Delete", "actionDelete": "Delete",
"actionRename": "Rename", "actionRename": "Rename",
"actionMove": "Move", "actionMove": "Move",
@ -43,6 +41,8 @@
} }
} }
}, },
"actionDeleteTitle": "Permanently delete?",
"filesChooseCreate": "Add to Nextcloud",
"folderCreate": "Create folder", "folderCreate": "Create folder",
"folderName": "Folder name", "folderName": "Folder name",
"folderRename": "Rename folder", "folderRename": "Rename folder",

24
packages/neon/neon_files/lib/l10n/localizations.dart

@ -89,18 +89,6 @@ abstract class FilesLocalizations {
/// A list of this localizations delegate's supported locales. /// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')]; 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. /// No description provided for @actionDelete.
/// ///
/// In en, this message translates to: /// 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})?'** /// **'Are you sure you want to download a file that is bigger than {warningSize} ({actualSize})?'**
String downloadConfirmSizeWarning(String warningSize, String 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. /// No description provided for @folderCreate.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

12
packages/neon/neon_files/lib/l10n/localizations_en.dart

@ -4,12 +4,6 @@ import 'localizations.dart';
class FilesLocalizationsEn extends FilesLocalizations { class FilesLocalizationsEn extends FilesLocalizations {
FilesLocalizationsEn([String locale = 'en']) : super(locale); FilesLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get actionYes => 'Yes';
@override
String get actionNo => 'No';
@override @override
String get actionDelete => 'Delete'; 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)?'; 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 @override
String get folderCreate => 'Create folder'; String get folderCreate => 'Create folder';

2
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/widgets.dart';
import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/routes.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:neon_files/widgets/file_list_tile.dart';
import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';

4
packages/neon/neon_files/lib/pages/details.dart

@ -56,8 +56,8 @@ class FilesDetailsPage extends StatelessWidget {
}, },
if (details.isFavorite != null) ...{ if (details.isFavorite != null) ...{
FilesLocalizations.of(context).detailsIsFavorite: details.isFavorite! FilesLocalizations.of(context).detailsIsFavorite: details.isFavorite!
? FilesLocalizations.of(context).actionYes ? NeonLocalizations.of(context).actionYes
: FilesLocalizations.of(context).actionNo, : NeonLocalizations.of(context).actionNo,
}, },
}.entries) ...[ }.entries) ...[
DataRow( DataRow(

10
packages/neon/neon_files/lib/pages/main.dart

@ -29,15 +29,7 @@ class _FilesMainPageState extends State<FilesMainPage> {
filesBloc: bloc, filesBloc: bloc,
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () async { onPressed: () async => showFilesCreateModal(context),
await showDialog<void>(
context: context,
builder: (final context) => FilesChooseCreateDialog(
bloc: bloc,
basePath: bloc.browser.uri.value,
),
);
},
tooltip: FilesLocalizations.of(context).uploadFiles, tooltip: FilesLocalizations.of(context).uploadFiles,
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),

129
packages/neon/neon_files/lib/utils/dialog.dart

@ -0,0 +1,129 @@
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';
import 'package:nextcloud/nextcloud.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<PathUri?> showChooseFolderDialog(final BuildContext context, final FileDetails details) async {
final bloc = NeonProvider.of<FilesBloc>(context);
final originalUri = details.uri;
final b = bloc.getNewFilesBrowserBloc(initialUri: originalUri);
final result = await showDialog<PathUri>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: bloc,
originalPath: originalUri,
),
);
b.dispose();
return result;
}
/// Displays a [NeonConfirmationDialog] to confirm deleting a file or folder with the given [details].
///
/// Returns a future whether the action has been accepted.
Future<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),
);
}
}

60
packages/neon/neon_files/lib/widgets/actions.dart

@ -1,10 +1,9 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:neon/platform.dart'; import 'package:neon/platform.dart';
import 'package:neon/utils.dart'; import 'package:neon/utils.dart';
import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/neon_files.dart'; import 'package:neon_files/neon_files.dart';
import 'package:neon_files/widgets/dialog.dart'; import 'package:neon_files/utils/dialog.dart';
import 'package:nextcloud/webdav.dart'; import 'package:nextcloud/webdav.dart';
class FileActions extends StatelessWidget { class FileActions extends StatelessWidget {
@ -45,7 +44,7 @@ class FileActions extends StatelessWidget {
title: details.isDirectory title: details.isDirectory
? FilesLocalizations.of(context).folderRename ? FilesLocalizations.of(context).folderRename
: FilesLocalizations.of(context).fileRename, : FilesLocalizations.of(context).fileRename,
value: details.name, initialValue: details.name,
); );
if (result != null) { if (result != null) {
bloc.rename(details.uri, result); bloc.rename(details.uri, result);
@ -54,17 +53,8 @@ class FileActions extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final originalPath = details.uri.parent!; final result = await showChooseFolderDialog(context, details);
final b = bloc.getNewFilesBrowserBloc(initialUri: originalPath);
final result = await showDialog<PathUri>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: bloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) { if (result != null) {
bloc.move(details.uri, result.join(PathUri.parse(details.name))); bloc.move(details.uri, result.join(PathUri.parse(details.name)));
} }
@ -72,17 +62,8 @@ class FileActions extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final originalPath = details.uri.parent!;
final b = bloc.getNewFilesBrowserBloc(initialUri: originalPath); final result = await showChooseFolderDialog(context, details);
final result = await showDialog<PathUri>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: bloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) { if (result != null) {
bloc.copy(details.uri, result.join(PathUri.parse(details.name))); bloc.copy(details.uri, result.join(PathUri.parse(details.name)));
} }
@ -92,13 +73,9 @@ class FileActions extends StatelessWidget {
} }
final sizeWarning = browserBloc.options.downloadSizeWarning.value; final sizeWarning = browserBloc.options.downloadSizeWarning.value;
if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { if (sizeWarning != null && details.size != null && details.size! > sizeWarning) {
if (!(await showConfirmationDialog( final decision = await showDownloadConfirmationDialog(context, sizeWarning, details.size!);
context,
FilesLocalizations.of(context).downloadConfirmSizeWarning( if (!decision) {
filesize(sizeWarning),
filesize(details.size),
),
))) {
return; return;
} }
} }
@ -107,12 +84,8 @@ class FileActions extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
if (await showConfirmationDialog( final decision = await showDeleteConfirmationDialog(context, details);
context, if (decision) {
details.isDirectory
? FilesLocalizations.of(context).folderDeleteConfirm(details.name)
: FilesLocalizations.of(context).fileDeleteConfirm(details.name),
)) {
bloc.delete(details.uri); bloc.delete(details.uri);
} }
} }
@ -121,13 +94,12 @@ class FileActions extends StatelessWidget {
@override @override
Widget build(final BuildContext context) => PopupMenuButton<FilesFileAction>( Widget build(final BuildContext context) => PopupMenuButton<FilesFileAction>(
itemBuilder: (final context) => [ itemBuilder: (final context) => [
if (!details.isDirectory && NeonPlatform.instance.canUseSharing) ...[ if (!details.isDirectory && NeonPlatform.instance.canUseSharing)
PopupMenuItem( PopupMenuItem(
value: FilesFileAction.share, value: FilesFileAction.share,
child: Text(FilesLocalizations.of(context).actionShare), child: Text(FilesLocalizations.of(context).actionShare),
), ),
], if (details.isFavorite != null)
if (details.isFavorite != null) ...[
PopupMenuItem( PopupMenuItem(
value: FilesFileAction.toggleFavorite, value: FilesFileAction.toggleFavorite,
child: Text( child: Text(
@ -136,7 +108,7 @@ class FileActions extends StatelessWidget {
: FilesLocalizations.of(context).addToFavorites, : FilesLocalizations.of(context).addToFavorites,
), ),
), ),
],
PopupMenuItem( PopupMenuItem(
value: FilesFileAction.details, value: FilesFileAction.details,
child: Text(FilesLocalizations.of(context).details), child: Text(FilesLocalizations.of(context).details),
@ -154,12 +126,12 @@ class FileActions extends StatelessWidget {
child: Text(FilesLocalizations.of(context).actionCopy), child: Text(FilesLocalizations.of(context).actionCopy),
), ),
// TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4
if (!details.isDirectory) ...[ if (!details.isDirectory)
PopupMenuItem( PopupMenuItem(
value: FilesFileAction.sync, value: FilesFileAction.sync,
child: Text(FilesLocalizations.of(context).actionSync), child: Text(FilesLocalizations.of(context).actionSync),
), ),
],
PopupMenuItem( PopupMenuItem(
value: FilesFileAction.delete, value: FilesFileAction.delete,
child: Text(FilesLocalizations.of(context).actionDelete), child: Text(FilesLocalizations.of(context).actionDelete),

388
packages/neon/neon_files/lib/widgets/dialog.dart

@ -1,39 +1,56 @@
import 'dart:async'; import 'dart:async';
import 'package:file_picker/file_picker.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.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:neon/platform.dart'; import 'package:neon/platform.dart';
import 'package:neon/theme.dart';
import 'package:neon/utils.dart'; import 'package:neon/utils.dart';
import 'package:neon/widgets.dart'; import 'package:neon/widgets.dart';
import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/neon_files.dart'; import 'package:neon_files/neon_files.dart';
import 'package:neon_files/utils/dialog.dart';
import 'package:nextcloud/nextcloud.dart'; import 'package:nextcloud/nextcloud.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:universal_io/io.dart'; import 'package:universal_io/io.dart';
class FilesChooseCreateDialog extends StatefulWidget { /// Creates an adaptive bottom sheet to select an action to add a file.
const FilesChooseCreateDialog({ class FilesChooseCreateModal extends StatefulWidget {
/// Creates a new add files modal.
const FilesChooseCreateModal({
required this.bloc, required this.bloc,
required this.basePath,
super.key, super.key,
}); });
/// The bloc of the flies client.
final FilesBloc bloc; final FilesBloc bloc;
final PathUri basePath;
@override @override
State<FilesChooseCreateDialog> createState() => _FilesChooseCreateDialogState(); State<FilesChooseCreateModal> createState() => _FilesChooseCreateModalState();
} }
class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> { class _FilesChooseCreateModalState extends State<FilesChooseCreateModal> {
late PathUri baseUri;
@override
void initState() {
baseUri = widget.bloc.browser.uri.value;
super.initState();
}
Future<void> uploadFromPick(final FileType type) async { Future<void> uploadFromPick(final FileType type) async {
final result = await FilePicker.platform.pickFiles( final result = await FilePicker.platform.pickFiles(
allowMultiple: true, allowMultiple: true,
type: type, type: type,
); );
if (mounted) {
Navigator.of(context).pop();
}
if (result != null) { if (result != null) {
for (final file in result.files) { for (final file in result.files) {
await upload(File(file.path!)); await upload(File(file.path!));
@ -46,95 +63,150 @@ class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> {
if (sizeWarning != null) { if (sizeWarning != null) {
final stat = file.statSync(); final stat = file.statSync();
if (stat.size > sizeWarning) { if (stat.size > sizeWarning) {
if (!(await showConfirmationDialog( final result = await showUploadConfirmationDialog(context, sizeWarning, stat.size);
context,
FilesLocalizations.of(context).uploadConfirmSizeWarning( if (!result) {
filesize(sizeWarning),
filesize(stat.size),
),
))) {
return; return;
} }
} }
} }
widget.bloc.uploadFile( widget.bloc.uploadFile(
widget.basePath.join(PathUri.parse(p.basename(file.path))), baseUri.join(PathUri.parse(p.basename(file.path))),
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 @override
Widget build(final BuildContext context) => NeonDialog( Widget build(final BuildContext context) {
children: [ final theme = Theme.of(context);
ListTile( final title = FilesLocalizations.of(context).filesChooseCreate;
leading: Icon(
MdiIcons.filePlus,
color: Theme.of(context).colorScheme.primary,
),
title: Text(FilesLocalizations.of(context).uploadFiles),
onTap: () async {
await uploadFromPick(FileType.any);
if (mounted) { final actions = [
Navigator.of(context).pop(); 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( message: Text(FilesLocalizations.of(context).uploadCamera),
leading: Icon( onPressed: () async {
MdiIcons.fileImagePlus, Navigator.of(context).pop();
color: Theme.of(context).colorScheme.primary,
),
title: Text(FilesLocalizations.of(context).uploadImages),
onTap: () async {
await uploadFromPick(FileType.image);
if (mounted) { final picker = ImagePicker();
Navigator.of(context).pop(); final result = await picker.pickImage(source: ImageSource.camera);
} if (result != null) {
}, await upload(File(result.path));
), }
if (NeonPlatform.instance.canUseCamera) ...[ },
ListTile( ),
leading: Icon( wrapAction(
MdiIcons.cameraPlus, icon: Icon(
color: Theme.of(context).colorScheme.primary, MdiIcons.folderPlus,
), color: Theme.of(context).colorScheme.primary,
title: Text(FilesLocalizations.of(context).uploadCamera), ),
onTap: () async { message: Text(FilesLocalizations.of(context).folderCreate),
Navigator.of(context).pop(); onPressed: () async {
Navigator.of(context).pop();
final picker = ImagePicker();
final result = await picker.pickImage(source: ImageSource.camera); final result = await showFolderCreateDialog(context: context);
if (result != null) { if (result != null) {
await upload(File(result.path)); widget.bloc.browser.createFolder(baseUri.join(PathUri.parse(result)));
} }
}, },
), ),
], ];
ListTile(
leading: Icon( switch (theme.platform) {
MdiIcons.folderPlus, case TargetPlatform.android:
color: Theme.of(context).colorScheme.primary, 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<String>(
context: context,
builder: (final context) => const FilesCreateFolderDialog(),
);
if (result != null) {
widget.bloc.browser.createFolder(widget.basePath.join(PathUri.parse(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 { class FilesChooseFolderDialog extends StatelessWidget {
/// Creates a new folder chooser dialog.
const FilesChooseFolderDialog({ const FilesChooseFolderDialog({
required this.bloc, required this.bloc,
required this.filesBloc, required this.filesBloc,
@ -145,61 +217,67 @@ class FilesChooseFolderDialog extends StatelessWidget {
final FilesBrowserBloc bloc; final FilesBrowserBloc bloc;
final FilesBloc filesBloc; final FilesBloc filesBloc;
/// The initial path to start at.
final PathUri originalPath; final PathUri originalPath;
@override @override
Widget build(final BuildContext context) => AlertDialog( Widget build(final BuildContext context) {
title: Text(FilesLocalizations.of(context).folderChoose), final dialogTheme = NeonDialogTheme.of(context);
contentPadding: EdgeInsets.zero,
content: SizedBox( return StreamBuilder<PathUri>(
width: double.maxFinite, stream: bloc.uri,
child: Column( builder: (final context, final uriSnapshot) {
children: [ final actions = [
Expanded( OutlinedButton(
child: FilesBrowserView( onPressed: () async {
bloc: bloc, final result = await showFolderCreateDialog(context: context);
filesBloc: filesBloc,
mode: FilesBrowserMode.selectDirectory, if (result != null) {
), bloc.createFolder(uriSnapshot.requireData.join(PathUri.parse(result)));
), }
StreamBuilder<PathUri>( },
stream: bloc.uri, child: Text(
builder: (final context, final uriSnapshot) => uriSnapshot.hasData FilesLocalizations.of(context).folderCreate,
? Container( textAlign: TextAlign.end,
margin: const EdgeInsets.all(10), ),
child: Row( ),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ElevatedButton(
children: [ onPressed:
ElevatedButton( originalPath != uriSnapshot.requireData ? () => Navigator.of(context).pop(uriSnapshot.data) : null,
onPressed: () async { child: Text(
final result = await showDialog<String>( FilesLocalizations.of(context).folderChoose,
context: context, textAlign: TextAlign.end,
builder: (final context) => const FilesCreateFolderDialog(), ),
); ),
if (result != null) { ];
bloc.createFolder(uriSnapshot.requireData.join(PathUri.parse(result)));
} return AlertDialog(
}, title: Text(FilesLocalizations.of(context).folderChoose),
child: Text(FilesLocalizations.of(context).folderCreate), content: ConstrainedBox(
), constraints: dialogTheme.constraints,
ElevatedButton( child: SizedBox(
onPressed: originalPath != uriSnapshot.requireData width: double.maxFinite,
? () => Navigator.of(context).pop(uriSnapshot.requireData) child: FilesBrowserView(
: null, bloc: bloc,
child: Text(FilesLocalizations.of(context).folderChoose), filesBloc: filesBloc,
), mode: FilesBrowserMode.selectDirectory,
],
),
)
: const SizedBox(),
), ),
], ),
), ),
), actions: uriSnapshot.hasData ? actions : null,
); );
},
);
}
} }
/// A [NeonDialog] that shows for renaming creating a new folder.
///
/// Use `showFolderCreateDialog` to display this dialog.
///
/// When submitted the folder name will be popped as a `String`.
class FilesCreateFolderDialog extends StatefulWidget { class FilesCreateFolderDialog extends StatefulWidget {
/// Creates a new NeonDialog for creating a folder.
const FilesCreateFolderDialog({ const FilesCreateFolderDialog({
super.key, super.key,
}); });
@ -210,7 +288,6 @@ class FilesCreateFolderDialog extends StatefulWidget {
class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> { class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> {
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final controller = TextEditingController(); final controller = TextEditingController();
@override @override
@ -226,32 +303,37 @@ class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> {
} }
@override @override
Widget build(final BuildContext context) => NeonDialog( Widget build(final BuildContext context) {
title: Text(FilesLocalizations.of(context).folderCreate), final content = Material(
children: [ child: TextFormField(
Form( controller: controller,
key: formKey, decoration: InputDecoration(
child: Column( hintText: FilesLocalizations.of(context).folderName,
crossAxisAlignment: CrossAxisAlignment.end, ),
children: [ autofocus: true,
TextFormField( validator: (final input) => validateNotEmpty(context, input),
controller: controller, onFieldSubmitted: (final _) {
decoration: InputDecoration( submit();
hintText: FilesLocalizations.of(context).folderName, },
), ),
autofocus: true, );
validator: (final input) => validateNotEmpty(context, input),
onFieldSubmitted: (final _) { return NeonDialog(
submit(); title: Text(FilesLocalizations.of(context).folderCreate),
}, content: Form(
), key: formKey,
ElevatedButton( child: content,
onPressed: submit, ),
child: Text(FilesLocalizations.of(context).folderCreate), actions: [
), NeonDialogAction(
], isDefaultAction: true,
), onPressed: submit,
child: Text(
FilesLocalizations.of(context).folderCreate,
textAlign: TextAlign.end,
), ),
], ),
); ],
);
}
} }

13
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.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:neon/theme.dart'; import 'package:neon/theme.dart';
import 'package:neon/utils.dart';
import 'package:neon/widgets.dart'; import 'package:neon/widgets.dart';
import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/neon_files.dart'; import 'package:neon_files/neon_files.dart';
import 'package:neon_files/utils/dialog.dart';
import 'package:neon_files/widgets/actions.dart'; import 'package:neon_files/widgets/actions.dart';
class FileListTile extends StatelessWidget { class FileListTile extends StatelessWidget {
@ -28,13 +27,9 @@ class FileListTile extends StatelessWidget {
} else if (mode == FilesBrowserMode.browser) { } else if (mode == FilesBrowserMode.browser) {
final sizeWarning = bloc.options.downloadSizeWarning.value; final sizeWarning = bloc.options.downloadSizeWarning.value;
if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { if (sizeWarning != null && details.size != null && details.size! > sizeWarning) {
if (!(await showConfirmationDialog( final decision = await showDownloadConfirmationDialog(context, sizeWarning, details.size!);
context,
FilesLocalizations.of(context).downloadConfirmSizeWarning( if (!decision) {
filesize(sizeWarning),
filesize(details.size),
),
))) {
return; return;
} }
} }

3
packages/neon/neon_news/lib/l10n/en.arb

@ -1,8 +1,6 @@
{ {
"@@locale": "en", "@@locale": "en",
"actionClose": "Close",
"actionDelete": "Delete", "actionDelete": "Delete",
"actionRemove": "Remove",
"actionRename": "Rename", "actionRename": "Rename",
"actionMove": "Move", "actionMove": "Move",
"general": "General", "general": "General",
@ -19,6 +17,7 @@
} }
} }
}, },
"actionDeleteTitle": "Permanently delete?",
"folderRename": "Rename folder", "folderRename": "Rename folder",
"feeds": "Feeds", "feeds": "Feeds",
"feedAdd": "Add feed", "feedAdd": "Add feed",

18
packages/neon/neon_news/lib/l10n/localizations.dart

@ -89,24 +89,12 @@ abstract class NewsLocalizations {
/// A list of this localizations delegate's supported locales. /// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')]; 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. /// No description provided for @actionDelete.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Delete'** /// **'Delete'**
String get actionDelete; String get actionDelete;
/// No description provided for @actionRemove.
///
/// In en, this message translates to:
/// **'Remove'**
String get actionRemove;
/// No description provided for @actionRename. /// No description provided for @actionRename.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -161,6 +149,12 @@ abstract class NewsLocalizations {
/// **'Are you sure you want to delete the folder \'{name}\'?'** /// **'Are you sure you want to delete the folder \'{name}\'?'**
String folderDeleteConfirm(String 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. /// No description provided for @folderRename.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

9
packages/neon/neon_news/lib/l10n/localizations_en.dart

@ -4,15 +4,9 @@ import 'localizations.dart';
class NewsLocalizationsEn extends NewsLocalizations { class NewsLocalizationsEn extends NewsLocalizations {
NewsLocalizationsEn([String locale = 'en']) : super(locale); NewsLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get actionClose => 'Close';
@override @override
String get actionDelete => 'Delete'; String get actionDelete => 'Delete';
@override
String get actionRemove => 'Remove';
@override @override
String get actionRename => 'Rename'; String get actionRename => 'Rename';
@ -42,6 +36,9 @@ class NewsLocalizationsEn extends NewsLocalizations {
return 'Are you sure you want to delete the folder \'$name\'?'; return 'Are you sure you want to delete the folder \'$name\'?';
} }
@override
String get actionDeleteTitle => 'Permanently delete?';
@override @override
String get folderRename => 'Rename folder'; String get folderRename => 'Rename folder';

1
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/widgets.dart';
import 'package:neon_news/l10n/localizations.dart'; import 'package:neon_news/l10n/localizations.dart';
import 'package:neon_news/routes.dart'; import 'package:neon_news/routes.dart';
import 'package:neon_news/utils/dialog.dart';
import 'package:neon_news/widgets/dialog.dart'; import 'package:neon_news/widgets/dialog.dart';
import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/news.dart' as news; import 'package:nextcloud/news.dart' as news;

67
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,
);

387
packages/neon/neon_news/lib/widgets/dialog.dart

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:neon/blocs.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:neon_news/neon_news.dart';
import 'package:nextcloud/news.dart' as news; 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 { class NewsAddFeedDialog extends StatefulWidget {
/// Creates a new add feed dialog.
const NewsAddFeedDialog({ const NewsAddFeedDialog({
required this.bloc, required this.bloc,
this.folderID, this.folderID,
super.key, super.key,
}); });
/// The active client bloc.
final NewsBloc bloc; final NewsBloc bloc;
/// The initial id of the folder the feed is in.
final int? folderID; final int? folderID;
@override @override
@ -58,146 +66,114 @@ class _NewsAddFeedDialogState extends State<NewsAddFeedDialog> {
} }
@override @override
Widget build(final BuildContext context) => ResultBuilder<List<news.Folder>>.behaviorSubject( Widget build(final BuildContext context) {
subject: widget.bloc.folders, final urlField = Form(
builder: (final context, final folders) => NeonDialog( key: formKey,
title: Text(NewsLocalizations.of(context).feedAdd), child: TextFormField(
children: [ autofocus: true,
Form( controller: controller,
key: formKey, decoration: const InputDecoration(
child: Column( hintText: 'https://...',
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),
),
],
),
),
],
), ),
); keyboardType: TextInputType.url,
} validator: (final input) => validateHttpUrl(context, input),
onFieldSubmitted: (final _) {
class NewsCreateFolderDialog extends StatefulWidget { submit();
const NewsCreateFolderDialog({ },
super.key, autofillHints: const [AutofillHints.url],
}); ),
);
@override
State<NewsCreateFolderDialog> createState() => _NewsCreateFolderDialogState();
}
class _NewsCreateFolderDialogState extends State<NewsCreateFolderDialog> {
final formKey = GlobalKey<FormState>();
final controller = TextEditingController();
@override final folderSelector = ResultBuilder<List<news.Folder>>.behaviorSubject(
void dispose() { subject: widget.bloc.folders,
controller.dispose(); builder: (final context, final folders) {
super.dispose(); 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() { return NewsFolderSelect(
if (formKey.currentState!.validate()) { folders: folders.requireData,
Navigator.of(context).pop(controller.text); value: folder,
} onChanged: (final f) {
} setState(() {
folder = f;
});
},
);
},
);
@override return NeonDialog(
Widget build(final BuildContext context) => NeonDialog( title: Text(NewsLocalizations.of(context).feedAdd),
title: Text(NewsLocalizations.of(context).folderCreate), content: Material(
children: [ child: Column(
Form( mainAxisSize: MainAxisSize.min,
key: formKey, children: [
child: Column( urlField,
crossAxisAlignment: CrossAxisAlignment.end, const SizedBox(height: 8),
children: [ folderSelector,
TextFormField( ],
autofocus: true, ),
controller: controller, ),
decoration: InputDecoration( actions: [
hintText: NewsLocalizations.of(context).folderCreateName, NeonDialogAction(
), isDefaultAction: true,
validator: (final input) => validateNotEmpty(context, input), onPressed: submit,
onFieldSubmitted: (final _) { child: Text(
submit(); NewsLocalizations.of(context).feedAdd,
}, textAlign: TextAlign.end,
),
ElevatedButton(
onPressed: submit,
child: Text(NewsLocalizations.of(context).folderCreate),
),
],
),
), ),
], ),
); ],
);
}
} }
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({ const NewsFeedShowURLDialog({
required this.feed, required this.feed,
super.key, super.key,
}); });
/// The feed to display the url for.
final news.Feed feed; final news.Feed feed;
@override @override
State<NewsFeedShowURLDialog> createState() => _NewsFeedShowURLDialogState(); Widget build(final BuildContext context) => NeonDialog(
} title: Text(feed.url),
class _NewsFeedShowURLDialogState extends State<NewsFeedShowURLDialog> {
@override
Widget build(final BuildContext context) => AlertDialog(
title: Text(widget.feed.url),
actions: [ actions: [
ElevatedButton( NeonDialogAction(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
NeonLocalizations.of(context).actionClose,
textAlign: TextAlign.end,
),
),
NeonDialogAction(
isDefaultAction: true,
onPressed: () async { onPressed: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: widget.feed.url, text: feed.url,
), ),
); );
if (mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(NewsLocalizations.of(context).feedCopiedURL), content: Text(NewsLocalizations.of(context).feedCopiedURL),
@ -206,19 +182,16 @@ class _NewsFeedShowURLDialogState extends State<NewsFeedShowURLDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
child: Text(NewsLocalizations.of(context).feedCopyURL), child: Text(
), NewsLocalizations.of(context).feedCopyURL,
ElevatedButton( textAlign: TextAlign.end,
onPressed: () { ),
Navigator.of(context).pop();
},
child: Text(NewsLocalizations.of(context).actionClose),
), ),
], ],
); );
} }
class NewsFeedUpdateErrorDialog extends StatefulWidget { class NewsFeedUpdateErrorDialog extends StatelessWidget {
const NewsFeedUpdateErrorDialog({ const NewsFeedUpdateErrorDialog({
required this.feed, required this.feed,
super.key, super.key,
@ -227,22 +200,27 @@ class NewsFeedUpdateErrorDialog extends StatefulWidget {
final news.Feed feed; final news.Feed feed;
@override @override
State<NewsFeedUpdateErrorDialog> createState() => _NewsFeedUpdateErrorDialogState(); Widget build(final BuildContext context) => NeonDialog(
} title: Text(feed.lastUpdateError!),
class _NewsFeedUpdateErrorDialogState extends State<NewsFeedUpdateErrorDialog> {
@override
Widget build(final BuildContext context) => AlertDialog(
title: Text(widget.feed.lastUpdateError!),
actions: [ actions: [
ElevatedButton( NeonDialogAction(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
NeonLocalizations.of(context).actionClose,
textAlign: TextAlign.end,
),
),
NeonDialogAction(
isDefaultAction: true,
onPressed: () async { onPressed: () async {
await Clipboard.setData( await Clipboard.setData(
ClipboardData( ClipboardData(
text: widget.feed.lastUpdateError!, text: feed.lastUpdateError!,
), ),
); );
if (mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage),
@ -251,26 +229,30 @@ class _NewsFeedUpdateErrorDialogState extends State<NewsFeedUpdateErrorDialog> {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
child: Text(NewsLocalizations.of(context).feedCopyErrorMessage), child: Text(
), NewsLocalizations.of(context).feedCopyErrorMessage,
ElevatedButton( textAlign: TextAlign.end,
onPressed: () { ),
Navigator.of(context).pop();
},
child: Text(NewsLocalizations.of(context).actionClose),
), ),
], ],
); );
} }
/// 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 { class NewsMoveFeedDialog extends StatefulWidget {
/// Creates a new move feed dialog.
const NewsMoveFeedDialog({ const NewsMoveFeedDialog({
required this.folders, required this.folders,
required this.feed, required this.feed,
super.key, super.key,
}); });
/// The list of available folders.
final List<news.Folder> folders; final List<news.Folder> folders;
/// The feed to move.
final news.Feed feed; final news.Feed feed;
@override @override
@ -284,37 +266,110 @@ class _NewsMoveFeedDialogState extends State<NewsMoveFeedDialog> {
void submit() { void submit() {
if (formKey.currentState!.validate()) { 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 @override
Widget build(final BuildContext context) => NeonDialog( Widget build(final BuildContext context) => NeonDialog(
title: Text(NewsLocalizations.of(context).feedMove), title: Text(NewsLocalizations.of(context).feedMove),
children: [ content: Material(
Form( child: Form(
key: formKey, key: formKey,
child: Column( child: NewsFolderSelect(
crossAxisAlignment: CrossAxisAlignment.end, folders: widget.folders,
children: [ value: folder,
NewsFolderSelect( onChanged: (final f) {
folders: widget.folders, setState(() {
value: widget.feed.folderId != null folder = f;
? widget.folders.singleWhere((final folder) => folder.id == widget.feed.folderId) });
: null, },
onChanged: (final f) { ),
setState(() { ),
folder = f; ),
}); actions: [
}, NeonDialogAction(
), isDefaultAction: true,
ElevatedButton( onPressed: submit,
onPressed: submit, child: Text(
child: Text(NewsLocalizations.of(context).feedMove), 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,
),
),
],
);
}
}

2
packages/neon/neon_news/lib/widgets/feed_floating_action_button.dart

@ -13,7 +13,7 @@ class NewsFeedFloatingActionButton extends StatelessWidget {
@override @override
Widget build(final BuildContext context) => FloatingActionButton( Widget build(final BuildContext context) => FloatingActionButton(
onPressed: () async { onPressed: () async {
final result = await showDialog<(String, int?)>( final result = await showAdaptiveDialog<(String, int?)>(
context: context, context: context,
builder: (final context) => NewsAddFeedDialog( builder: (final context) => NewsAddFeedDialog(
bloc: bloc, bloc: bloc,

37
packages/neon/neon_news/lib/widgets/feeds_view.dart

@ -57,16 +57,14 @@ class NewsFeedsView extends StatelessWidget {
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (feed.updateErrorCount > 0) ...[ if (feed.updateErrorCount > 0)
IconButton( IconButton(
onPressed: () async { onPressed: () async => showAdaptiveDialog<void>(
await showDialog<void>( context: context,
context: context, builder: (final context) => NewsFeedUpdateErrorDialog(
builder: (final context) => NewsFeedUpdateErrorDialog( feed: feed,
feed: feed, ),
), ),
);
},
tooltip: NewsLocalizations.of(context).feedShowErrorMessage, tooltip: NewsLocalizations.of(context).feedShowErrorMessage,
iconSize: 30, iconSize: 30,
icon: Text( icon: Text(
@ -76,7 +74,6 @@ class NewsFeedsView extends StatelessWidget {
), ),
), ),
), ),
],
PopupMenuButton<NewsFeedAction>( PopupMenuButton<NewsFeedAction>(
itemBuilder: (final context) => [ itemBuilder: (final context) => [
PopupMenuItem( PopupMenuItem(
@ -91,17 +88,16 @@ class NewsFeedsView extends StatelessWidget {
value: NewsFeedAction.rename, value: NewsFeedAction.rename,
child: Text(NewsLocalizations.of(context).actionRename), child: Text(NewsLocalizations.of(context).actionRename),
), ),
if (folders.isNotEmpty) ...[ if (folders.isNotEmpty)
PopupMenuItem( PopupMenuItem(
value: NewsFeedAction.move, value: NewsFeedAction.move,
child: Text(NewsLocalizations.of(context).actionMove), child: Text(NewsLocalizations.of(context).actionMove),
), ),
],
], ],
onSelected: (final action) async { onSelected: (final action) async {
switch (action) { switch (action) {
case NewsFeedAction.showURL: case NewsFeedAction.showURL:
await showDialog<void>( await showAdaptiveDialog<void>(
context: context, context: context,
builder: (final context) => NewsFeedShowURLDialog( builder: (final context) => NewsFeedShowURLDialog(
feed: feed, feed: feed,
@ -111,10 +107,9 @@ class NewsFeedsView extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
if (await showConfirmationDialog( final result = await showDeleteFeedDialog(context, feed);
context,
NewsLocalizations.of(context).feedRemoveConfirm(feed.title), if (result) {
)) {
bloc.removeFeed(feed.id); bloc.removeFeed(feed.id);
} }
case NewsFeedAction.rename: case NewsFeedAction.rename:
@ -124,7 +119,7 @@ class NewsFeedsView extends StatelessWidget {
final result = await showRenameDialog( final result = await showRenameDialog(
context: context, context: context,
title: NewsLocalizations.of(context).feedRename, title: NewsLocalizations.of(context).feedRename,
value: feed.title, initialValue: feed.title,
); );
if (result != null) { if (result != null) {
bloc.renameFeed(feed.id, result); bloc.renameFeed(feed.id, result);
@ -133,15 +128,15 @@ class NewsFeedsView extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final result = await showDialog<List<int?>>( final result = await showAdaptiveDialog<int?>(
context: context, context: context,
builder: (final context) => NewsMoveFeedDialog( builder: (final context) => NewsMoveFeedDialog(
folders: folders, folders: folders,
feed: feed, feed: feed,
), ),
); );
if (result != null) { if (result != feed.folderId) {
bloc.moveFeed(feed.id, result[0]); bloc.moveFeed(feed.id, result);
} }
} }
}, },

6
packages/neon/neon_news/lib/widgets/folder_floating_action_button.dart

@ -11,10 +11,8 @@ class NewsFolderFloatingActionButton extends StatelessWidget {
@override @override
Widget build(final BuildContext context) => FloatingActionButton( Widget build(final BuildContext context) => FloatingActionButton(
onPressed: () async { onPressed: () async {
final result = await showDialog<String>( final result = await showFolderCreateDialog(context: context);
context: context,
builder: (final context) => const NewsCreateFolderDialog(),
);
if (result != null) { if (result != null) {
bloc.createFolder(result); bloc.createFolder(result);
} }

12
packages/neon/neon_news/lib/widgets/folders_view.dart

@ -88,21 +88,15 @@ class NewsFoldersView extends StatelessWidget {
onSelected: (final action) async { onSelected: (final action) async {
switch (action) { switch (action) {
case NewsFolderAction.delete: case NewsFolderAction.delete:
if (await showConfirmationDialog( final result = await showFolderDeleteDialog(context: context, folderName: folder.name);
context, if (result) {
NewsLocalizations.of(context).folderDeleteConfirm(folder.name),
)) {
bloc.deleteFolder(folder.id); bloc.deleteFolder(folder.id);
} }
case NewsFolderAction.rename: case NewsFolderAction.rename:
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final result = await showRenameDialog( final result = await showFolderRenameDialog(context: context, folderName: folder.name);
context: context,
title: NewsLocalizations.of(context).folderRename,
value: folder.name,
);
if (result != null) { if (result != null) {
bloc.renameFolder(folder.id, result); bloc.renameFolder(folder.id, result);
} }

1
packages/neon/neon_news/pubspec.yaml

@ -7,6 +7,7 @@ environment:
flutter: '>=3.13.0' flutter: '>=3.13.0'
dependencies: dependencies:
collection: ^1.0.0
flutter: flutter:
sdk: flutter sdk: flutter
flutter_html: ^3.0.0-beta.2 flutter_html: ^3.0.0-beta.2

2
packages/neon/neon_notes/lib/pages/note.dart

@ -120,7 +120,7 @@ class _NotesNotePageState extends State<NotesNotePage> {
return IconButton( return IconButton(
onPressed: () async { onPressed: () async {
final result = await showDialog<String>( final result = await showAdaptiveDialog<String>(
context: context, context: context,
builder: (final context) => NotesSelectCategoryDialog( builder: (final context) => NotesSelectCategoryDialog(
bloc: widget.notesBloc, bloc: widget.notesBloc,

221
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:neon_notes/neon_notes.dart';
import 'package:nextcloud/notes.dart' as notes; import 'package:nextcloud/notes.dart' as notes;
/// A dialog for creating a note.
class NotesCreateNoteDialog extends StatefulWidget { class NotesCreateNoteDialog extends StatefulWidget {
/// Creates a new create note dialog.
const NotesCreateNoteDialog({ const NotesCreateNoteDialog({
required this.bloc, required this.bloc,
this.category, this.initialCategory,
super.key, super.key,
}); });
/// The active notes bloc.
final NotesBloc bloc; final NotesBloc bloc;
final String? category;
/// The initial category of the note.
final String? initialCategory;
@override @override
State<NotesCreateNoteDialog> createState() => _NotesCreateNoteDialogState(); State<NotesCreateNoteDialog> createState() => _NotesCreateNoteDialogState();
@ -33,74 +38,96 @@ class _NotesCreateNoteDialogState extends State<NotesCreateNoteDialog> {
void submit() { void submit() {
if (formKey.currentState!.validate()) { if (formKey.currentState!.validate()) {
Navigator.of(context).pop((controller.text, widget.category ?? selectedCategory)); Navigator.of(context).pop((controller.text, widget.initialCategory ?? selectedCategory));
} }
} }
@override @override
Widget build(final BuildContext context) => ResultBuilder<List<notes.Note>>.behaviorSubject( Widget build(final BuildContext context) {
subject: widget.bloc.notesList, final titleField = Form(
builder: (final context, final notes) => NeonDialog( key: formKey,
title: Text(NotesLocalizations.of(context).noteCreate), child: TextFormField(
children: [ autofocus: true,
Form( controller: controller,
key: formKey, decoration: InputDecoration(
child: Column( hintText: NotesLocalizations.of(context).noteTitle,
crossAxisAlignment: CrossAxisAlignment.end, ),
children: [ validator: (final input) => validateNotEmpty(context, input),
TextFormField( onFieldSubmitted: (final _) {
autofocus: true, submit();
controller: controller, },
decoration: InputDecoration( ),
hintText: NotesLocalizations.of(context).noteTitle, );
),
validator: (final input) => validateNotEmpty(context, input), final folderSelector = ResultBuilder<List<notes.Note>>.behaviorSubject(
onFieldSubmitted: (final _) { subject: widget.bloc.notesList,
submit(); builder: (final context, final notes) {
}, if (notes.hasError) {
), return Center(
if (widget.category == null) ...[ child: NeonError(
Center( notes.error,
child: NeonError( onRetry: widget.bloc.refresh,
notes.error, ),
onRetry: widget.bloc.refresh, );
), }
), if (!notes.hasData) {
Center( return Center(
child: NeonLinearProgressIndicator( child: NeonLinearProgressIndicator(
visible: notes.isLoading, 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),
),
],
),
), ),
);
}
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 { class NotesSelectCategoryDialog extends StatefulWidget {
/// Creates a new category selection dialog.
const NotesSelectCategoryDialog({ const NotesSelectCategoryDialog({
required this.bloc, required this.bloc,
this.initialCategory, this.initialCategory,
super.key, super.key,
}); });
/// The active notes bloc.
final NotesBloc bloc; final NotesBloc bloc;
/// The initial category of the note.
final String? initialCategory; final String? initialCategory;
@override @override
@ -119,45 +146,55 @@ class _NotesSelectCategoryDialogState extends State<NotesSelectCategoryDialog> {
} }
@override @override
Widget build(final BuildContext context) => ResultBuilder<List<notes.Note>>.behaviorSubject( Widget build(final BuildContext context) {
subject: widget.bloc.notesList, final folderSelector = ResultBuilder<List<notes.Note>>.behaviorSubject(
builder: (final context, final notes) => NeonDialog( subject: widget.bloc.notesList,
title: Text(NotesLocalizations.of(context).category), builder: (final context, final notes) {
children: [ if (notes.hasError) {
Form( return Center(
key: formKey, child: NeonError(
child: Column( notes.error,
crossAxisAlignment: CrossAxisAlignment.end, onRetry: widget.bloc.refresh,
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),
),
],
),
), ),
], );
}
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,
),
), ),
); ],
);
}
} }

4
packages/neon/neon_notes/lib/widgets/notes_floating_action_button.dart

@ -13,11 +13,11 @@ class NotesFloatingActionButton extends StatelessWidget {
@override @override
Widget build(final BuildContext context) => FloatingActionButton( Widget build(final BuildContext context) => FloatingActionButton(
onPressed: () async { onPressed: () async {
final result = await showDialog<(String, String?)>( final result = await showAdaptiveDialog<(String, String?)>(
context: context, context: context,
builder: (final context) => NotesCreateNoteDialog( builder: (final context) => NotesCreateNoteDialog(
bloc: bloc, bloc: bloc,
category: category, initialCategory: category,
), ),
); );
if (result != null) { if (result != null) {

4
packages/neon/neon_notes/lib/widgets/notes_view.dart

@ -90,8 +90,8 @@ class NotesView extends StatelessWidget {
}, },
onLongPress: () async { onLongPress: () async {
final result = await showConfirmationDialog( final result = await showConfirmationDialog(
context, context: context,
NotesLocalizations.of(context).noteDeleteConfirm(note.title), title: NotesLocalizations.of(context).noteDeleteConfirm(note.title),
); );
if (result) { if (result) {
bloc.deleteNote(note.id); bloc.deleteNote(note.id);

1
packages/neon/neon_notifications/lib/l10n/en.arb

@ -1,6 +1,5 @@
{ {
"@@locale": "en", "@@locale": "en",
"actionClose": "Close",
"notificationsDismissAll": "Dismiss all notifications", "notificationsDismissAll": "Dismiss all notifications",
"notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet" "notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet"
} }

6
packages/neon/neon_notifications/lib/l10n/localizations.dart

@ -89,12 +89,6 @@ abstract class NotificationsLocalizations {
/// A list of this localizations delegate's supported locales. /// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[Locale('en')]; 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. /// No description provided for @notificationsDismissAll.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

3
packages/neon/neon_notifications/lib/l10n/localizations_en.dart

@ -4,9 +4,6 @@ import 'localizations.dart';
class NotificationsLocalizationsEn extends NotificationsLocalizations { class NotificationsLocalizationsEn extends NotificationsLocalizations {
NotificationsLocalizationsEn([String locale = 'en']) : super(locale); NotificationsLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get actionClose => 'Close';
@override @override
String get notificationsDismissAll => 'Dismiss all notifications'; String get notificationsDismissAll => 'Dismiss all notifications';

20
packages/neon/neon_notifications/lib/pages/main.dart

@ -96,25 +96,9 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> {
final accountsBloc = NeonProvider.of<AccountsBloc>(context); final accountsBloc = NeonProvider.of<AccountsBloc>(context);
await accountsBloc.activeAppsBloc.setActiveApp(app.id); await accountsBloc.activeAppsBloc.setActiveApp(app.id);
} else { } else {
final colorScheme = Theme.of(context).colorScheme; await showUnimplementedDialog(
await showDialog<void>(
context: context, context: context,
builder: (final context) => AlertDialog( title: NotificationsLocalizations.of(context).notificationAppNotImplementedYet,
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),
),
],
),
); );
} }
}, },

Loading…
Cancel
Save