Compare commits
2 Commits
main
...
refactor/n
Author | SHA1 | Date |
---|---|---|
Nikolas Rimikis | 280c64e415 | 1 year ago |
Nikolas Rimikis | 69ef5b2884 | 1 year ago |
57 changed files with 1901 additions and 1170 deletions
@ -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; |
@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/l10n/localizations.dart'; |
||||
import 'package:neon/src/widgets/dialog.dart'; |
||||
|
||||
/// 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), |
||||
) ?? |
||||
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 initialValue, |
||||
}) async => |
||||
showAdaptiveDialog<String?>( |
||||
context: context, |
||||
builder: (final context) => NeonRenameDialog( |
||||
title: title, |
||||
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, |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
@ -1,83 +0,0 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/src/utils/validators.dart'; |
||||
import 'package:neon/src/widgets/dialog.dart'; |
||||
|
||||
Future<String?> showRenameDialog({ |
||||
required final BuildContext context, |
||||
required final String title, |
||||
required final String value, |
||||
final Key? key, |
||||
}) async => |
||||
showDialog<String?>( |
||||
context: context, |
||||
builder: (final context) => _RenameDialog( |
||||
title: title, |
||||
value: value, |
||||
key: key, |
||||
), |
||||
); |
||||
|
||||
class _RenameDialog extends StatefulWidget { |
||||
const _RenameDialog({ |
||||
required this.title, |
||||
required this.value, |
||||
super.key, |
||||
}); |
||||
|
||||
final String title; |
||||
final String value; |
||||
|
||||
@override |
||||
State<_RenameDialog> createState() => _RenameDialogState(); |
||||
} |
||||
|
||||
class _RenameDialogState extends State<_RenameDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
|
||||
final controller = TextEditingController(); |
||||
|
||||
@override |
||||
void initState() { |
||||
controller.text = widget.value; |
||||
super.initState(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(controller.text); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(widget.title), |
||||
children: [ |
||||
Form( |
||||
key: formKey, |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.end, |
||||
children: [ |
||||
TextFormField( |
||||
autofocus: true, |
||||
controller: controller, |
||||
validator: (final input) => validateNotEmpty(context, input), |
||||
onFieldSubmitted: (final _) { |
||||
submit(); |
||||
}, |
||||
), |
||||
ElevatedButton( |
||||
onPressed: submit, |
||||
child: Text(widget.title), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -1,64 +0,0 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:neon/src/blocs/accounts.dart'; |
||||
import 'package:neon/src/theme/dialog.dart'; |
||||
import 'package:neon/src/utils/provider.dart'; |
||||
import 'package:neon/src/widgets/account_tile.dart'; |
||||
|
||||
@internal |
||||
class NeonAccountSelectionDialog extends StatelessWidget { |
||||
const NeonAccountSelectionDialog({ |
||||
this.highlightActiveAccount = false, |
||||
this.children, |
||||
super.key, |
||||
}); |
||||
|
||||
final bool highlightActiveAccount; |
||||
final List<Widget>? children; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final accountsBloc = NeonProvider.of<AccountsBloc>(context); |
||||
final accounts = accountsBloc.accounts.value; |
||||
final activeAccount = accountsBloc.activeAccount.value!; |
||||
|
||||
final sortedAccounts = List.of(accounts) |
||||
..removeWhere((final account) => account.id == activeAccount.id) |
||||
..insert(0, activeAccount); |
||||
|
||||
final tiles = sortedAccounts |
||||
.map<Widget>( |
||||
(final account) => NeonAccountTile( |
||||
account: account, |
||||
trailing: highlightActiveAccount && account.id == activeAccount.id ? const Icon(Icons.check_circle) : null, |
||||
onTap: () { |
||||
Navigator.of(context).pop(account); |
||||
}, |
||||
), |
||||
) |
||||
.toList(); |
||||
if (highlightActiveAccount && accounts.length > 1) { |
||||
tiles.insert(1, const Divider()); |
||||
} |
||||
|
||||
final body = SingleChildScrollView( |
||||
child: Column( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
||||
...tiles, |
||||
...?children, |
||||
], |
||||
), |
||||
); |
||||
|
||||
return Dialog( |
||||
child: IntrinsicHeight( |
||||
child: Container( |
||||
padding: const EdgeInsets.all(24), |
||||
constraints: NeonDialogTheme.of(context).constraints, |
||||
child: body, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
@ -1,33 +1,518 @@
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:meta/meta.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: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), |
||||
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, |
||||
children: children, |
||||
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 = confirmAction ?? |
||||
NeonDialogAction( |
||||
isDestructiveAction: isDestructive, |
||||
onPressed: () { |
||||
Navigator.of(context).pop(true); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).actionContinue, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
); |
||||
|
||||
final decline = declineAction ?? |
||||
NeonDialogAction( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(false); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).actionCancel, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
); |
||||
|
||||
return NeonDialog( |
||||
icon: icon, |
||||
title: Text(title), |
||||
content: content, |
||||
actions: [ |
||||
decline, |
||||
confirm, |
||||
], |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// A [NeonDialog] that shows for renaming an object. |
||||
/// |
||||
/// Use `showRenameDialog` to display this dialog. |
||||
/// |
||||
/// When submitted the new value will be popped as a `String`. |
||||
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<NeonRenameDialog> createState() => _NeonRenameDialogState(); |
||||
} |
||||
|
||||
class _NeonRenameDialogState extends State<NeonRenameDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
final controller = TextEditingController(); |
||||
|
||||
@override |
||||
void initState() { |
||||
controller.text = widget.value; |
||||
super.initState(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(controller.text); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
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 |
||||
Widget build(final BuildContext context) { |
||||
final dialogTheme = NeonDialogTheme.of(context); |
||||
final accountsBloc = NeonProvider.of<AccountsBloc>(context); |
||||
final accounts = accountsBloc.accounts.value; |
||||
final activeAccount = accountsBloc.activeAccount.value!; |
||||
|
||||
final sortedAccounts = List.of(accounts) |
||||
..removeWhere((final account) => account.id == activeAccount.id) |
||||
..insert(0, activeAccount); |
||||
|
||||
final tiles = sortedAccounts |
||||
.map<Widget>( |
||||
(final account) => NeonAccountTile( |
||||
account: account, |
||||
trailing: highlightActiveAccount && account.id == activeAccount.id ? const Icon(Icons.check_circle) : null, |
||||
onTap: () { |
||||
Navigator.of(context).pop(account); |
||||
}, |
||||
), |
||||
) |
||||
.toList(); |
||||
if (highlightActiveAccount && accounts.length > 1) { |
||||
tiles.insert(1, const Divider()); |
||||
} |
||||
|
||||
final body = SingleChildScrollView( |
||||
child: Column( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
||||
...tiles, |
||||
...?children, |
||||
], |
||||
), |
||||
); |
||||
|
||||
return Dialog( |
||||
child: IntrinsicHeight( |
||||
child: Container( |
||||
padding: dialogTheme.padding, |
||||
constraints: dialogTheme.constraints, |
||||
child: body, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// A [NeonDialog] to inform the user about the UnifiedPush feature of neon. |
||||
@internal |
||||
class NeonUnifiedPushDialog extends StatelessWidget { |
||||
/// Creates a new UnifiedPush dialog. |
||||
const NeonUnifiedPushDialog({ |
||||
super.key, |
||||
}); |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(NeonLocalizations.of(context).nextPushSupported), |
||||
content: Text(NeonLocalizations.of(context).nextPushSupportedText), |
||||
actions: [ |
||||
NeonDialogAction( |
||||
onPressed: () { |
||||
Navigator.pop(context); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).actionCancel, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
NeonDialogAction( |
||||
isDefaultAction: true, |
||||
onPressed: () async { |
||||
Navigator.pop(context); |
||||
await launchUrlString( |
||||
'https://f-droid.org/packages/$unifiedPushNextPushID', |
||||
mode: LaunchMode.externalApplication, |
||||
); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).nextPushSupportedInstall, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
@ -1,9 +1,8 @@
|
||||
export 'package:neon/l10n/localizations.dart'; |
||||
export 'package:neon/src/utils/app_route.dart'; |
||||
export 'package:neon/src/utils/confirmation_dialog.dart'; |
||||
export 'package:neon/src/utils/dialog.dart'; |
||||
export 'package:neon/src/utils/exceptions.dart'; |
||||
export 'package:neon/src/utils/hex_color.dart'; |
||||
export 'package:neon/src/utils/provider.dart'; |
||||
export 'package:neon/src/utils/rename_dialog.dart'; |
||||
export 'package:neon/src/utils/request_manager.dart' hide Cache; |
||||
export 'package:neon/src/utils/validators.dart'; |
||||
|
@ -1,121 +0,0 @@
|
||||
part of '../neon_files.dart'; |
||||
|
||||
class FilesChooseCreateDialog extends StatefulWidget { |
||||
const FilesChooseCreateDialog({ |
||||
required this.bloc, |
||||
required this.basePath, |
||||
super.key, |
||||
}); |
||||
|
||||
final FilesBloc bloc; |
||||
final PathUri basePath; |
||||
|
||||
@override |
||||
State<FilesChooseCreateDialog> createState() => _FilesChooseCreateDialogState(); |
||||
} |
||||
|
||||
class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> { |
||||
Future<void> uploadFromPick(final FileType type) async { |
||||
final result = await FilePicker.platform.pickFiles( |
||||
allowMultiple: true, |
||||
type: type, |
||||
); |
||||
if (result != null) { |
||||
for (final file in result.files) { |
||||
await upload(File(file.path!)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Future<void> upload(final File file) async { |
||||
final sizeWarning = widget.bloc.options.uploadSizeWarning.value; |
||||
if (sizeWarning != null) { |
||||
final stat = file.statSync(); |
||||
if (stat.size > sizeWarning) { |
||||
if (!(await showConfirmationDialog( |
||||
context, |
||||
FilesLocalizations.of(context).uploadConfirmSizeWarning( |
||||
filesize(sizeWarning), |
||||
filesize(stat.size), |
||||
), |
||||
))) { |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
widget.bloc.uploadFile( |
||||
widget.basePath.join(PathUri.parse(p.basename(file.path))), |
||||
file.path, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
children: [ |
||||
ListTile( |
||||
leading: Icon( |
||||
MdiIcons.filePlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
title: Text(FilesLocalizations.of(context).uploadFiles), |
||||
onTap: () async { |
||||
await uploadFromPick(FileType.any); |
||||
|
||||
if (mounted) { |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
), |
||||
ListTile( |
||||
leading: Icon( |
||||
MdiIcons.fileImagePlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
title: Text(FilesLocalizations.of(context).uploadImages), |
||||
onTap: () async { |
||||
await uploadFromPick(FileType.image); |
||||
|
||||
if (mounted) { |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
), |
||||
if (NeonPlatform.instance.canUseCamera) ...[ |
||||
ListTile( |
||||
leading: Icon( |
||||
MdiIcons.cameraPlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
title: Text(FilesLocalizations.of(context).uploadCamera), |
||||
onTap: () async { |
||||
Navigator.of(context).pop(); |
||||
|
||||
final picker = ImagePicker(); |
||||
final result = await picker.pickImage(source: ImageSource.camera); |
||||
if (result != null) { |
||||
await upload(File(result.path)); |
||||
} |
||||
}, |
||||
), |
||||
], |
||||
ListTile( |
||||
leading: Icon( |
||||
MdiIcons.folderPlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
title: Text(FilesLocalizations.of(context).folderCreate), |
||||
onTap: () async { |
||||
Navigator.of(context).pop(); |
||||
|
||||
final result = await showDialog<String>( |
||||
context: context, |
||||
builder: (final context) => const FilesCreateFolderDialog(), |
||||
); |
||||
if (result != null) { |
||||
widget.bloc.browser.createFolder(widget.basePath.join(PathUri.parse(result))); |
||||
} |
||||
}, |
||||
), |
||||
], |
||||
); |
||||
} |
@ -1,66 +0,0 @@
|
||||
part of '../neon_files.dart'; |
||||
|
||||
class FilesChooseFolderDialog extends StatelessWidget { |
||||
const FilesChooseFolderDialog({ |
||||
required this.bloc, |
||||
required this.filesBloc, |
||||
required this.originalPath, |
||||
super.key, |
||||
}); |
||||
|
||||
final FilesBrowserBloc bloc; |
||||
final FilesBloc filesBloc; |
||||
|
||||
final PathUri originalPath; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => AlertDialog( |
||||
title: Text(FilesLocalizations.of(context).folderChoose), |
||||
contentPadding: EdgeInsets.zero, |
||||
content: SizedBox( |
||||
width: double.maxFinite, |
||||
child: Column( |
||||
children: [ |
||||
Expanded( |
||||
child: FilesBrowserView( |
||||
bloc: bloc, |
||||
filesBloc: filesBloc, |
||||
mode: FilesBrowserMode.selectDirectory, |
||||
), |
||||
), |
||||
StreamBuilder<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(), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
@ -1,58 +0,0 @@
|
||||
part of '../neon_files.dart'; |
||||
|
||||
class FilesCreateFolderDialog extends StatefulWidget { |
||||
const FilesCreateFolderDialog({ |
||||
super.key, |
||||
}); |
||||
|
||||
@override |
||||
State<FilesCreateFolderDialog> createState() => _FilesCreateFolderDialogState(); |
||||
} |
||||
|
||||
class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> { |
||||
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) => 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), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -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), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,339 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
import 'package:neon/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'; |
||||
|
||||
/// Creates an adaptive bottom sheet to select an action to add a file. |
||||
class FilesChooseCreateModal extends StatefulWidget { |
||||
/// Creates a new add files modal. |
||||
const FilesChooseCreateModal({ |
||||
required this.bloc, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The bloc of the flies client. |
||||
final FilesBloc bloc; |
||||
|
||||
@override |
||||
State<FilesChooseCreateModal> createState() => _FilesChooseCreateModalState(); |
||||
} |
||||
|
||||
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!)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Future<void> upload(final File file) async { |
||||
final sizeWarning = widget.bloc.options.uploadSizeWarning.value; |
||||
if (sizeWarning != null) { |
||||
final stat = file.statSync(); |
||||
if (stat.size > sizeWarning) { |
||||
final result = await showUploadConfirmationDialog(context, sizeWarning, stat.size); |
||||
|
||||
if (!result) { |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
widget.bloc.uploadFile( |
||||
baseUri.join(PathUri.parse(p.basename(file.path))), |
||||
file.path, |
||||
); |
||||
} |
||||
|
||||
Widget wrapAction({ |
||||
required final Widget icon, |
||||
required final Widget message, |
||||
required final VoidCallback onPressed, |
||||
}) { |
||||
final theme = Theme.of(context); |
||||
|
||||
switch (theme.platform) { |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
return ListTile( |
||||
leading: icon, |
||||
title: message, |
||||
onTap: onPressed, |
||||
); |
||||
|
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
return CupertinoActionSheetAction( |
||||
onPressed: onPressed, |
||||
child: message, |
||||
); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
final title = FilesLocalizations.of(context).filesChooseCreate; |
||||
|
||||
final actions = [ |
||||
wrapAction( |
||||
icon: Icon( |
||||
MdiIcons.filePlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
message: Text(FilesLocalizations.of(context).uploadFiles), |
||||
onPressed: () async => uploadFromPick(FileType.any), |
||||
), |
||||
wrapAction( |
||||
icon: Icon( |
||||
MdiIcons.fileImagePlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
message: Text(FilesLocalizations.of(context).uploadImages), |
||||
onPressed: () async => uploadFromPick(FileType.image), |
||||
), |
||||
if (NeonPlatform.instance.canUseCamera) |
||||
wrapAction( |
||||
icon: Icon( |
||||
MdiIcons.cameraPlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
message: Text(FilesLocalizations.of(context).uploadCamera), |
||||
onPressed: () async { |
||||
Navigator.of(context).pop(); |
||||
|
||||
final picker = ImagePicker(); |
||||
final result = await picker.pickImage(source: ImageSource.camera); |
||||
if (result != null) { |
||||
await upload(File(result.path)); |
||||
} |
||||
}, |
||||
), |
||||
wrapAction( |
||||
icon: Icon( |
||||
MdiIcons.folderPlus, |
||||
color: Theme.of(context).colorScheme.primary, |
||||
), |
||||
message: Text(FilesLocalizations.of(context).folderCreate), |
||||
onPressed: () async { |
||||
Navigator.of(context).pop(); |
||||
|
||||
final result = await showFolderCreateDialog(context: context); |
||||
if (result != null) { |
||||
widget.bloc.browser.createFolder(baseUri.join(PathUri.parse(result))); |
||||
} |
||||
}, |
||||
), |
||||
]; |
||||
|
||||
switch (theme.platform) { |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
return BottomSheet( |
||||
onClosing: () {}, |
||||
builder: (final context) => Padding( |
||||
padding: const EdgeInsets.all(24), |
||||
child: Column( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
||||
Padding( |
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
child: Align( |
||||
alignment: AlignmentDirectional.centerStart, |
||||
child: Text( |
||||
title, |
||||
style: theme.textTheme.titleLarge, |
||||
), |
||||
), |
||||
), |
||||
...actions, |
||||
], |
||||
), |
||||
), |
||||
); |
||||
|
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
return CupertinoActionSheet( |
||||
actions: actions, |
||||
title: Text(title), |
||||
cancelButton: CupertinoActionSheetAction( |
||||
onPressed: () => Navigator.pop(context), |
||||
isDestructiveAction: true, |
||||
child: Text(NeonLocalizations.of(context).actionCancel), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// A dialog for choosing a folder. |
||||
/// |
||||
/// This dialog is not adaptive and always builds a material design dialog. |
||||
class FilesChooseFolderDialog extends StatelessWidget { |
||||
/// Creates a new folder chooser dialog. |
||||
const FilesChooseFolderDialog({ |
||||
required this.bloc, |
||||
required this.filesBloc, |
||||
required this.originalPath, |
||||
super.key, |
||||
}); |
||||
|
||||
final FilesBrowserBloc bloc; |
||||
final FilesBloc filesBloc; |
||||
|
||||
/// The initial path to start at. |
||||
final PathUri originalPath; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final dialogTheme = NeonDialogTheme.of(context); |
||||
|
||||
return StreamBuilder<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, |
||||
}); |
||||
|
||||
@override |
||||
State<FilesCreateFolderDialog> createState() => _FilesCreateFolderDialogState(); |
||||
} |
||||
|
||||
class _FilesCreateFolderDialogState extends State<FilesCreateFolderDialog> { |
||||
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: 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, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
@ -1,108 +0,0 @@
|
||||
part of '../neon_news.dart'; |
||||
|
||||
class NewsAddFeedDialog extends StatefulWidget { |
||||
const NewsAddFeedDialog({ |
||||
required this.bloc, |
||||
this.folderID, |
||||
super.key, |
||||
}); |
||||
|
||||
final NewsBloc bloc; |
||||
final int? folderID; |
||||
|
||||
@override |
||||
State<NewsAddFeedDialog> createState() => _NewsAddFeedDialogState(); |
||||
} |
||||
|
||||
class _NewsAddFeedDialogState extends State<NewsAddFeedDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
final controller = TextEditingController(); |
||||
|
||||
news.Folder? folder; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop((controller.text, widget.folderID ?? folder?.id)); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
unawaited( |
||||
Clipboard.getData(Clipboard.kTextPlain).then((final clipboardContent) { |
||||
if (clipboardContent != null && clipboardContent.text != null) { |
||||
final uri = Uri.tryParse(clipboardContent.text!); |
||||
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { |
||||
controller.text = clipboardContent.text!; |
||||
} |
||||
} |
||||
}), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => ResultBuilder<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), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
@ -1,58 +0,0 @@
|
||||
part of '../neon_news.dart'; |
||||
|
||||
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(); |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(controller.text); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(NewsLocalizations.of(context).folderCreate), |
||||
children: [ |
||||
Form( |
||||
key: formKey, |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.end, |
||||
children: [ |
||||
TextFormField( |
||||
autofocus: true, |
||||
controller: controller, |
||||
decoration: InputDecoration( |
||||
hintText: NewsLocalizations.of(context).folderCreateName, |
||||
), |
||||
validator: (final input) => validateNotEmpty(context, input), |
||||
onFieldSubmitted: (final _) { |
||||
submit(); |
||||
}, |
||||
), |
||||
ElevatedButton( |
||||
onPressed: submit, |
||||
child: Text(NewsLocalizations.of(context).folderCreate), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -1,46 +0,0 @@
|
||||
part of '../neon_news.dart'; |
||||
|
||||
class NewsFeedShowURLDialog extends StatefulWidget { |
||||
const NewsFeedShowURLDialog({ |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
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), |
||||
actions: [ |
||||
ElevatedButton( |
||||
onPressed: () async { |
||||
await Clipboard.setData( |
||||
ClipboardData( |
||||
text: widget.feed.url, |
||||
), |
||||
); |
||||
if (mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(NewsLocalizations.of(context).feedCopiedURL), |
||||
), |
||||
); |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
child: Text(NewsLocalizations.of(context).feedCopyURL), |
||||
), |
||||
ElevatedButton( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Text(NewsLocalizations.of(context).actionClose), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -1,46 +0,0 @@
|
||||
part of '../neon_news.dart'; |
||||
|
||||
class NewsFeedUpdateErrorDialog extends StatefulWidget { |
||||
const NewsFeedUpdateErrorDialog({ |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
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!), |
||||
actions: [ |
||||
ElevatedButton( |
||||
onPressed: () async { |
||||
await Clipboard.setData( |
||||
ClipboardData( |
||||
text: widget.feed.lastUpdateError!, |
||||
), |
||||
); |
||||
if (mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), |
||||
), |
||||
); |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
child: Text(NewsLocalizations.of(context).feedCopyErrorMessage), |
||||
), |
||||
ElevatedButton( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Text(NewsLocalizations.of(context).actionClose), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -1,57 +0,0 @@
|
||||
part of '../neon_news.dart'; |
||||
|
||||
class NewsMoveFeedDialog extends StatefulWidget { |
||||
const NewsMoveFeedDialog({ |
||||
required this.folders, |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
final List<news.Folder> folders; |
||||
final news.Feed feed; |
||||
|
||||
@override |
||||
State<NewsMoveFeedDialog> createState() => _NewsMoveFeedDialogState(); |
||||
} |
||||
|
||||
class _NewsMoveFeedDialogState extends State<NewsMoveFeedDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
|
||||
news.Folder? folder; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop([folder?.id]); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(NewsLocalizations.of(context).feedMove), |
||||
children: [ |
||||
Form( |
||||
key: formKey, |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.end, |
||||
children: [ |
||||
NewsFolderSelect( |
||||
folders: widget.folders, |
||||
value: widget.feed.folderId != null |
||||
? widget.folders.singleWhere((final folder) => folder.id == widget.feed.folderId) |
||||
: null, |
||||
onChanged: (final f) { |
||||
setState(() { |
||||
folder = f; |
||||
}); |
||||
}, |
||||
), |
||||
ElevatedButton( |
||||
onPressed: submit, |
||||
child: Text(NewsLocalizations.of(context).feedMove), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
@ -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, |
||||
); |
@ -0,0 +1,375 @@
|
||||
import 'dart:async'; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/utils.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
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 |
||||
State<NewsAddFeedDialog> createState() => _NewsAddFeedDialogState(); |
||||
} |
||||
|
||||
class _NewsAddFeedDialogState extends State<NewsAddFeedDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
final controller = TextEditingController(); |
||||
|
||||
news.Folder? folder; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop((controller.text, widget.folderID ?? folder?.id)); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
unawaited( |
||||
Clipboard.getData(Clipboard.kTextPlain).then((final clipboardContent) { |
||||
if (clipboardContent != null && clipboardContent.text != null) { |
||||
final uri = Uri.tryParse(clipboardContent.text!); |
||||
if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { |
||||
controller.text = clipboardContent.text!; |
||||
} |
||||
} |
||||
}), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final urlField = Form( |
||||
key: formKey, |
||||
child: TextFormField( |
||||
autofocus: true, |
||||
controller: controller, |
||||
decoration: const InputDecoration( |
||||
hintText: 'https://...', |
||||
), |
||||
keyboardType: TextInputType.url, |
||||
validator: (final input) => validateHttpUrl(context, input), |
||||
onFieldSubmitted: (final _) { |
||||
submit(); |
||||
}, |
||||
autofillHints: const [AutofillHints.url], |
||||
), |
||||
); |
||||
|
||||
final folderSelector = ResultBuilder<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, |
||||
), |
||||
); |
||||
} |
||||
|
||||
return NewsFolderSelect( |
||||
folders: folders.requireData, |
||||
value: folder, |
||||
onChanged: (final f) { |
||||
setState(() { |
||||
folder = f; |
||||
}); |
||||
}, |
||||
); |
||||
}, |
||||
); |
||||
|
||||
return NeonDialog( |
||||
title: Text(NewsLocalizations.of(context).feedAdd), |
||||
content: Material( |
||||
child: Column( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: [ |
||||
urlField, |
||||
const SizedBox(height: 8), |
||||
folderSelector, |
||||
], |
||||
), |
||||
), |
||||
actions: [ |
||||
NeonDialogAction( |
||||
isDefaultAction: true, |
||||
onPressed: submit, |
||||
child: Text( |
||||
NewsLocalizations.of(context).feedAdd, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// A dialog for displaying the url of a news feed. |
||||
class NewsFeedShowURLDialog extends StatelessWidget { |
||||
/// Creates a new display url dialog. |
||||
const NewsFeedShowURLDialog({ |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The feed to display the url for. |
||||
final news.Feed feed; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(feed.url), |
||||
actions: [ |
||||
NeonDialogAction( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).actionClose, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
NeonDialogAction( |
||||
isDefaultAction: true, |
||||
onPressed: () async { |
||||
await Clipboard.setData( |
||||
ClipboardData( |
||||
text: feed.url, |
||||
), |
||||
); |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(NewsLocalizations.of(context).feedCopiedURL), |
||||
), |
||||
); |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
child: Text( |
||||
NewsLocalizations.of(context).feedCopyURL, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
class NewsFeedUpdateErrorDialog extends StatelessWidget { |
||||
const NewsFeedUpdateErrorDialog({ |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
final news.Feed feed; |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(feed.lastUpdateError!), |
||||
actions: [ |
||||
NeonDialogAction( |
||||
onPressed: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Text( |
||||
NeonLocalizations.of(context).actionClose, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
NeonDialogAction( |
||||
isDefaultAction: true, |
||||
onPressed: () async { |
||||
await Clipboard.setData( |
||||
ClipboardData( |
||||
text: feed.lastUpdateError!, |
||||
), |
||||
); |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of(context).showSnackBar( |
||||
SnackBar( |
||||
content: Text(NewsLocalizations.of(context).feedCopiedErrorMessage), |
||||
), |
||||
); |
||||
Navigator.of(context).pop(); |
||||
} |
||||
}, |
||||
child: Text( |
||||
NewsLocalizations.of(context).feedCopyErrorMessage, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
/// A dialog for moving a news feed by into a different folder. |
||||
/// |
||||
/// When moved the id of the new folder will be popped. |
||||
class NewsMoveFeedDialog extends StatefulWidget { |
||||
/// Creates a new move feed dialog. |
||||
const NewsMoveFeedDialog({ |
||||
required this.folders, |
||||
required this.feed, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The list of available folders. |
||||
final List<news.Folder> folders; |
||||
|
||||
/// The feed to move. |
||||
final news.Feed feed; |
||||
|
||||
@override |
||||
State<NewsMoveFeedDialog> createState() => _NewsMoveFeedDialogState(); |
||||
} |
||||
|
||||
class _NewsMoveFeedDialogState extends State<NewsMoveFeedDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
|
||||
news.Folder? folder; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(folder?.id); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
folder = widget.folders.singleWhereOrNull((final folder) => folder.id == widget.feed.folderId); |
||||
|
||||
super.initState(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => NeonDialog( |
||||
title: Text(NewsLocalizations.of(context).feedMove), |
||||
content: Material( |
||||
child: Form( |
||||
key: formKey, |
||||
child: NewsFolderSelect( |
||||
folders: widget.folders, |
||||
value: folder, |
||||
onChanged: (final f) { |
||||
setState(() { |
||||
folder = f; |
||||
}); |
||||
}, |
||||
), |
||||
), |
||||
), |
||||
actions: [ |
||||
NeonDialogAction( |
||||
isDefaultAction: true, |
||||
onPressed: submit, |
||||
child: Text( |
||||
NewsLocalizations.of(context).feedMove, |
||||
textAlign: TextAlign.end, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
/// A [NeonDialog] that shows for renaming creating a new folder. |
||||
/// |
||||
/// Use `showFolderCreateDialog` to display this dialog. |
||||
/// |
||||
/// When submitted the folder name will be popped as a `String`. |
||||
class NewsCreateFolderDialog extends StatefulWidget { |
||||
/// Creates a new NeonDialog for creating a folder. |
||||
const NewsCreateFolderDialog({ |
||||
super.key, |
||||
}); |
||||
|
||||
@override |
||||
State<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, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
@ -1,88 +0,0 @@
|
||||
part of '../neon_notes.dart'; |
||||
|
||||
class NotesCreateNoteDialog extends StatefulWidget { |
||||
const NotesCreateNoteDialog({ |
||||
required this.bloc, |
||||
this.category, |
||||
super.key, |
||||
}); |
||||
|
||||
final NotesBloc bloc; |
||||
final String? category; |
||||
|
||||
@override |
||||
State<NotesCreateNoteDialog> createState() => _NotesCreateNoteDialogState(); |
||||
} |
||||
|
||||
class _NotesCreateNoteDialogState extends State<NotesCreateNoteDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
final controller = TextEditingController(); |
||||
String? selectedCategory; |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop((controller.text, widget.category ?? selectedCategory)); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) => ResultBuilder<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), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
@ -1,70 +0,0 @@
|
||||
part of '../neon_notes.dart'; |
||||
|
||||
class NotesSelectCategoryDialog extends StatefulWidget { |
||||
const NotesSelectCategoryDialog({ |
||||
required this.bloc, |
||||
this.initialCategory, |
||||
super.key, |
||||
}); |
||||
|
||||
final NotesBloc bloc; |
||||
final String? initialCategory; |
||||
|
||||
@override |
||||
State<NotesSelectCategoryDialog> createState() => _NotesSelectCategoryDialogState(); |
||||
} |
||||
|
||||
class _NotesSelectCategoryDialogState extends State<NotesSelectCategoryDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
|
||||
String? selectedCategory; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(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).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), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
} |
@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:neon/blocs.dart'; |
||||
import 'package:neon/utils.dart'; |
||||
import 'package:neon/widgets.dart'; |
||||
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.initialCategory, |
||||
super.key, |
||||
}); |
||||
|
||||
/// The active notes bloc. |
||||
final NotesBloc bloc; |
||||
|
||||
/// The initial category of the note. |
||||
final String? initialCategory; |
||||
|
||||
@override |
||||
State<NotesCreateNoteDialog> createState() => _NotesCreateNoteDialogState(); |
||||
} |
||||
|
||||
class _NotesCreateNoteDialogState extends State<NotesCreateNoteDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
final controller = TextEditingController(); |
||||
String? selectedCategory; |
||||
|
||||
@override |
||||
void dispose() { |
||||
controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop((controller.text, widget.initialCategory ?? selectedCategory)); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(final BuildContext context) { |
||||
final titleField = Form( |
||||
key: formKey, |
||||
child: TextFormField( |
||||
autofocus: true, |
||||
controller: controller, |
||||
decoration: InputDecoration( |
||||
hintText: NotesLocalizations.of(context).noteTitle, |
||||
), |
||||
validator: (final input) => validateNotEmpty(context, input), |
||||
onFieldSubmitted: (final _) { |
||||
submit(); |
||||
}, |
||||
), |
||||
); |
||||
|
||||
final folderSelector = ResultBuilder<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 |
||||
State<NotesSelectCategoryDialog> createState() => _NotesSelectCategoryDialogState(); |
||||
} |
||||
|
||||
class _NotesSelectCategoryDialogState extends State<NotesSelectCategoryDialog> { |
||||
final formKey = GlobalKey<FormState>(); |
||||
|
||||
String? selectedCategory; |
||||
|
||||
void submit() { |
||||
if (formKey.currentState!.validate()) { |
||||
Navigator.of(context).pop(selectedCategory); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
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, |
||||
), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
} |
@ -1,6 +1,5 @@
|
||||
{ |
||||
"@@locale": "en", |
||||
"actionClose": "Close", |
||||
"notificationsDismissAll": "Dismiss all notifications", |
||||
"notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet" |
||||
} |
||||
|
Loading…
Reference in new issue