From b67832a7b3cedb445a91b9769bcd9b8a91d3cd56 Mon Sep 17 00:00:00 2001 From: chinboy Date: Thu, 7 May 2026 08:45:28 +0800 Subject: [PATCH] feat(Statistic): introduce Statistic and TimerStatistic components with formatting, theming, and responsive layout support --- .../LanguageResourceConst.g.cs | 1 + .../ShowCases/ShowCaseRegister.cs | 1 + .../DataDisplay/StatisticViewModel.cs | 50 +++++ .../Views/DataDisplay/StatisticShowCase.axaml | 81 ++++++++ .../DataDisplay/StatisticShowCase.axaml.cs | 22 +++ .../Localization/CaseNavigationLang/en_US.cs | 1 + .../Localization/CaseNavigationLang/zh_CN.cs | 1 + .../ViewModels/CaseNavigationViewModel.cs | 1 + .../Workspace/Views/CaseNavigation.axaml | 2 + .../DesktopControlThemesProvider.axaml | 2 +- .../ControlTokenTypePool.g.cs | 1 + .../TokenResourceConst.g.cs | 17 ++ .../Statistic/AbstractStatistic.cs | 144 ++++++++++++++ .../Statistic/Statistic.cs | 85 +++++++++ .../Statistic/StatisticCountUp.cs | 114 +++++++++++ .../Statistic/StatisticToken.cs | 38 ++++ .../Statistic/StatisticUtils.cs | 50 +++++ .../Themes/AbstractStatisticTheme.axaml | 59 ++++++ .../Themes/AbstractStatisticTheme.cs | 7 + .../Themes/StatisticCountUpTheme.axaml | 24 +++ .../Statistic/Themes/StatisticTheme.axaml | 26 +++ .../Statistic/Themes/StatisticThemes.axaml | 11 ++ .../Themes/TimerStatisticTheme.axaml | 45 +++++ .../Statistic/TimerStatistic.cs | 177 ++++++++++++++++++ 24 files changed, 959 insertions(+), 1 deletion(-) create mode 100644 controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/StatisticViewModel.cs create mode 100644 controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml create mode 100644 controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/AbstractStatistic.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Statistic.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/StatisticCountUp.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/StatisticToken.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/StatisticUtils.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.cs create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticCountUpTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticThemes.axaml create mode 100644 src/AtomUI.Desktop.Controls/Statistic/Themes/TimerStatisticTheme.axaml create mode 100644 src/AtomUI.Desktop.Controls/Statistic/TimerStatistic.cs diff --git a/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs b/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs index 05c3bea09..90b1527cb 100644 --- a/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs +++ b/controlgallery/AtomUIGallery/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.LanguageGenerator/LanguageResourceConst.g.cs @@ -20,6 +20,7 @@ namespace AtomUIGallery.Localization DataDisplay_List, DataDisplay_QRCode, DataDisplay_Segmented, + DataDisplay_Statistic, DataDisplay_Tag, DataDisplay_Timeline, DataDisplay_Tour, diff --git a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs index eb87667e1..5b702efe4 100644 --- a/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs +++ b/controlgallery/AtomUIGallery/ShowCases/ShowCaseRegister.cs @@ -46,6 +46,7 @@ public sealed class ShowCaseViewModule : IViewModule locator.Map(() => new GroupBoxShowCase()); locator.Map(() => new ResultShowCase()); locator.Map(() => new BadgeShowCase()); + locator.Map(() => new StatisticShowCase()); locator.Map(() => new TimelineShowCase()); locator.Map(() => new QRCodeShowCase()); locator.Map(() => new LineEditShowCase()); diff --git a/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/StatisticViewModel.cs b/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/StatisticViewModel.cs new file mode 100644 index 000000000..d0152ad0a --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/ViewModels/DataDisplay/StatisticViewModel.cs @@ -0,0 +1,50 @@ +using AtomUI.Controls; +using ReactiveUI; + +namespace AtomUIGallery.ShowCases.ViewModels; + +public class StatisticViewModel : ReactiveObject, IRoutableViewModel +{ + public static EntityKey ID = "Statistic"; + + public IScreen HostScreen { get; } + + public string? UrlPathSegment => ID.ToString(); + + private double _values = 112893; + + public double Values + { + get => _values; + set => this.RaiseAndSetIfChanged(ref _values, value); + } + + private DateTime _deadline; + + public DateTime Deadline + { + get => _deadline; + set => this.RaiseAndSetIfChanged(ref _deadline, value); + } + + private DateTime _tenSecondsLater; + + public DateTime TenSecondsLater + { + get => _tenSecondsLater; + set => this.RaiseAndSetIfChanged(ref _tenSecondsLater, value); + } + + private DateTime _before; + + public DateTime Before + { + get => _before; + set => this.RaiseAndSetIfChanged(ref _before, value); + } + + public StatisticViewModel(IScreen screen) + { + HostScreen = screen; + } +} diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml new file mode 100644 index 000000000..598567aab --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml @@ -0,0 +1,81 @@ + + + + + + + + Recharge + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml.cs b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml.cs new file mode 100644 index 000000000..a74a8e11b --- /dev/null +++ b/controlgallery/AtomUIGallery/ShowCases/Views/DataDisplay/StatisticShowCase.axaml.cs @@ -0,0 +1,22 @@ +using AtomUIGallery.ShowCases.ViewModels; +using ReactiveUI; +using ReactiveUI.Avalonia; + +namespace AtomUIGallery.ShowCases.Views; + +public partial class StatisticShowCase : ReactiveUserControl +{ + public StatisticShowCase() + { + this.WhenActivated(disposables => + { + if (DataContext is StatisticViewModel viewModel) + { + viewModel.Deadline = DateTime.Now.Add(TimeSpan.FromSeconds(60 * 60 * 24 * 2 + 30)); + viewModel.Before = DateTime.Now.Subtract(TimeSpan.FromSeconds(60 * 60 * 24 * 2 + 30)); + viewModel.TenSecondsLater = DateTime.Now.AddSeconds(10); + } + }); + InitializeComponent(); + } +} diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs index 1d0075609..e54eb9b7c 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/en_US.cs @@ -54,6 +54,7 @@ internal class en_US : LanguageProvider public const string DataDisplay_Segmented = "Segmented"; public const string DataDisplay_GroupBox = "GroupBox"; public const string DataDisplay_Badge = "Badge"; + public const string DataDisplay_Statistic = "Statistic"; public const string DataDisplay_Timeline = "Timeline"; public const string DataDisplay_QRCode = "QRCode"; public const string DataDisplay_InfoFlyout = "InfoFlyout"; diff --git a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs index a00fb8830..fcb030ae0 100644 --- a/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs +++ b/controlgallery/AtomUIGallery/Workspace/Localization/CaseNavigationLang/zh_CN.cs @@ -54,6 +54,7 @@ internal class zh_CN : LanguageProvider public const string DataDisplay_Segmented = "Segmented 分段控制器"; public const string DataDisplay_GroupBox = "GroupBox 分组框"; public const string DataDisplay_Badge = "Badge 徽标"; + public const string DataDisplay_Statistic = "Statistic 统计数值"; public const string DataDisplay_Timeline = "Timeline 时间轴"; public const string DataDisplay_QRCode = "QRCode 二维码"; public const string DataDisplay_InfoFlyout = "InfoFlyout 信息浮层"; diff --git a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs index 05f8de579..b9887a228 100644 --- a/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs +++ b/controlgallery/AtomUIGallery/Workspace/ViewModels/CaseNavigationViewModel.cs @@ -91,6 +91,7 @@ public class CaseNavigationViewModel : ReactiveObject, IActivatableViewModel _showCaseViewModelFactories.Add(GroupBoxViewModel.ID, () => new GroupBoxViewModel(HostScreen)); _showCaseViewModelFactories.Add(ResultViewModel.ID, () => new ResultViewModel(HostScreen)); _showCaseViewModelFactories.Add(BadgeViewModel.ID, () => new BadgeViewModel(HostScreen)); + _showCaseViewModelFactories.Add(StatisticViewModel.ID, () => new StatisticViewModel(HostScreen)); _showCaseViewModelFactories.Add(TimelineViewModel.ID, () => new TimelineViewModel(HostScreen)); _showCaseViewModelFactories.Add(QRCodeViewModel.ID, () => new QRCodeViewModel(HostScreen)); _showCaseViewModelFactories.Add(LineEditViewModel.ID, () => new LineEditViewModel(HostScreen)); diff --git a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml index 9f59fb0c0..9044c5ad6 100644 --- a/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml +++ b/controlgallery/AtomUIGallery/Workspace/Views/CaseNavigation.axaml @@ -111,6 +111,8 @@ ItemKey="{x:Static viewmodels:GroupBoxViewModel.ID}" /> + - + diff --git a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs index a86717d68..54667bb16 100644 --- a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs +++ b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/ControlTokenTypePool.g.cs @@ -63,6 +63,7 @@ namespace AtomUI.Theme tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SpinToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SplitterToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.SplitViewToken)); + tokenTypes.Add(typeof(AtomUI.Desktop.Controls.StatisticToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.StepsToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.TabControlToken)); tokenTypes.Add(typeof(AtomUI.Desktop.Controls.TagToken)); diff --git a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs index cb3c73ad6..6552b60ef 100644 --- a/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs +++ b/src/AtomUI.Desktop.Controls/GeneratedFiles/AtomUI.Generator/AtomUI.Generator.TokenResourceKeyGenerator/TokenResourceConst.g.cs @@ -922,6 +922,12 @@ namespace AtomUI.Desktop.Controls.DesignTokens PaneOpenMotionDuration } + public enum StatisticTokenKind + { + ContentFontSize, + TitleFontSize + } + public enum StepsTokenKind { CustomIconFontSize, @@ -1773,6 +1779,17 @@ namespace AtomUI.Desktop.Controls.DesignTokens } } + public class StatisticTokenResourceExtension : TokenResourceExtension + { + public StatisticTokenResourceExtension() + { + } + + public StatisticTokenResourceExtension(StatisticTokenKind kind) : base(kind) + { + } + } + public class StepsTokenResourceExtension : TokenResourceExtension { public StepsTokenResourceExtension() diff --git a/src/AtomUI.Desktop.Controls/Statistic/AbstractStatistic.cs b/src/AtomUI.Desktop.Controls/Statistic/AbstractStatistic.cs new file mode 100644 index 000000000..61aac6c9d --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/AbstractStatistic.cs @@ -0,0 +1,144 @@ +using AtomUI.Controls; +using AtomUI.Reflection; +using AtomUI.Theme; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Templates; +using Avalonia.LogicalTree; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AtomUI.Desktop.Controls; + +public abstract class AbstractStatistic : HeaderedContentControl +{ + #region 公共属性定义 + + public static readonly StyledProperty DecimalSeparatorProperty = + AvaloniaProperty.Register(nameof(DecimalSeparator), "."); + + public static readonly StyledProperty GroupSeparatorProperty = + AvaloniaProperty.Register(nameof(GroupSeparator), ","); + + public static readonly StyledProperty PrecisionProperty = + AvaloniaProperty.Register(nameof(Precision), 0); + + public static readonly StyledProperty IsLoadingProperty = + AvaloniaProperty.Register(nameof(IsLoading)); + + public static readonly StyledProperty ValuePrefixAddOnProperty = + AvaloniaProperty.Register(nameof(ValuePrefixAddOn)); + + public static readonly StyledProperty ValuePrefixAddOnTemplateProperty = + AvaloniaProperty.Register(nameof(ValuePrefixAddOnTemplate)); + + public static readonly StyledProperty ValueSuffixAddOnProperty = + AvaloniaProperty.Register(nameof(ValueSuffixAddOn)); + + public static readonly StyledProperty ValueSuffixAddOnTemplateProperty = + AvaloniaProperty.Register(nameof(ValueSuffixAddOnTemplate)); + + public static readonly StyledProperty ContentForegroundProperty = + AvaloniaProperty.Register(nameof(ContentForeground)); + + public static readonly StyledProperty ContentFontSizeProperty = + AvaloniaProperty.Register(nameof(ContentFontSize)); + + public string DecimalSeparator + { + get => GetValue(DecimalSeparatorProperty); + set => SetValue(DecimalSeparatorProperty, value); + } + + public string GroupSeparator + { + get => GetValue(GroupSeparatorProperty); + set => SetValue(GroupSeparatorProperty, value); + } + + public int Precision + { + get => GetValue(PrecisionProperty); + set => SetValue(PrecisionProperty, value); + } + + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + [DependsOn(nameof(ValuePrefixAddOnTemplate))] + public object? ValuePrefixAddOn + { + get => GetValue(ValuePrefixAddOnProperty); + set => SetValue(ValuePrefixAddOnProperty, value); + } + + public IDataTemplate? ValuePrefixAddOnTemplate + { + get => GetValue(ValuePrefixAddOnTemplateProperty); + set => SetValue(ValuePrefixAddOnTemplateProperty, value); + } + + [DependsOn(nameof(ValueSuffixAddOnTemplate))] + public object? ValueSuffixAddOn + { + get => GetValue(ValueSuffixAddOnProperty); + set => SetValue(ValueSuffixAddOnProperty, value); + } + + public IDataTemplate? ValueSuffixAddOnTemplate + { + get => GetValue(ValueSuffixAddOnTemplateProperty); + set => SetValue(ValueSuffixAddOnTemplateProperty, value); + } + + public IBrush? ContentForeground + { + get => GetValue(ContentForegroundProperty); + set => SetValue(ContentForegroundProperty, value); + } + + public double ContentFontSize + { + get => GetValue(ContentFontSizeProperty); + set => SetValue(ContentFontSizeProperty, value); + } + + #endregion + + static AbstractStatistic() + { + HeaderProperty.Changed.AddClassHandler((x, e) => x.HandleChildChanged(e)); + ValuePrefixAddOnProperty.Changed.AddClassHandler((x, e) => x.HandleChildChanged(e)); + ValueSuffixAddOnProperty.Changed.AddClassHandler((x, e) => x.HandleChildChanged(e)); + AffectsMeasure(DecimalSeparatorProperty, GroupSeparatorProperty, PrecisionProperty); + } + + public AbstractStatistic() + { + this.RegisterTokenResourceScope(StatisticToken.ScopeProvider); + } + + private void HandleChildChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.OldValue is ILogical oldChild) + { + LogicalChildren.Remove(oldChild); + if (oldChild is Icon icon) + { + icon.SetTemplatedParent(null); + } + } + + if (e.NewValue is ILogical newChild) + { + LogicalChildren.Add(newChild); + if (newChild is Icon icon) + { + icon.SetTemplatedParent(this); + } + } + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Statistic.cs b/src/AtomUI.Desktop.Controls/Statistic/Statistic.cs new file mode 100644 index 000000000..a774452a0 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Statistic.cs @@ -0,0 +1,85 @@ +using Avalonia; + +namespace AtomUI.Desktop.Controls; + +public class Statistic : AbstractStatistic +{ + #region 公共属性定义 + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public object? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public Func? Formatter { get; set; } + + #endregion + + #region 内部属性定义 + + internal static readonly DirectProperty EffectiveValueProperty = + AvaloniaProperty.RegisterDirect(nameof(EffectiveValue), + o => o.EffectiveValue, + (o, v) => o.EffectiveValue = v); + + private string? _effectiveValue; + + internal string? EffectiveValue + { + get => _effectiveValue; + set => SetAndRaise(EffectiveValueProperty, ref _effectiveValue, value); + } + + #endregion + + static Statistic() + { + ContentProperty.Changed.AddClassHandler((x, e) => x.HandleContentChanged(e)); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == ValueProperty || + change.Property == DecimalSeparatorProperty || + change.Property == GroupSeparatorProperty || + change.Property == PrecisionProperty) + { + GenerateEffectiveValue(); + } + } + + protected override void OnInitialized() + { + base.OnInitialized(); + if (Content == null) + { + SetCurrentValue(ContentProperty, EffectiveValue); + } + } + + private void GenerateEffectiveValue() + { + if (Formatter != null) + { + SetCurrentValue(EffectiveValueProperty, Formatter(this, Value)); + } + else if (Value != null) + { + var effectiveValue = StatisticUtils.FormatNumber(Value, GroupSeparator, DecimalSeparator, Precision); + SetCurrentValue(EffectiveValueProperty, effectiveValue); + } + } + + private void HandleContentChanged(AvaloniaPropertyChangedEventArgs e) + { + if (e.NewValue is StatisticCountUp countUp) + { + countUp.DataContext = this; + } + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/StatisticCountUp.cs b/src/AtomUI.Desktop.Controls/Statistic/StatisticCountUp.cs new file mode 100644 index 000000000..b4ba2943f --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/StatisticCountUp.cs @@ -0,0 +1,114 @@ +using AtomUI.Theme; +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Interactivity; +using Avalonia.VisualTree; + +namespace AtomUI.Desktop.Controls; + +public class StatisticCountUp : TemplatedControl +{ + #region 公共属性定义 + + public static readonly StyledProperty EndValueProperty = + AvaloniaProperty.Register(nameof(EndValue)); + + public static readonly StyledProperty DecimalSeparatorProperty = + AvaloniaProperty.Register(nameof(DecimalSeparator), "."); + + public static readonly StyledProperty GroupSeparatorProperty = + AvaloniaProperty.Register(nameof(GroupSeparator), ","); + + public static readonly StyledProperty PrecisionProperty = + AvaloniaProperty.Register(nameof(Precision), 0); + + public double EndValue + { + get => GetValue(EndValueProperty); + set => SetValue(EndValueProperty, value); + } + + public string DecimalSeparator + { + get => GetValue(DecimalSeparatorProperty); + set => SetValue(DecimalSeparatorProperty, value); + } + + public string GroupSeparator + { + get => GetValue(GroupSeparatorProperty); + set => SetValue(GroupSeparatorProperty, value); + } + + public int Precision + { + get => GetValue(PrecisionProperty); + set => SetValue(PrecisionProperty, value); + } + + #endregion + + #region 内部属性定义 + + public static readonly StyledProperty AnimatingValueProperty = + AvaloniaProperty.Register(nameof(AnimatingValue), 0); + + internal static readonly DirectProperty FormattedValueProperty = + AvaloniaProperty.RegisterDirect(nameof(FormattedValue), + o => o.FormattedValue, + (o, v) => o.FormattedValue = v); + + public double AnimatingValue + { + get => GetValue(AnimatingValueProperty); + set => SetValue(AnimatingValueProperty, value); + } + + private string? _formattedValue; + + internal string? FormattedValue + { + get => _formattedValue; + set => SetAndRaise(FormattedValueProperty, ref _formattedValue, value); + } + + #endregion + + static StatisticCountUp() + { + AffectsMeasure(FormattedValueProperty); + } + + public StatisticCountUp() + { + this.RegisterTokenResourceScope(StatisticToken.ScopeProvider); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + + if (this.IsAttachedToVisualTree()) + { + if (change.Property == EndValueProperty) + { + SetCurrentValue(AnimatingValueProperty, EndValue); + } + } + + if (change.Property == AnimatingValueProperty || + change.Property == GroupSeparatorProperty || + change.Property == PrecisionProperty || + change.Property == DecimalSeparatorProperty) + { + var effectiveValue = StatisticUtils.FormatNumber(AnimatingValue, GroupSeparator, DecimalSeparator, Precision); + SetCurrentValue(FormattedValueProperty, effectiveValue); + } + } + + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + AnimatingValue = EndValue; + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/StatisticToken.cs b/src/AtomUI.Desktop.Controls/Statistic/StatisticToken.cs new file mode 100644 index 000000000..248ed60b1 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/StatisticToken.cs @@ -0,0 +1,38 @@ +using AtomUI.Desktop.Controls.DesignTokens; +using AtomUI.Theme; +using AtomUI.Theme.TokenSystem; + +namespace AtomUI.Desktop.Controls; + +[ControlDesignToken] +internal class StatisticToken : AbstractControlDesignToken +{ + public const string ID = "Statistic"; + public static readonly ControlTokenResourceScopeProvider ScopeProvider = new(ID); + + /// + /// 标题字体大小 + /// Title font size + /// + public double TitleFontSize { get; set; } + + /// + /// 内容字体大小 + /// Content font size + /// + public double ContentFontSize { get; set; } + + public StatisticToken() + : base(ID) + { + } + + public override void CalculateTokenValues(bool isDarkMode) + { + base.CalculateTokenValues(isDarkMode); + TitleFontSize = SharedToken.FontSize; + ContentFontSize = SharedToken.FontSizeHeading3; + } + + protected override Type GetTokenKindType() => typeof(StatisticTokenKind); +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/StatisticUtils.cs b/src/AtomUI.Desktop.Controls/Statistic/StatisticUtils.cs new file mode 100644 index 000000000..6314cc874 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/StatisticUtils.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace AtomUI.Desktop.Controls; + +internal static class StatisticUtils +{ + public static string? FormatNumber(object value, string groupSeparator, string decimalSeparator, int precision) + { + var strValue = value.ToString(); + var customFormat = new CultureInfo("en-US"); + customFormat.NumberFormat.NumberGroupSeparator = groupSeparator; + customFormat.NumberFormat.NumberDecimalSeparator = decimalSeparator; + customFormat.NumberFormat.NumberDecimalDigits = precision; + string? effectiveValue = null; + if (int.TryParse(strValue, out var intValue)) + { + effectiveValue = intValue.ToString("N", customFormat); + } + else if (uint.TryParse(strValue, out var uintValue)) + { + effectiveValue = uintValue.ToString("N", customFormat); + } + else if (long.TryParse(strValue, out var longValue)) + { + effectiveValue = longValue.ToString("N", customFormat); + } + else if (long.TryParse(strValue, out var ulongValue)) + { + effectiveValue = ulongValue.ToString("N", customFormat); + } + else if (float.TryParse(strValue, out var floatValue)) + { + effectiveValue = floatValue.ToString("N", customFormat); + } + else if (double.TryParse(strValue, out var doubleValue)) + { + effectiveValue = doubleValue.ToString("N", customFormat); + } + else if (decimal.TryParse(strValue, out var decimalValue)) + { + effectiveValue = decimalValue.ToString("N", customFormat); + } + else + { + effectiveValue = strValue; + } + + return effectiveValue; + } +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.axaml b/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.axaml new file mode 100644 index 000000000..54845f3e1 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.axaml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.cs b/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.cs new file mode 100644 index 000000000..94ca5693a --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/AbstractStatisticTheme.cs @@ -0,0 +1,7 @@ +using Avalonia.Styling; + +namespace AtomUI.Desktop.Controls.Themes; + +internal class AbstractStatisticTheme : ControlTheme +{ +} \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticCountUpTheme.axaml b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticCountUpTheme.axaml new file mode 100644 index 000000000..4db6886d3 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticCountUpTheme.axaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticTheme.axaml b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticTheme.axaml new file mode 100644 index 000000000..cf94f3d97 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticTheme.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticThemes.axaml b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticThemes.axaml new file mode 100644 index 000000000..5eab84ad3 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/StatisticThemes.axaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/AtomUI.Desktop.Controls/Statistic/Themes/TimerStatisticTheme.axaml b/src/AtomUI.Desktop.Controls/Statistic/Themes/TimerStatisticTheme.axaml new file mode 100644 index 000000000..a57cc50d7 --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/Themes/TimerStatisticTheme.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/AtomUI.Desktop.Controls/Statistic/TimerStatistic.cs b/src/AtomUI.Desktop.Controls/Statistic/TimerStatistic.cs new file mode 100644 index 000000000..1433c7d9f --- /dev/null +++ b/src/AtomUI.Desktop.Controls/Statistic/TimerStatistic.cs @@ -0,0 +1,177 @@ +using Avalonia; +using Avalonia.Threading; + +namespace AtomUI.Desktop.Controls; + +public class TimerStatistic : AbstractStatistic +{ + #region 公共属性定义 + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty FormatProperty = + AvaloniaProperty.Register(nameof(Format)); + + public static readonly StyledProperty RefreshDurationProperty = + AvaloniaProperty.Register(nameof(RefreshDuration), TimeSpan.FromMilliseconds(10)); + + public DateTime Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Format + { + get => GetValue(FormatProperty); + set => SetValue(FormatProperty, value); + } + + public TimeSpan RefreshDuration + { + get => GetValue(RefreshDurationProperty); + set => SetValue(RefreshDurationProperty, value); + } + + + public Func? Formatter; + #endregion + + #region 公共事件定义 + + public event EventHandler? CountdownFinished; + + #endregion + + #region 内部属性定义 + + internal static readonly DirectProperty RemainingTimeProperty = + AvaloniaProperty.RegisterDirect(nameof(RemainingTime), + o => o.RemainingTime, + (o, v) => o.RemainingTime = v); + + internal static readonly DirectProperty RemainingTimeTextProperty = + AvaloniaProperty.RegisterDirect(nameof(RemainingTimeText), + o => o.RemainingTimeText, + (o, v) => o.RemainingTimeText = v); + + private TimeSpan _remainingTime; + + internal TimeSpan RemainingTime + { + get => _remainingTime; + set => SetAndRaise(RemainingTimeProperty, ref _remainingTime, value); + } + + private string? _remainingTimeText; + + internal string? RemainingTimeText + { + get => _remainingTimeText; + set => SetAndRaise(RemainingTimeTextProperty, ref _remainingTimeText, value); + } + + #endregion + + private DispatcherTimer? _timer; + + static TimerStatistic() + { + AffectsMeasure(ValueProperty, FormatProperty, RemainingTimeTextProperty); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _timer?.Start(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (_timer != null) + { + _timer.Stop(); + _timer.Tick -= HandleTickElapsed; + _timer = null; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == RemainingTimeProperty || + change.Property == FormatProperty) + { + GenerateRemainingTimeText(); + } + + if (change.Property == ValueProperty || + change.Property == RefreshDurationProperty) + { + BuildTimer(true); + } + } + + private void GenerateRemainingTimeText() + { + string? formattedText; + if (Formatter != null) + { + formattedText = Formatter(RemainingTime); + } + else + { + if (Format != null) + { + formattedText = RemainingTime.ToString(Format); + } + else if (RemainingTime.TotalHours >= 1) + { + formattedText = $"{(int)RemainingTime.TotalHours:00}:{RemainingTime.Minutes:00}:{RemainingTime.Seconds:00}"; + } + else + { + formattedText = $"{(int)RemainingTime.TotalMinutes:00}:{RemainingTime.Seconds:00}"; + } + } + SetCurrentValue(RemainingTimeTextProperty, formattedText); + } + + private void BuildTimer(bool start) + { + // 先清理旧定时器 + if (_timer != null) + { + _timer.Stop(); + _timer.Tick -= HandleTickElapsed; + } + + _timer = new DispatcherTimer(); + _timer.Interval = RefreshDuration; + _timer.Tick += HandleTickElapsed; + if (start) + { + _timer.Start(); + } + } + + private void HandleTickElapsed(object? sender, EventArgs args) + { + if (Value > DateTime.Now) + { + RemainingTime = Value - DateTime.Now; + } + else + { + RemainingTime = DateTime.Now - Value; + } + if (RemainingTime <= TimeSpan.Zero) + { + RemainingTime = TimeSpan.Zero; + _timer?.Stop(); + CountdownFinished?.Invoke(this, EventArgs.Empty); + } + } +} \ No newline at end of file