Compare commits
2 Commits
main
...
refactor/n
Author | SHA1 | Date |
---|---|---|
|
280c64e415 | 1 year ago |
|
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: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 { |
class NeonDialog extends StatelessWidget { |
||||||
/// Creates a Neon dialog. |
/// Creates a Neon dialog. |
||||||
/// |
/// |
||||||
/// Typically used in conjunction with [showDialog]. |
/// Typically used in conjunction with [showDialog]. |
||||||
const NeonDialog({ |
const NeonDialog({ |
||||||
|
this.icon, |
||||||
this.title, |
this.title, |
||||||
this.children, |
this.content, |
||||||
|
this.actions, |
||||||
|
this.automaticallyShowCancel = true, |
||||||
super.key, |
super.key, |
||||||
}); |
}); |
||||||
|
|
||||||
|
/// {@template NeonDialog.icon} |
||||||
|
/// An optional icon to display at the top of the dialog. |
||||||
|
/// |
||||||
|
/// Typically, an [Icon] widget. Providing an icon centers the [title]'s text. |
||||||
|
/// {@endtemplate} |
||||||
|
final Widget? icon; |
||||||
|
|
||||||
/// The (optional) title of the dialog is displayed in a large font at the top |
/// The (optional) title of the dialog is displayed in a large font at the top |
||||||
/// of the dialog. |
/// of the dialog. |
||||||
/// |
/// |
||||||
|
/// It is up to the caller to enforce [NeonDialogTheme.constraints] is meat. |
||||||
|
/// |
||||||
/// Typically a [Text] widget. |
/// Typically a [Text] widget. |
||||||
final Widget? title; |
final Widget? title; |
||||||
|
|
||||||
/// The (optional) content of the dialog is displayed in a |
/// {@template NeonDialog.content} |
||||||
/// [SingleChildScrollView] underneath the title. |
/// The (optional) content of the dialog is displayed in the center of the |
||||||
|
/// dialog in a lighter font. |
||||||
/// |
/// |
||||||
/// Typically a list of [SimpleDialogOption]s. |
/// Typically this is a [SingleChildScrollView] that contains the dialog's |
||||||
final List<Widget>? children; |
/// message. As noted in the [AlertDialog] documentation, it's important |
||||||
|
/// to use a [SingleChildScrollView] if there's any risk that the content |
||||||
|
/// will not fit, as the contents will otherwise overflow the dialog. |
||||||
|
/// |
||||||
|
/// The horizontal dimension of this widget is constrained by the closest |
||||||
|
/// [NeonDialogTheme.constraints]. |
||||||
|
/// {@endtemplate} |
||||||
|
final Widget? content; |
||||||
|
|
||||||
|
/// The (optional) set of actions that are displayed at the bottom of the |
||||||
|
/// dialog with an [OverflowBar]. |
||||||
|
/// |
||||||
|
/// Typically this is a list of [NeonDialogAction] widgets. It is recommended |
||||||
|
/// to set the [Text.textAlign] to [TextAlign.end] for the [Text] within the |
||||||
|
/// [TextButton], so that buttons whose labels wrap to an extra line align |
||||||
|
/// with the overall [OverflowBar]'s alignment within the dialog. |
||||||
|
/// |
||||||
|
/// If the [title] is not null but the [content] _is_ null, then an extra 20 |
||||||
|
/// pixels of padding is added above the [OverflowBar] to separate the [title] |
||||||
|
/// from the [actions]. |
||||||
|
final List<Widget>? actions; |
||||||
|
|
||||||
|
/// Whether to automatically show a cancel button when only less than two |
||||||
|
/// actions are supplied. |
||||||
|
/// |
||||||
|
/// This is needed for the ios where dialogs are not dismissible by tapping |
||||||
|
/// outside their boundary. |
||||||
|
/// |
||||||
|
/// Defaults to `true`. |
||||||
|
final bool automaticallyShowCancel; |
||||||
|
|
||||||
@override |
@override |
||||||
Widget build(final BuildContext context) => SimpleDialog( |
Widget build(final BuildContext context) { |
||||||
titlePadding: const EdgeInsets.all(10), |
final theme = Theme.of(context); |
||||||
contentPadding: const EdgeInsets.all(10), |
final dialogTheme = NeonDialogTheme.of(context); |
||||||
|
|
||||||
|
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, |
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/l10n/localizations.dart'; |
||||||
export 'package:neon/src/utils/app_route.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/exceptions.dart'; |
||||||
export 'package:neon/src/utils/hex_color.dart'; |
export 'package:neon/src/utils/hex_color.dart'; |
||||||
export 'package:neon/src/utils/provider.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/request_manager.dart' hide Cache; |
||||||
export 'package:neon/src/utils/validators.dart'; |
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", |
"@@locale": "en", |
||||||
"actionClose": "Close", |
|
||||||
"notificationsDismissAll": "Dismiss all notifications", |
"notificationsDismissAll": "Dismiss all notifications", |
||||||
"notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet" |
"notificationAppNotImplementedYet": "Sorry, this Nextcloud app has not been implemented yet" |
||||||
} |
} |
||||||
|
Loading…
Reference in new issue