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",
"actionNo": "No",
"actionClose": "Close",
@ -84,6 +85,7 @@
"actionShowSlashHide": "Show/Hide",
"actionExit": "Exit",
"actionContinue": "Continue",
"actionCancel": "Cancel",
"firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications",
"nextPushSupported": "NextPush is supported!",
"nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.",
@ -97,9 +99,11 @@
"settingsAccountManage": "Manage accounts",
"settingsExport": "Export settings",
"settingsImport": "Import settings",
"settingsReset": "Reset settings?",
"settingsImportWrongFileExtension": "Settings import has wrong file extension (has to be .json.base64)",
"settingsResetAll": "Reset all settings",
"settingsResetAllConfirmation": "Do you want to reset all settings?",
"settingsResetAllExplanation": "This will reset all preferences back to their default settings.",
"settingsResetFor": "Reset all settings for {name}",
"@settingsResetFor": {
"placeholders": {
@ -108,6 +112,8 @@
}
}
},
"settingsResetForExplanation": "This will reset your account preferences back to their default settings.",
"settingsResetForClientExplanation": "This will reset all preferences for the app back to their default settings.",
"settingsResetForConfirmation": "Do you want to reset all settings for {name}?",
"@settingsResetForConfirmation": {
"placeholders": {

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

@ -269,6 +269,12 @@ abstract class NeonLocalizations {
/// **'Route not found: {route}'**
String errorRouteNotFound(String route);
/// No description provided for @errorDialog.
///
/// In en, this message translates to:
/// **'An error has occurred'**
String get errorDialog;
/// No description provided for @actionYes.
///
/// In en, this message translates to:
@ -311,6 +317,12 @@ abstract class NeonLocalizations {
/// **'Continue'**
String get actionContinue;
/// No description provided for @actionCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get actionCancel;
/// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications.
///
/// In en, this message translates to:
@ -389,6 +401,12 @@ abstract class NeonLocalizations {
/// **'Import settings'**
String get settingsImport;
/// No description provided for @settingsReset.
///
/// In en, this message translates to:
/// **'Reset settings?'**
String get settingsReset;
/// No description provided for @settingsImportWrongFileExtension.
///
/// In en, this message translates to:
@ -407,12 +425,30 @@ abstract class NeonLocalizations {
/// **'Do you want to reset all settings?'**
String get settingsResetAllConfirmation;
/// No description provided for @settingsResetAllExplanation.
///
/// In en, this message translates to:
/// **'This will reset all preferences back to their default settings.'**
String get settingsResetAllExplanation;
/// No description provided for @settingsResetFor.
///
/// In en, this message translates to:
/// **'Reset all settings for {name}'**
String settingsResetFor(String name);
/// No description provided for @settingsResetForExplanation.
///
/// In en, this message translates to:
/// **'This will reset your account preferences back to their default settings.'**
String get settingsResetForExplanation;
/// No description provided for @settingsResetForClientExplanation.
///
/// In en, this message translates to:
/// **'This will reset all preferences for the app back to their default settings.'**
String get settingsResetForClientExplanation;
/// No description provided for @settingsResetForConfirmation.
///
/// In en, this message translates to:

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

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

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

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

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/result.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/apps.dart';
@ -10,11 +9,11 @@ import 'package:neon/src/models/account.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_popups.dart';
import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/widgets/app_bar.dart';
import 'package:neon/src/widgets/drawer.dart';
import 'package:neon/src/widgets/error.dart';
import 'package:neon/src/widgets/unified_search_results.dart';
import 'package:neon/utils.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:provider/provider.dart';
@ -65,7 +64,7 @@ class _HomePageState extends State<HomePage> {
}
final message = l10n.errorUnsupportedAppVersions(buffer.toString());
unawaited(_showProblem(message));
unawaited(showErrorDialog(context: context, message: message));
});
GlobalPopups().register(context);
@ -83,10 +82,10 @@ class _HomePageState extends State<HomePage> {
Future<void> _checkMaintenanceMode() async {
try {
final status = await _account.client.core.getStatus();
if (status.body.maintenance && mounted) {
await _showProblem(
NeonLocalizations.of(context).errorServerInMaintenanceMode,
);
final message = NeonLocalizations.of(context).errorServerInMaintenanceMode;
await showErrorDialog(context: context, message: message);
}
} catch (e, s) {
debugPrint(e.toString());
@ -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
Widget build(final BuildContext context) {
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_list.dart';
import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/dialog.dart';
import 'package:neon/src/widgets/dialog.dart';
@internal
class NextcloudAppSettingsPage extends StatelessWidget {
@ -25,10 +25,19 @@ class NextcloudAppSettingsPage extends StatelessWidget {
actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(
context,
NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)),
)) {
final content =
'${NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context))} ${NeonLocalizations.of(context).settingsResetForClientExplanation}';
final decision = await showAdaptiveDialog<bool>(
context: context,
builder: (final context) => NeonConfirmationDialog(
icon: const Icon(Icons.restart_alt),
title: NeonLocalizations.of(context).settingsReset,
content: Text(content),
),
);
if (decision ?? false) {
appImplementation.options.reset();
}
},

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/dialog.dart';
import 'package:neon/src/utils/adaptive.dart';
import 'package:neon/src/utils/dialog.dart';
import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/utils/save_file.dart';
import 'package:neon/src/widgets/dialog.dart';
import 'package:neon/src/widgets/error.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -96,7 +96,18 @@ class _SettingsPageState extends State<SettingsPage> {
actions: [
IconButton(
onPressed: () async {
if (await showConfirmationDialog(context, NeonLocalizations.of(context).settingsResetAllConfirmation)) {
final content =
'${NeonLocalizations.of(context).settingsResetAllConfirmation} ${NeonLocalizations.of(context).settingsResetAllExplanation}';
final decision = await showAdaptiveDialog<bool>(
context: context,
builder: (final context) => NeonConfirmationDialog(
icon: const Icon(Icons.restart_alt),
title: NeonLocalizations.of(context).settingsReset,
content: Text(content),
),
);
if (decision ?? false) {
globalOptions.reset();
for (final appImplementation in appImplementations) {

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

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

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:neon/l10n/localizations.dart';
import 'package:neon/src/widgets/dialog.dart';
Future<bool> showConfirmationDialog(final BuildContext context, final String title) async =>
await showDialog<bool>(
/// Displays a simple [NeonConfirmationDialog] with the given [title].
///
/// Returns a future whether the action has been accepted.
Future<bool> showConfirmationDialog({
required final BuildContext context,
required final String title,
}) async =>
await showAdaptiveDialog<bool>(
context: context,
builder: (final context) => NeonConfirmationDialog(
title: title,
),
builder: (final context) => NeonConfirmationDialog(title: title),
) ??
false;
/// Displays a [NeonRenameDialog] with the given [title] and [initialValue].
///
/// Returns a future with the new name of name.
Future<String?> showRenameDialog({
required final BuildContext context,
required final String title,
required final String value,
final Key? key,
required final String initialValue,
}) async =>
showDialog<String?>(
showAdaptiveDialog<String?>(
context: context,
builder: (final context) => RenameDialog(
builder: (final context) => NeonRenameDialog(
title: title,
value: value,
key: key,
value: initialValue,
),
);
/// Displays a [NeonErrorDialog] with the given [message].
Future<void> showErrorDialog({
required final BuildContext context,
required final String message,
final String? title,
}) =>
showAdaptiveDialog<void>(
context: context,
builder: (final context) => NeonErrorDialog(content: message),
);
/// Displays a [NeonDialog] with the given [title] informing the user that a
/// feature is not implemented yet.
Future<void> showUnimplementedDialog({
required final BuildContext context,
required final String title,
}) =>
showAdaptiveDialog<void>(
context: context,
builder: (final context) => NeonDialog(
automaticallyShowCancel: false,
title: Text(title),
actions: [
NeonDialogAction(
isDestructiveAction: true,
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
NeonLocalizations.of(context).actionClose,
textAlign: TextAlign.end,
),
),
],
),
);

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/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:neon/src/widgets/dialog.dart';
/// Singleton class managing global popups.
@internal
@ -88,30 +88,9 @@ class GlobalPopups {
return;
}
await showDialog<void>(
await showAdaptiveDialog<void>(
context: _context,
builder: (final context) => AlertDialog(
title: Text(NeonLocalizations.of(context).nextPushSupported),
content: Text(NeonLocalizations.of(context).nextPushSupportedText),
actions: [
OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(NeonLocalizations.of(context).actionNo),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
launchUrlString(
'https://f-droid.org/packages/$unifiedPushNextPushID',
mode: LaunchMode.externalApplication,
);
},
child: Text(NeonLocalizations.of(context).nextPushSupportedInstall),
),
],
),
builder: (final context) => const NeonUnifiedPushDialog(),
);
}),
]);

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:meta/meta.dart';
import 'package:neon/blocs.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/provider.dart';
import 'package:neon/src/utils/validators.dart';
import 'package:neon/src/widgets/account_tile.dart';
import 'package:neon/theme.dart';
import 'package:neon/utils.dart';
import 'package:url_launcher/url_launcher_string.dart';
/// A Neon material design dialog based on [SimpleDialog].
/// An button typically used in an [AlertDialog.adaptive].
///
/// It adaptively creates an [CupertinoDialogAction] based on the closest
/// [ThemeData.platform].
class NeonDialogAction extends StatelessWidget {
/// Creates a new adaptive Neon dialog action.
const NeonDialogAction({
required this.onPressed,
required this.child,
this.isDefaultAction = false,
this.isDestructiveAction = false,
super.key,
});
/// The callback that is called when the button is tapped or otherwise
/// activated.
///
/// If this is set to null, the button will be disabled.
final VoidCallback? onPressed;
/// The widget below this widget in the tree.
///
/// Typically a [Text] widget.
final Widget child;
/// Set to true if button is the default choice in the dialog.
///
/// Default buttons have higher emphasis. Similar to
/// [CupertinoDialogAction.isDefaultAction]. More than one action can have
/// this attribute set to true in the same [Dialog].
///
/// This parameters defaults to false and cannot be null.
final bool isDefaultAction;
/// Whether this action destroys an object.
///
/// For example, an action that deletes an email is destructive.
///
/// Defaults to false and cannot be null.
final bool isDestructiveAction;
@override
Widget build(final BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
switch (theme.platform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
if (isDestructiveAction) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.errorContainer,
foregroundColor: colorScheme.onErrorContainer,
),
onPressed: onPressed,
child: child,
);
}
if (isDefaultAction) {
return ElevatedButton(onPressed: onPressed, child: child);
}
return OutlinedButton(onPressed: onPressed, child: child);
case TargetPlatform.iOS:
case TargetPlatform.macOS:
return CupertinoDialogAction(
onPressed: onPressed,
isDefaultAction: isDefaultAction,
isDestructiveAction: isDestructiveAction,
child: child,
);
}
}
}
/// A Neon design dialog based on [AlertDialog.adaptive].
///
/// THis widget enforces the closest [NeonDialogTheme] and constraints the
/// [content] width accordingly. The [title] should never be larger than the
/// [NeonDialogTheme.constraints] and it it up to the caller to handle this.
class NeonDialog extends StatelessWidget {
/// Creates a Neon dialog.
///
/// Typically used in conjunction with [showDialog].
const NeonDialog({
this.icon,
this.title,
this.children,
this.content,
this.actions,
this.automaticallyShowCancel = true,
super.key,
});
/// {@template NeonDialog.icon}
/// An optional icon to display at the top of the dialog.
///
/// Typically, an [Icon] widget. Providing an icon centers the [title]'s text.
/// {@endtemplate}
final Widget? icon;
/// The (optional) title of the dialog is displayed in a large font at the top
/// of the dialog.
///
/// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat.
///
/// Typically a [Text] widget.
final Widget? title;
/// The (optional) content of the dialog is displayed in a
/// [SingleChildScrollView] underneath the title.
/// {@template NeonDialog.content}
/// The (optional) content of the dialog is displayed in the center of the
/// dialog in a lighter font.
///
/// Typically a list of [SimpleDialogOption]s.
final List<Widget>? children;
/// Typically this is a [SingleChildScrollView] that contains the dialog's
/// message. As noted in the [AlertDialog] documentation, it's important
/// to use a [SingleChildScrollView] if there's any risk that the content
/// will not fit, as the contents will otherwise overflow the dialog.
///
/// The horizontal dimension of this widget is constrained by the closest
/// [NeonDialogTheme.constraints].
/// {@endtemplate}
final Widget? content;
/// The (optional) set of actions that are displayed at the bottom of the
/// dialog with an [OverflowBar].
///
/// Typically this is a list of [NeonDialogAction] widgets. It is recommended
/// to set the [Text.textAlign] to [TextAlign.end] for the [Text] within the
/// [TextButton], so that buttons whose labels wrap to an extra line align
/// with the overall [OverflowBar]'s alignment within the dialog.
///
/// If the [title] is not null but the [content] _is_ null, then an extra 20
/// pixels of padding is added above the [OverflowBar] to separate the [title]
/// from the [actions].
final List<Widget>? actions;
/// Whether to automatically show a cancel button when only less than two
/// actions are supplied.
///
/// This is needed for the ios where dialogs are not dismissible by tapping
/// outside their boundary.
///
/// Defaults to `true`.
final bool automaticallyShowCancel;
@override
Widget build(final BuildContext context) => SimpleDialog(
titlePadding: const EdgeInsets.all(10),
contentPadding: const EdgeInsets.all(10),
title: title,
children: children,
Widget build(final BuildContext context) {
final theme = Theme.of(context);
final dialogTheme = NeonDialogTheme.of(context);
var content = this.content;
if (content != null) {
content = ConstrainedBox(
constraints: dialogTheme.constraints,
child: content,
);
}
final needsCancelAction = automaticallyShowCancel &&
(actions == null || actions!.length <= 1) &&
(theme.platform == TargetPlatform.iOS || theme.platform == TargetPlatform.macOS);
return AlertDialog.adaptive(
icon: icon,
title: title,
content: content,
actions: [
if (needsCancelAction)
NeonDialogAction(
onPressed: () {
Navigator.pop(context);
},
child: Text(
NeonLocalizations.of(context).actionCancel,
textAlign: TextAlign.end,
),
),
...?actions,
],
);
}
}
/// A [NeonDialog] with predefined `actions` to confirm or decline.
class NeonConfirmationDialog extends StatelessWidget {
/// Creates a new confirmation dialog.
const NeonConfirmationDialog({
required this.title,
this.content,
this.icon,
this.confirmAction,
this.declineAction,
this.isDestructive = true,
super.key,
});
/// The title of the dialog is displayed in a large font at the top of the
/// dialog.
///
/// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat
/// and the text does not overflow.
final String title;
/// {@macro NeonDialog.icon}
final Widget? icon;
/// {@macro NeonDialog.content}
final Widget? content;
/// An optional override for the confirming action.
///
/// It is advised to wrap the action in a [Builder] to retain an up to date
/// `context` for the Navigator.
///
/// Typically this is a [NeonDialogAction] widget.
final Widget? confirmAction;
/// An optional override for the declining action.
///
/// It is advised to wrap the action in a [Builder] to retain an up to date
/// `context` for the Navigator.
///
/// Typically this is a [NeonDialogAction] widget.
final Widget? declineAction;
/// Whether confirming this dialog destroys an object.
///
/// For example, a warning dialog that when accepted deletes an email is
/// considered destructive.
/// This value will set the default confirming action to being destructive.
///
/// Defaults to true and cannot be null.
final bool isDestructive;
@override
Widget build(final BuildContext context) {
final confirm = ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: NcColors.accept,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(NeonLocalizations.of(context).actionYes),
);
final confirm = confirmAction ??
NeonDialogAction(
isDestructiveAction: isDestructive,
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(
NeonLocalizations.of(context).actionContinue,
textAlign: TextAlign.end,
),
);
final decline = ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: NcColors.decline,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(NeonLocalizations.of(context).actionNo),
);
final decline = declineAction ??
NeonDialogAction(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(
NeonLocalizations.of(context).actionCancel,
textAlign: TextAlign.end,
),
);
return AlertDialog(
return NeonDialog(
icon: icon,
title: Text(title),
actionsAlignment: MainAxisAlignment.spaceEvenly,
content: content,
actions: [
decline,
confirm,
@ -80,23 +286,33 @@ class NeonConfirmationDialog extends StatelessWidget {
}
}
class RenameDialog extends StatefulWidget {
const RenameDialog({
/// A [NeonDialog] that shows for renaming an object.
///
/// Use `showRenameDialog` to display this dialog.
///
/// When submitted the new value will be popped as a `String`.
class NeonRenameDialog extends StatefulWidget {
/// Creates a new Neon rename dialog.
const NeonRenameDialog({
required this.title,
required this.value,
super.key,
});
/// The title of the dialog.
final String title;
/// The initial value of the rename field.
///
/// This is the current name of the object to be renamed.
final String value;
@override
State<RenameDialog> createState() => _RenameDialogState();
State<NeonRenameDialog> createState() => _NeonRenameDialogState();
}
class _RenameDialogState extends State<RenameDialog> {
class _NeonRenameDialogState extends State<NeonRenameDialog> {
final formKey = GlobalKey<FormState>();
final controller = TextEditingController();
@override
@ -118,42 +334,99 @@ class _RenameDialogState extends State<RenameDialog> {
}
@override
Widget build(final BuildContext context) => NeonDialog(
title: Text(widget.title),
children: [
Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
TextFormField(
autofocus: true,
controller: controller,
validator: (final input) => validateNotEmpty(context, input),
onFieldSubmitted: (final _) {
submit();
},
),
ElevatedButton(
onPressed: submit,
child: Text(widget.title),
),
],
),
Widget build(final BuildContext context) {
final content = Material(
child: TextFormField(
autofocus: true,
controller: controller,
validator: (final input) => validateNotEmpty(context, input),
onFieldSubmitted: (final _) {
submit();
},
),
);
return NeonDialog(
title: Text(widget.title),
content: Form(key: formKey, child: content),
actions: [
NeonDialogAction(
isDefaultAction: true,
onPressed: submit,
child: Text(
widget.title,
textAlign: TextAlign.end,
),
],
);
),
],
);
}
}
/// A [NeonDialog] that informs the user about an error.
///
/// Use `showErrorDialog` to display this dialog.
class NeonErrorDialog extends StatelessWidget {
/// Creates a new error dialog.
const NeonErrorDialog({
required this.content,
this.title,
super.key,
});
/// The (optional) title for the dialog.
///
/// Defaults to [NeonLocalizations.errorDialog].
final String? title;
/// The content of the dialog.
final String content;
@override
Widget build(final BuildContext context) {
final title = this.title ?? NeonLocalizations.of(context).errorDialog;
final closeAction = NeonDialogAction(
isDestructiveAction: true,
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
NeonLocalizations.of(context).actionClose,
textAlign: TextAlign.end,
),
);
return NeonDialog(
automaticallyShowCancel: false,
icon: const Icon(Icons.error),
title: Text(title),
content: Text(content),
actions: [
closeAction,
],
);
}
}
/// Account selection dialog.
///
/// Displays a list of all logged in accounts.
///
/// When one is selected the dialog gets pooped with the selected `Account`.
@internal
class NeonAccountSelectionDialog extends StatelessWidget {
/// Creates a new account selection dialog.
const NeonAccountSelectionDialog({
this.highlightActiveAccount = false,
this.children,
super.key,
});
/// Whether the selected account is highlighted with a leading check icon.
final bool highlightActiveAccount;
/// The (optional) trailing children of this dialog.
final List<Widget>? children;
@override
@ -195,7 +468,7 @@ class NeonAccountSelectionDialog extends StatelessWidget {
return Dialog(
child: IntrinsicHeight(
child: Container(
padding: const EdgeInsets.all(24),
padding: dialogTheme.padding,
constraints: dialogTheme.constraints,
child: body,
),
@ -203,3 +476,43 @@ class NeonAccountSelectionDialog extends StatelessWidget {
);
}
}
/// A [NeonDialog] to inform the user about the UnifiedPush feature of neon.
@internal
class NeonUnifiedPushDialog extends StatelessWidget {
/// Creates a new UnifiedPush dialog.
const NeonUnifiedPushDialog({
super.key,
});
@override
Widget build(final BuildContext context) => NeonDialog(
title: Text(NeonLocalizations.of(context).nextPushSupported),
content: Text(NeonLocalizations.of(context).nextPushSupportedText),
actions: [
NeonDialogAction(
onPressed: () {
Navigator.pop(context);
},
child: Text(
NeonLocalizations.of(context).actionCancel,
textAlign: TextAlign.end,
),
),
NeonDialogAction(
isDefaultAction: true,
onPressed: () async {
Navigator.pop(context);
await launchUrlString(
'https://f-droid.org/packages/$unifiedPushNextPushID',
mode: LaunchMode.externalApplication,
);
},
child: Text(
NeonLocalizations.of(context).nextPushSupportedInstall,
textAlign: TextAlign.end,
),
),
],
);
}

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/image.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",
"actionYes": "Yes",
"actionNo": "No",
"actionDelete": "Delete",
"actionRename": "Rename",
"actionMove": "Move",
@ -43,6 +41,8 @@
}
}
},
"actionDeleteTitle": "Permanently delete?",
"filesChooseCreate": "Add to Nextcloud",
"folderCreate": "Create folder",
"folderName": "Folder name",
"folderRename": "Rename folder",

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.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
/// No description provided for @actionYes.
///
/// In en, this message translates to:
/// **'Yes'**
String get actionYes;
/// No description provided for @actionNo.
///
/// In en, this message translates to:
/// **'No'**
String get actionNo;
/// No description provided for @actionDelete.
///
/// In en, this message translates to:
@ -185,6 +173,18 @@ abstract class FilesLocalizations {
/// **'Are you sure you want to download a file that is bigger than {warningSize} ({actualSize})?'**
String downloadConfirmSizeWarning(String warningSize, String actualSize);
/// No description provided for @actionDeleteTitle.
///
/// In en, this message translates to:
/// **'Permanently delete?'**
String get actionDeleteTitle;
/// No description provided for @filesChooseCreate.
///
/// In en, this message translates to:
/// **'Add to Nextcloud'**
String get filesChooseCreate;
/// No description provided for @folderCreate.
///
/// In en, this message translates to:

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

@ -4,12 +4,6 @@ import 'localizations.dart';
class FilesLocalizationsEn extends FilesLocalizations {
FilesLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get actionYes => 'Yes';
@override
String get actionNo => 'No';
@override
String get actionDelete => 'Delete';
@ -58,6 +52,12 @@ class FilesLocalizationsEn extends FilesLocalizations {
return 'Are you sure you want to download a file that is bigger than $warningSize ($actualSize)?';
}
@override
String get actionDeleteTitle => 'Permanently delete?';
@override
String get filesChooseCreate => 'Add to Nextcloud';
@override
String get folderCreate => 'Create folder';

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1
packages/neon/neon_news/pubspec.yaml

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

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

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

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

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

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

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

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

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

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

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.
static const List<Locale> supportedLocales = <Locale>[Locale('en')];
/// No description provided for @actionClose.
///
/// In en, this message translates to:
/// **'Close'**
String get actionClose;
/// No description provided for @notificationsDismissAll.
///
/// In en, this message translates to:

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

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

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

@ -96,25 +96,9 @@ class _NotificationsMainPageState extends State<NotificationsMainPage> {
final accountsBloc = NeonProvider.of<AccountsBloc>(context);
await accountsBloc.activeAppsBloc.setActiveApp(app.id);
} else {
final colorScheme = Theme.of(context).colorScheme;
await showDialog<void>(
await showUnimplementedDialog(
context: context,
builder: (final context) => AlertDialog(
title: Text(NotificationsLocalizations.of(context).notificationAppNotImplementedYet),
actions: [
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
),
onPressed: () {
Navigator.of(context).pop();
},
child: Text(NotificationsLocalizations.of(context).actionClose),
),
],
),
title: NotificationsLocalizations.of(context).notificationAppNotImplementedYet,
);
}
},

Loading…
Cancel
Save