mirror of
https://gitee.com/chinware/atomui.git
synced 2024-12-02 03:47:52 +08:00
完成 Message 控件
This commit is contained in:
parent
54b5d5e289
commit
12e06ef532
@ -8,6 +8,54 @@
|
||||
xmlns:showcase="clr-namespace:AtomUI.Demo.Desktop.ShowCase"
|
||||
mc:Ignorable="d">
|
||||
<showcase:ShowCasePanel>
|
||||
<showcase:ShowCaseItem
|
||||
Title="Basic"
|
||||
Description="The simplest usage for Message.">
|
||||
<atom:Button ButtonType="Primary"
|
||||
Click="ShowSimpleMessage">
|
||||
Display normal message
|
||||
</atom:Button>
|
||||
</showcase:ShowCaseItem>
|
||||
|
||||
<showcase:ShowCaseItem
|
||||
Title="Other types of message"
|
||||
Description="Messages of success, error and warning types.">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowSuccessMessage">
|
||||
Success
|
||||
</atom:Button>
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowInfoMessage">
|
||||
Info
|
||||
</atom:Button>
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowWarningMessage">
|
||||
Warning
|
||||
</atom:Button>
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowErrorMessage">
|
||||
Error
|
||||
</atom:Button>
|
||||
</StackPanel>
|
||||
</showcase:ShowCaseItem>
|
||||
|
||||
<showcase:ShowCaseItem
|
||||
Title="Message with loading indicator"
|
||||
Description="Display a global loading indicator, which is dismissed by itself asynchronously.">
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowLoadingMessage">
|
||||
Display a loading indicator
|
||||
</atom:Button>
|
||||
</showcase:ShowCaseItem>
|
||||
|
||||
<showcase:ShowCaseItem
|
||||
Title="Callback"
|
||||
Description="The above example will display a new message when the old message is about to close.">
|
||||
<atom:Button ButtonType="Default"
|
||||
Click="ShowSequentialMessage">
|
||||
Display a loading indicator
|
||||
</atom:Button>
|
||||
</showcase:ShowCaseItem>
|
||||
</showcase:ShowCasePanel>
|
||||
</UserControl>
|
@ -1,11 +1,98 @@
|
||||
using Avalonia.Controls;
|
||||
using AtomUI.Controls.Message;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace AtomUI.Demo.Desktop.ShowCase;
|
||||
|
||||
public partial class MessageShowCase : UserControl
|
||||
{
|
||||
private WindowMessageManager? _messageManager;
|
||||
|
||||
public MessageShowCase()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
base.OnAttachedToVisualTree(e);
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
_messageManager = new WindowMessageManager(topLevel)
|
||||
{
|
||||
MaxItems = 10
|
||||
};
|
||||
}
|
||||
|
||||
private void ShowSimpleMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
content: "Hello, AtomUI/Avalonia!"
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowInfoMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Information,
|
||||
content: "This is a information message."
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowSuccessMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Success,
|
||||
content: "This is a success message."
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowWarningMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Warning,
|
||||
content: "This is a warning message."
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowErrorMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Error,
|
||||
content: "This is a error message."
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowLoadingMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Loading,
|
||||
content: "Action in progress..."
|
||||
));
|
||||
}
|
||||
|
||||
private void ShowSequentialMessage(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Loading,
|
||||
content: "Action in progress...",
|
||||
expiration: TimeSpan.FromSeconds(2.5),
|
||||
onClose: () =>
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Success,
|
||||
expiration: TimeSpan.FromSeconds(2.5),
|
||||
content: "Loading finished",
|
||||
onClose: () =>
|
||||
{
|
||||
_messageManager?.Show(new Message(
|
||||
type: MessageType.Information,
|
||||
expiration: TimeSpan.FromSeconds(2.5),
|
||||
content: "Loading finished"
|
||||
));
|
||||
}
|
||||
));
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
@ -43,6 +43,8 @@
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.MenuSeparatorTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.MenuTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.TopLevelMenuItemTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.Message.MessageCardTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.Message.WindowMessageManagerTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.NotificationCardTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.WindowNotificationManagerTheme());
|
||||
ThemeManager.Current.RegisterControlTheme(new AtomUI.Controls.NumericUpDownTheme());
|
||||
|
@ -22,6 +22,7 @@
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.LoadingIndicatorToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.MarqueeLabelToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.MenuToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.Message.MessageToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.NotificationToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.NumericUpDownToken));
|
||||
ThemeManager.Current.RegisterControlTokenType(typeof(AtomUI.Controls.OptionButtonToken));
|
||||
|
@ -305,9 +305,18 @@ namespace AtomUI.Theme.Styling
|
||||
public static readonly TokenResourceKey SeparatorItemHeight = new TokenResourceKey("Menu.SeparatorItemHeight", "AtomUI.Token");
|
||||
}
|
||||
|
||||
public static class MessageTokenResourceKey
|
||||
{
|
||||
public static readonly TokenResourceKey ContentBg = new TokenResourceKey("Message.ContentBg", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey ContentPadding = new TokenResourceKey("Message.ContentPadding", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey CardHeight = new TokenResourceKey("Message.CardHeight", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey MessageIconSize = new TokenResourceKey("Message.MessageIconSize", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey MessageIconMargin = new TokenResourceKey("Message.MessageIconMargin", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey MessageTopMargin = new TokenResourceKey("Message.MessageTopMargin", "AtomUI.Token");
|
||||
}
|
||||
|
||||
public static class NotificationTokenResourceKey
|
||||
{
|
||||
public static readonly TokenResourceKey AnimationMaxHeight = new TokenResourceKey("Notification.AnimationMaxHeight", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey NotificationBg = new TokenResourceKey("Notification.NotificationBg", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey NotificationPadding = new TokenResourceKey("Notification.NotificationPadding", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey NotificationIconSize = new TokenResourceKey("Notification.NotificationIconSize", "AtomUI.Token");
|
||||
|
30
src/AtomUI.Controls/Message/IMessage.cs
Normal file
30
src/AtomUI.Controls/Message/IMessage.cs
Normal file
@ -0,0 +1,30 @@
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
public interface IMessage
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the message.
|
||||
/// </summary>
|
||||
string Content { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 自定义图标
|
||||
/// </summary>
|
||||
PathIcon? Icon { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="MessageType"/> of the notification.
|
||||
/// </summary>
|
||||
MessageType Type { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the expiration time of the notification after which it will automatically close.
|
||||
/// If the value is <see cref="TimeSpan.Zero"/> then the notification will remain open until the user closes it.
|
||||
/// </summary>
|
||||
TimeSpan Expiration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets an Action to be run when the message is closed.
|
||||
/// </summary>
|
||||
Action? OnClose { get; }
|
||||
}
|
6
src/AtomUI.Controls/Message/IMessageManager.cs
Normal file
6
src/AtomUI.Controls/Message/IMessageManager.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
public interface IMessageManager
|
||||
{
|
||||
public void Show(IMessage message, string[]? classes = null);
|
||||
}
|
60
src/AtomUI.Controls/Message/Message.cs
Normal file
60
src/AtomUI.Controls/Message/Message.cs
Normal file
@ -0,0 +1,60 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
public class Message : IMessage, INotifyPropertyChanged
|
||||
{
|
||||
private string _content;
|
||||
private PathIcon? _icon;
|
||||
|
||||
public Message(string content,
|
||||
MessageType type = MessageType.Information,
|
||||
PathIcon? icon = null,
|
||||
TimeSpan? expiration = null,
|
||||
Action? onClose = null)
|
||||
{
|
||||
_content = content;
|
||||
_icon = icon;
|
||||
Type = type;
|
||||
Expiration = expiration.HasValue ? expiration.Value : TimeSpan.FromSeconds(5);
|
||||
OnClose = onClose;
|
||||
}
|
||||
|
||||
public string Content
|
||||
{
|
||||
get => _content;
|
||||
set
|
||||
{
|
||||
if (_content != value) {
|
||||
_content = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public PathIcon? Icon
|
||||
{
|
||||
get => _icon;
|
||||
set
|
||||
{
|
||||
if (!object.ReferenceEquals(_icon, value)) {
|
||||
_icon = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public MessageType Type { get; set; }
|
||||
|
||||
public TimeSpan Expiration { get; set; }
|
||||
|
||||
public Action? OnClose { get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
235
src/AtomUI.Controls/Message/MessageCard.cs
Normal file
235
src/AtomUI.Controls/Message/MessageCard.cs
Normal file
@ -0,0 +1,235 @@
|
||||
using AtomUI.Icon;
|
||||
using AtomUI.Theme.Styling;
|
||||
using AtomUI.Utils;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Metadata;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
[PseudoClasses(ErrorPC, InformationPC, SuccessPC, WarningPC, LoadingPC)]
|
||||
public class MessageCard : TemplatedControl
|
||||
{
|
||||
public const string ErrorPC = ":error";
|
||||
public const string InformationPC = ":information";
|
||||
public const string SuccessPC = ":success";
|
||||
public const string WarningPC = ":warning";
|
||||
public const string LoadingPC = ":loading";
|
||||
|
||||
#region 公共属性定义
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="IsClosing"/> property.
|
||||
/// </summary>
|
||||
public static readonly DirectProperty<MessageCard, bool> IsClosingProperty =
|
||||
AvaloniaProperty.RegisterDirect<MessageCard, bool>(nameof(IsClosing), o => o.IsClosing);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="IsClosed"/> property.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> IsClosedProperty =
|
||||
AvaloniaProperty.Register<MessageCard, bool>(nameof(IsClosed));
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="NotificationType" /> property
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<MessageType> MessageTypeProperty =
|
||||
AvaloniaProperty.Register<MessageCard, MessageType>(nameof(NotificationType));
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MessageClosed"/> event.
|
||||
/// </summary>
|
||||
public static readonly RoutedEvent<RoutedEventArgs> MessageClosedEvent =
|
||||
RoutedEvent.Register<MessageCard, RoutedEventArgs>(nameof(MessageClosed), RoutingStrategies.Bubble);
|
||||
|
||||
public static readonly StyledProperty<PathIcon?> IconProperty
|
||||
= AvaloniaProperty.Register<MessageCard, PathIcon?>(nameof(Icon));
|
||||
|
||||
public static readonly StyledProperty<string> MessageProperty =
|
||||
AvaloniaProperty.Register<NotificationCard, string>(nameof(Message));
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the notification is already closing.
|
||||
/// </summary>
|
||||
public bool IsClosing
|
||||
{
|
||||
get => _isClosing;
|
||||
private set => SetAndRaise(IsClosingProperty, ref _isClosing, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the notification is closed.
|
||||
/// </summary>
|
||||
public bool IsClosed
|
||||
{
|
||||
get => GetValue(IsClosedProperty);
|
||||
set => SetValue(IsClosedProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the notification
|
||||
/// </summary>
|
||||
public MessageType MessageType
|
||||
{
|
||||
get => GetValue(MessageTypeProperty);
|
||||
set => SetValue(MessageTypeProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the <see cref="MessageCard"/> has closed.
|
||||
/// </summary>
|
||||
public event EventHandler<RoutedEventArgs>? MessageClosed
|
||||
{
|
||||
add => AddHandler(MessageClosedEvent, value);
|
||||
remove => RemoveHandler(MessageClosedEvent, value);
|
||||
}
|
||||
|
||||
public PathIcon? Icon
|
||||
{
|
||||
get => GetValue(IconProperty);
|
||||
set => SetValue(IconProperty, value);
|
||||
}
|
||||
|
||||
public string Message
|
||||
{
|
||||
get => GetValue(MessageProperty);
|
||||
set => SetValue(MessageProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private bool _isClosing;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MessageCard"/> class.
|
||||
/// </summary>
|
||||
public MessageCard()
|
||||
{
|
||||
UpdateMessageType();
|
||||
ClipToBounds = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the <see cref="MessageCard"/>.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
if (IsClosing) {
|
||||
return;
|
||||
}
|
||||
|
||||
IsClosing = true;
|
||||
}
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
base.OnPropertyChanged(e);
|
||||
|
||||
if (e.Property == MessageTypeProperty) {
|
||||
SetupMessageIcon();
|
||||
UpdateMessageType();
|
||||
}
|
||||
|
||||
if (e.Property == MessageTypeProperty) {
|
||||
UpdateMessageType();
|
||||
}
|
||||
|
||||
if (e.Property == IsClosedProperty) {
|
||||
if (!IsClosing && !IsClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
RaiseEvent(new RoutedEventArgs(MessageClosedEvent));
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
if (Icon is null) {
|
||||
SetupMessageIcon();
|
||||
UpdateMessageType();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMessageType()
|
||||
{
|
||||
switch (MessageType) {
|
||||
case MessageType.Error:
|
||||
PseudoClasses.Add(":error");
|
||||
break;
|
||||
|
||||
case MessageType.Information:
|
||||
PseudoClasses.Add(":information");
|
||||
break;
|
||||
|
||||
case MessageType.Success:
|
||||
PseudoClasses.Add(":success");
|
||||
break;
|
||||
|
||||
case MessageType.Warning:
|
||||
PseudoClasses.Add(":warning");
|
||||
break;
|
||||
|
||||
case MessageType.Loading:
|
||||
PseudoClasses.Add(":loading");
|
||||
break;
|
||||
}
|
||||
if (Icon is not null) {
|
||||
SetupMessageIconColor(Icon);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupMessageIconColor(PathIcon icon)
|
||||
{
|
||||
if (MessageType == MessageType.Error) {
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(icon, PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorError);
|
||||
} else if (MessageType == MessageType.Information ||
|
||||
MessageType == MessageType.Loading) {
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(icon, PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorPrimary);
|
||||
} else if (MessageType == MessageType.Success) {
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(icon, PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorSuccess);
|
||||
} else if (MessageType == MessageType.Warning) {
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(icon, PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorWarning);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupMessageIcon()
|
||||
{
|
||||
PathIcon? icon = null;
|
||||
if (MessageType == MessageType.Information) {
|
||||
icon = new PathIcon()
|
||||
{
|
||||
Kind = "InfoCircleFilled"
|
||||
};
|
||||
} else if (MessageType == MessageType.Success) {
|
||||
icon = new PathIcon()
|
||||
{
|
||||
Kind = "CheckCircleFilled"
|
||||
};
|
||||
} else if (MessageType == MessageType.Error) {
|
||||
icon = new PathIcon()
|
||||
{
|
||||
Kind = "CloseCircleFilled"
|
||||
};
|
||||
} else if (MessageType == MessageType.Warning) {
|
||||
icon = new PathIcon()
|
||||
{
|
||||
Kind = "ExclamationCircleFilled"
|
||||
};
|
||||
} else if (MessageType == MessageType.Loading) {
|
||||
icon = new PathIcon()
|
||||
{
|
||||
Kind = "LoadingOutlined",
|
||||
LoadingAnimation = IconAnimation.Spin
|
||||
};
|
||||
}
|
||||
|
||||
if (icon is not null) {
|
||||
SetupMessageIconColor(icon);
|
||||
}
|
||||
SetCurrentValue(IconProperty, icon);
|
||||
}
|
||||
}
|
212
src/AtomUI.Controls/Message/MessageCardTheme.cs
Normal file
212
src/AtomUI.Controls/Message/MessageCardTheme.cs
Normal file
@ -0,0 +1,212 @@
|
||||
using AtomUI.Controls.Utils;
|
||||
using AtomUI.Theme;
|
||||
using AtomUI.Theme.Styling;
|
||||
using AtomUI.Utils;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Animation.Easings;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
[ControlThemeProvider]
|
||||
internal class MessageCardTheme : BaseControlTheme
|
||||
{
|
||||
public const string FrameDecoratorPart = "PART_FrameDecorator";
|
||||
public const string IconContentPart = "PART_IconContent";
|
||||
public const string HeaderContainerPart = "PART_HeaderContainer";
|
||||
public const string MessagePart = "PART_Message";
|
||||
public const string LayoutTransformControlPart = "PART_LayoutTransformControl";
|
||||
public const string MarginGhostDecoratorPart = "PART_MarginGhostDecorator";
|
||||
|
||||
public const double AnimationMaxOffsetY = 100d;
|
||||
public const int AnimationDuration = 400;
|
||||
|
||||
private Easing _quadraticEaseOut = new QuadraticEaseOut();
|
||||
private Easing _quadraticEaseIn = new QuadraticEaseIn();
|
||||
|
||||
public MessageCardTheme()
|
||||
: base(typeof(MessageCard)) { }
|
||||
|
||||
protected override IControlTemplate BuildControlTemplate()
|
||||
{
|
||||
return new FuncControlTemplate<MessageCard>((card, scope) =>
|
||||
{
|
||||
BuildInstanceStyles(card);
|
||||
var layoutTransformControl = new LayoutTransformControl()
|
||||
{
|
||||
Name = LayoutTransformControlPart,
|
||||
ClipToBounds = false
|
||||
};
|
||||
layoutTransformControl.RegisterInNameScope(scope);
|
||||
|
||||
// 防止关闭的时候抖动,如果直接设置到 MessageCard,layoutTransformControl没有办法平滑处理
|
||||
var marginGhostDecorator = new Border()
|
||||
{
|
||||
Name = MarginGhostDecoratorPart,
|
||||
ClipToBounds = false
|
||||
};
|
||||
|
||||
var frameDecorator = new Border()
|
||||
{
|
||||
Name = FrameDecoratorPart,
|
||||
};
|
||||
|
||||
marginGhostDecorator.Child = frameDecorator;
|
||||
|
||||
var header = BuildContent(scope);
|
||||
frameDecorator.Child = header;
|
||||
|
||||
frameDecorator.RegisterInNameScope(scope);
|
||||
|
||||
layoutTransformControl.Child = marginGhostDecorator;
|
||||
return layoutTransformControl;
|
||||
});
|
||||
}
|
||||
|
||||
private DockPanel BuildContent(INameScope scope)
|
||||
{
|
||||
var headerLayout = new DockPanel()
|
||||
{
|
||||
Name = HeaderContainerPart,
|
||||
LastChildFill = true
|
||||
};
|
||||
var iconContent = new ContentPresenter()
|
||||
{
|
||||
Name = IconContentPart
|
||||
};
|
||||
DockPanel.SetDock(iconContent, Dock.Left);
|
||||
CreateTemplateParentBinding(iconContent, ContentPresenter.IsVisibleProperty, MessageCard.IconProperty,
|
||||
BindingMode.Default,
|
||||
ObjectConverters.IsNotNull);
|
||||
CreateTemplateParentBinding(iconContent, ContentPresenter.ContentProperty, MessageCard.IconProperty);
|
||||
TokenResourceBinder.CreateTokenBinding(iconContent, ContentPresenter.MarginProperty, MessageTokenResourceKey.MessageIconMargin);
|
||||
|
||||
headerLayout.Children.Add(iconContent);
|
||||
|
||||
var message = new SelectableTextBlock()
|
||||
{
|
||||
Name = MessagePart
|
||||
};
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(message, SelectableTextBlock.SelectionBrushProperty,
|
||||
GlobalTokenResourceKey.SelectionBackground);
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(message, SelectableTextBlock.SelectionForegroundBrushProperty,
|
||||
GlobalTokenResourceKey.SelectionForeground);
|
||||
|
||||
CreateTemplateParentBinding(message, SelectableTextBlock.TextProperty, MessageCard.MessageProperty);
|
||||
headerLayout.Children.Add(message);
|
||||
return headerLayout;
|
||||
}
|
||||
|
||||
protected override void BuildStyles()
|
||||
{
|
||||
BuildCommonStyle();
|
||||
BuildContentStyle();
|
||||
BuildContentStyle();
|
||||
BuildAnimationStyle();
|
||||
}
|
||||
|
||||
private void BuildCommonStyle()
|
||||
{
|
||||
var commonStyle = new Style(selector => selector.Nesting());
|
||||
|
||||
commonStyle.Add(MessageCard.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||
|
||||
var marginGhostDecoratorStyle =
|
||||
new Style(selector => selector.Nesting().Template().Name(MarginGhostDecoratorPart));
|
||||
marginGhostDecoratorStyle.Add(Border.MarginProperty, MessageTokenResourceKey.MessageTopMargin);
|
||||
commonStyle.Add(marginGhostDecoratorStyle);
|
||||
|
||||
var frameDecoratorStyle = new Style(selector => selector.Nesting().Template().Name(FrameDecoratorPart));
|
||||
frameDecoratorStyle.Add(Border.PaddingProperty, MessageTokenResourceKey.ContentPadding);
|
||||
frameDecoratorStyle.Add(Border.BoxShadowProperty, GlobalTokenResourceKey.BoxShadows);
|
||||
frameDecoratorStyle.Add(Border.BackgroundProperty, MessageTokenResourceKey.ContentBg);
|
||||
frameDecoratorStyle.Add(Border.CornerRadiusProperty, GlobalTokenResourceKey.BorderRadiusLG);
|
||||
commonStyle.Add(frameDecoratorStyle);
|
||||
|
||||
var closedStyle =
|
||||
new Style(selector => selector.Nesting().PropertyEquals(MessageCard.IsClosedProperty, true));
|
||||
closedStyle.Add(MessageCard.MarginProperty, new Thickness(0));
|
||||
commonStyle.Add(closedStyle);
|
||||
|
||||
Add(commonStyle);
|
||||
}
|
||||
|
||||
private void BuildContentStyle()
|
||||
{
|
||||
var titleStyle = new Style(selector => selector.Nesting().Template().Name(MessagePart));
|
||||
titleStyle.Add(ContentPresenter.LineHeightProperty, GlobalTokenResourceKey.FontHeight);
|
||||
titleStyle.Add(ContentPresenter.FontSizeProperty, GlobalTokenResourceKey.FontSize);
|
||||
titleStyle.Add(ContentPresenter.ForegroundProperty, GlobalTokenResourceKey.ColorText);
|
||||
Add(titleStyle);
|
||||
}
|
||||
|
||||
protected override void BuildInstanceStyles(Control control)
|
||||
{
|
||||
var iconStyle = new Style(selector => selector.Name(IconContentPart).Child().OfType<PathIcon>());
|
||||
iconStyle.Add(PathIcon.WidthProperty, MessageTokenResourceKey.MessageIconSize);
|
||||
iconStyle.Add(PathIcon.HeightProperty, MessageTokenResourceKey.MessageIconSize);
|
||||
control.Styles.Add(iconStyle);
|
||||
}
|
||||
|
||||
private void BuildAnimationStyle()
|
||||
{
|
||||
var commonStyle = new Style(selector => selector.Nesting());
|
||||
|
||||
{
|
||||
var layoutTransformStyle =
|
||||
new Style(selector => selector.Nesting().Template().Name(LayoutTransformControlPart));
|
||||
var moveRightInMotionConfig = MotionFactory.BuildMoveUpInMotion(
|
||||
AnimationMaxOffsetY, TimeSpan.FromMilliseconds(AnimationDuration), _quadraticEaseOut,
|
||||
FillMode.Forward);
|
||||
foreach (var animation in moveRightInMotionConfig.Animations) {
|
||||
layoutTransformStyle.Animations.Add(animation);
|
||||
}
|
||||
|
||||
commonStyle.Add(layoutTransformStyle);
|
||||
}
|
||||
|
||||
var isClosingStyle =
|
||||
new Style(selector => selector.Nesting().PropertyEquals(MessageCard.IsClosingProperty, true));
|
||||
{
|
||||
var layoutTransformStyle =
|
||||
new Style(selector => selector.Nesting().Template().Name(LayoutTransformControlPart));
|
||||
|
||||
var moveRightOutMotionConfig = MotionFactory.BuildMoveUpOutMotion(
|
||||
AnimationMaxOffsetY, TimeSpan.FromMilliseconds(AnimationDuration), _quadraticEaseIn, FillMode.Forward);
|
||||
|
||||
foreach (var animation in moveRightOutMotionConfig.Animations) {
|
||||
layoutTransformStyle.Animations.Add(animation);
|
||||
}
|
||||
|
||||
isClosingStyle.Animations.Add(new Animation()
|
||||
{
|
||||
Duration = TimeSpan.FromMilliseconds(AnimationDuration * 1.2),
|
||||
Easing = _quadraticEaseIn,
|
||||
FillMode = FillMode.Forward,
|
||||
Children =
|
||||
{
|
||||
new KeyFrame()
|
||||
{
|
||||
Cue = new Cue(1.0),
|
||||
Setters =
|
||||
{
|
||||
new Setter(MessageCard.IsClosedProperty, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
isClosingStyle.Add(layoutTransformStyle);
|
||||
}
|
||||
commonStyle.Add(isClosingStyle);
|
||||
|
||||
Add(commonStyle);
|
||||
}
|
||||
}
|
57
src/AtomUI.Controls/Message/MessageToken.cs
Normal file
57
src/AtomUI.Controls/Message/MessageToken.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using AtomUI.Theme.TokenSystem;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
[ControlDesignToken]
|
||||
public class MessageToken : AbstractControlDesignToken
|
||||
{
|
||||
public const string ID = "Message";
|
||||
|
||||
/// <summary>
|
||||
/// 提示框背景色
|
||||
/// </summary>
|
||||
public Color ContentBg { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提示框内边距
|
||||
/// </summary>
|
||||
public Thickness ContentPadding { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提示框高度
|
||||
/// </summary>
|
||||
public double CardHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒框图标尺寸
|
||||
/// </summary>
|
||||
public double MessageIconSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒框图标外边距
|
||||
/// </summary>
|
||||
public Thickness MessageIconMargin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒框上边缘外边距
|
||||
/// </summary>
|
||||
public Thickness MessageTopMargin { get; set; }
|
||||
|
||||
public MessageToken()
|
||||
: base(ID)
|
||||
{
|
||||
}
|
||||
|
||||
internal override void CalculateFromAlias()
|
||||
{
|
||||
base.CalculateFromAlias();
|
||||
ContentBg = _globalToken.ColorToken.ColorNeutralToken.ColorBgElevated;
|
||||
ContentPadding = new Thickness((_globalToken.HeightToken.ControlHeightLG - _globalToken.FontToken.FontSize * _globalToken.FontToken.LineHeight) / 2,
|
||||
_globalToken.PaddingXS);
|
||||
MessageIconMargin = new Thickness(0, 0, _globalToken.MarginXS, 0);
|
||||
MessageTopMargin = new Thickness(_globalToken.Margin, _globalToken.Margin, _globalToken.Margin, 0);
|
||||
MessageIconSize = _globalToken.FontToken.FontSizeSM * _globalToken.FontToken.LineHeightSM;
|
||||
}
|
||||
}
|
10
src/AtomUI.Controls/Message/MessageType.cs
Normal file
10
src/AtomUI.Controls/Message/MessageType.cs
Normal file
@ -0,0 +1,10 @@
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
public enum MessageType
|
||||
{
|
||||
Information,
|
||||
Success,
|
||||
Warning,
|
||||
Error,
|
||||
Loading
|
||||
}
|
149
src/AtomUI.Controls/Message/WindowMessageManager.cs
Normal file
149
src/AtomUI.Controls/Message/WindowMessageManager.cs
Normal file
@ -0,0 +1,149 @@
|
||||
using System.Collections;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Metadata;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
[TemplatePart(WindowNotificationManagerTheme.ItemsPart, typeof(Panel))]
|
||||
public class WindowMessageManager : TemplatedControl, IMessageManager
|
||||
{
|
||||
private IList? _items;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="Position"/> property.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<NotificationPosition> PositionProperty =
|
||||
AvaloniaProperty.Register<WindowNotificationManager, NotificationPosition>(
|
||||
nameof(Position), NotificationPosition.TopRight);
|
||||
|
||||
/// <summary>
|
||||
/// Defines which corner of the screen notifications can be displayed in.
|
||||
/// </summary>
|
||||
/// <seealso cref="NotificationPosition"/>
|
||||
public NotificationPosition Position
|
||||
{
|
||||
get => GetValue(PositionProperty);
|
||||
set => SetValue(PositionProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the <see cref="MaxItems"/> property.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<int> MaxItemsProperty =
|
||||
AvaloniaProperty.Register<WindowNotificationManager, int>(nameof(MaxItems), 5);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the maximum number of notifications visible at once.
|
||||
/// </summary>
|
||||
public int MaxItems
|
||||
{
|
||||
get => GetValue(MaxItemsProperty);
|
||||
set => SetValue(MaxItemsProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WindowNotificationManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="host">The TopLevel that will host the control.</param>
|
||||
public WindowMessageManager(TopLevel? host)
|
||||
{
|
||||
if (host is not null) {
|
||||
InstallFromTopLevel(host);
|
||||
}
|
||||
}
|
||||
|
||||
static WindowMessageManager()
|
||||
{
|
||||
HorizontalAlignmentProperty.OverrideDefaultValue<WindowMessageManager>(HorizontalAlignment.Stretch);
|
||||
VerticalAlignmentProperty.OverrideDefaultValue<WindowMessageManager>(VerticalAlignment.Stretch);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
|
||||
var itemsControl = e.NameScope.Find<Panel>("PART_Items");
|
||||
_items = itemsControl?.Children;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a Notification
|
||||
/// </summary>
|
||||
/// <param name="message">the content of the message</param>
|
||||
/// <param name="classes">style classes to apply</param>
|
||||
public async void Show(IMessage message, string[]? classes = null)
|
||||
{
|
||||
var expiration = message.Expiration;
|
||||
var onClose = message.OnClose;
|
||||
Dispatcher.UIThread.VerifyAccess();
|
||||
|
||||
var messageControl = new MessageCard
|
||||
{
|
||||
Icon = message.Icon,
|
||||
Message = message.Content,
|
||||
MessageType = message.Type
|
||||
};
|
||||
|
||||
// Add style classes if any
|
||||
if (classes != null) {
|
||||
foreach (var @class in classes) {
|
||||
messageControl.Classes.Add(@class);
|
||||
}
|
||||
}
|
||||
|
||||
messageControl.MessageClosed += (sender, args) =>
|
||||
{
|
||||
onClose?.Invoke();
|
||||
|
||||
_items?.Remove(sender);
|
||||
};
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_items?.Add(messageControl);
|
||||
|
||||
if (_items?.OfType<MessageCard>().Count(i => !i.IsClosing) > MaxItems) {
|
||||
_items.OfType<MessageCard>().First(i => !i.IsClosing).Close();
|
||||
}
|
||||
});
|
||||
|
||||
if (expiration == TimeSpan.Zero) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(expiration);
|
||||
messageControl.Close();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installs the <see cref="WindowNotificationManager"/> within the <see cref="AdornerLayer"/>
|
||||
/// </summary>
|
||||
private void InstallFromTopLevel(TopLevel topLevel)
|
||||
{
|
||||
topLevel.TemplateApplied += TopLevelOnTemplateApplied;
|
||||
var adorner = topLevel.FindDescendantOfType<VisualLayerManager>()?.AdornerLayer;
|
||||
if (adorner is not null) {
|
||||
adorner.Children.Add(this);
|
||||
AdornerLayer.SetAdornedElement(this, adorner);
|
||||
}
|
||||
}
|
||||
|
||||
private void TopLevelOnTemplateApplied(object? sender, TemplateAppliedEventArgs e)
|
||||
{
|
||||
if (Parent is AdornerLayer adornerLayer) {
|
||||
adornerLayer.Children.Remove(this);
|
||||
AdornerLayer.SetAdornedElement(this, null);
|
||||
}
|
||||
|
||||
// Reinstall notification manager on template reapplied.
|
||||
var topLevel = (TopLevel)sender!;
|
||||
topLevel.TemplateApplied -= TopLevelOnTemplateApplied;
|
||||
InstallFromTopLevel(topLevel);
|
||||
}
|
||||
}
|
44
src/AtomUI.Controls/Message/WindowMessageManagerTheme.cs
Normal file
44
src/AtomUI.Controls/Message/WindowMessageManagerTheme.cs
Normal file
@ -0,0 +1,44 @@
|
||||
using AtomUI.Theme;
|
||||
using AtomUI.Theme.Styling;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace AtomUI.Controls.Message;
|
||||
|
||||
[ControlThemeProvider]
|
||||
internal class WindowMessageManagerTheme : BaseControlTheme
|
||||
{
|
||||
public const string ItemsPart = "PART_Items";
|
||||
|
||||
public WindowMessageManagerTheme() : base(typeof(WindowMessageManager))
|
||||
{
|
||||
}
|
||||
|
||||
protected override IControlTemplate BuildControlTemplate()
|
||||
{
|
||||
return new FuncControlTemplate<WindowMessageManager>((manager, scope) =>
|
||||
{
|
||||
var mainLayout = new ReversibleStackPanel()
|
||||
{
|
||||
Name = ItemsPart,
|
||||
};
|
||||
mainLayout.RegisterInNameScope(scope);
|
||||
return mainLayout;
|
||||
});
|
||||
}
|
||||
|
||||
protected override void BuildStyles()
|
||||
{
|
||||
var topCenterStyle = new Style(selector => selector.Nesting().Class(WindowNotificationManager.TopCenterPC));
|
||||
{
|
||||
var itemsStyle = new Style(selector => selector.Nesting().Template().Name(ItemsPart));
|
||||
itemsStyle.Add(ReversibleStackPanel.ReverseOrderProperty, true);
|
||||
itemsStyle.Add(ReversibleStackPanel.HorizontalAlignmentProperty, HorizontalAlignment.Center);
|
||||
itemsStyle.Add(ReversibleStackPanel.VerticalAlignmentProperty, VerticalAlignment.Top);
|
||||
topCenterStyle.Add(itemsStyle);
|
||||
}
|
||||
Add(topCenterStyle);
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@ using Avalonia.LogicalTree;
|
||||
namespace AtomUI.Controls;
|
||||
|
||||
[PseudoClasses(ErrorPC, InformationPC, SuccessPC, WarningPC)]
|
||||
public class NotificationCard : TemplatedControl
|
||||
public class NotificationCard : ContentControl
|
||||
{
|
||||
public const string ErrorPC = ":error";
|
||||
public const string InformationPC = ":information";
|
||||
@ -52,12 +52,6 @@ public class NotificationCard : TemplatedControl
|
||||
public static readonly StyledProperty<string> TitleProperty =
|
||||
AvaloniaProperty.Register<NotificationCard, string>(nameof(Title));
|
||||
|
||||
public static readonly StyledProperty<object?> CardContentProperty =
|
||||
AvaloniaProperty.Register<NotificationCard, object?>(nameof(CardContent));
|
||||
|
||||
public static readonly StyledProperty<IDataTemplate?> CardContentTemplateProperty =
|
||||
AvaloniaProperty.Register<NotificationCard, IDataTemplate?>(nameof(CardContentTemplate));
|
||||
|
||||
public static readonly StyledProperty<PathIcon?> IconProperty
|
||||
= AvaloniaProperty.Register<NotificationCard, PathIcon?>(nameof(Icon));
|
||||
|
||||
@ -100,18 +94,6 @@ public class NotificationCard : TemplatedControl
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
public object? CardContent
|
||||
{
|
||||
get => GetValue(CardContentProperty);
|
||||
set => SetValue(CardContentProperty, value);
|
||||
}
|
||||
|
||||
public object? CardContentTemplate
|
||||
{
|
||||
get => GetValue(CardContentTemplateProperty);
|
||||
set => SetValue(CardContentTemplateProperty, value);
|
||||
}
|
||||
|
||||
public PathIcon? Icon
|
||||
{
|
||||
get => GetValue(IconProperty);
|
||||
@ -242,7 +224,7 @@ public class NotificationCard : TemplatedControl
|
||||
RaiseEvent(new RoutedEventArgs(NotificationClosedEvent));
|
||||
}
|
||||
|
||||
if (e.Property == CardContentProperty) {
|
||||
if (e.Property == ContentProperty) {
|
||||
if (e.NewValue is string) {
|
||||
SetupContent();
|
||||
}
|
||||
@ -311,14 +293,14 @@ public class NotificationCard : TemplatedControl
|
||||
|
||||
private void SetupContent()
|
||||
{
|
||||
if (CardContent is string content) {
|
||||
if (Content is string content) {
|
||||
var textBlock = new SelectableTextBlock()
|
||||
{
|
||||
Text = content,
|
||||
};
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(textBlock, SelectableTextBlock.SelectionBrushProperty, GlobalTokenResourceKey.SelectionBackground);
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(textBlock, SelectableTextBlock.SelectionForegroundBrushProperty, GlobalTokenResourceKey.SelectionForeground);
|
||||
CardContent = textBlock;
|
||||
Content = textBlock;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,9 +174,9 @@ internal class NotificationCardTheme : BaseControlTheme
|
||||
};
|
||||
TokenResourceBinder.CreateTokenBinding(contentPresenter, ContentPresenter.MarginProperty, NotificationTokenResourceKey.NotificationContentMargin);
|
||||
CreateTemplateParentBinding(contentPresenter, ContentPresenter.ContentProperty,
|
||||
NotificationCard.CardContentProperty);
|
||||
NotificationCard.ContentProperty);
|
||||
CreateTemplateParentBinding(contentPresenter, ContentPresenter.ContentTemplateProperty,
|
||||
NotificationCard.CardContentTemplateProperty);
|
||||
NotificationCard.ContentTemplateProperty);
|
||||
Grid.SetColumn(contentPresenter, 1);
|
||||
Grid.SetRow(contentPresenter, 1);
|
||||
layout.Children.Add(contentPresenter);
|
||||
|
@ -14,11 +14,6 @@ internal class NotificationToken : AbstractControlDesignToken
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 动画最大高度
|
||||
/// </summary>
|
||||
public double AnimationMaxHeight { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 提醒框背景色
|
||||
/// </summary>
|
||||
|
@ -143,7 +143,7 @@ public class WindowNotificationManager : TemplatedControl, INotificationManager
|
||||
var notificationControl = new NotificationCard(this)
|
||||
{
|
||||
Title = notification.Title,
|
||||
CardContent = notification.Content,
|
||||
Content = notification.Content,
|
||||
Icon = notification.Icon,
|
||||
NotificationType = notification.Type,
|
||||
Expiration = expiration == TimeSpan.Zero ? null : expiration,
|
||||
|
Loading…
Reference in New Issue
Block a user