diff --git a/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml b/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml new file mode 100644 index 0000000..43e4ac8 --- /dev/null +++ b/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml.cs b/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml.cs new file mode 100644 index 0000000..5933b5c --- /dev/null +++ b/demo/Ursa.Demo/Pages/VerificationCodeDemo.axaml.cs @@ -0,0 +1,21 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Ursa.Controls; + +namespace Ursa.Demo.Pages; + +public partial class VerificationCodeDemo : UserControl +{ + public VerificationCodeDemo() + { + InitializeComponent(); + } + + private async void VerificationCode_OnComplete(object? sender, VerificationCodeCompleteEventArgs e) + { + var text = string.Join(string.Empty, e.Code); + await MessageBox.ShowOverlayAsync(text); + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs index 5a0cd77..aa9f90a 100644 --- a/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MainViewViewModel.cs @@ -56,6 +56,7 @@ public class MainViewViewModel : ViewModelBase MenuKeys.MenuKeyTwoTonePathIcon => new TwoTonePathIconDemoViewModel(), MenuKeys.MenuKeyThemeToggler => new ThemeTogglerDemoViewModel(), MenuKeys.MenuKeyToolBar => new ToolBarDemoViewModel(), + MenuKeys.MenuKeyVerificationCode => new VerificationCodeDemoViewModel(), }; } } \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs index a48238e..1fa6c8a 100644 --- a/demo/Ursa.Demo/ViewModels/MenuViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/MenuViewModel.cs @@ -42,7 +42,8 @@ public class MenuViewModel: ViewModelBase new() { MenuHeader = "Theme Toggler", Key = MenuKeys.MenuKeyThemeToggler, Status = "New" }, new() { MenuHeader = "Timeline", Key = MenuKeys.MenuKeyTimeline, Status = "WIP" }, new() { MenuHeader = "TwoTonePathIcon", Key = MenuKeys.MenuKeyTwoTonePathIcon}, - new() { MenuHeader = "ToolBar", Key = MenuKeys.MenuKeyToolBar, Status = "New" } + new() { MenuHeader = "ToolBar", Key = MenuKeys.MenuKeyToolBar, Status = "New" }, + new() { MenuHeader = "Verification Code", Key = MenuKeys.MenuKeyVerificationCode, Status = "New" }, }; } } @@ -81,5 +82,6 @@ public static class MenuKeys public const string MenuKeyTwoTonePathIcon = "TwoTonePathIcon"; public const string MenuKeyThemeToggler = "ThemeToggler"; public const string MenuKeyToolBar = "ToolBar"; + public const string MenuKeyVerificationCode = "VerificationCode"; } \ No newline at end of file diff --git a/demo/Ursa.Demo/ViewModels/VerificationCodeDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/VerificationCodeDemoViewModel.cs new file mode 100644 index 0000000..5de9705 --- /dev/null +++ b/demo/Ursa.Demo/ViewModels/VerificationCodeDemoViewModel.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia.Collections; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Ursa.Controls; + +namespace Ursa.Demo.ViewModels; + +public partial class VerificationCodeDemoViewModel: ObservableObject +{ + public ICommand CompleteCommand { get; set; } + [ObservableProperty] private List? _error; + + public VerificationCodeDemoViewModel() + { + CompleteCommand = new AsyncRelayCommand>(OnComplete); + Error = [new Exception("Invalid verification code")]; + } + + private async Task OnComplete(IList? obj) + { + if (obj is null) return; + var code = string.Join("", obj); + await MessageBox.ShowOverlayAsync(code); + } +} \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/VerificationCode.axaml b/src/Ursa.Themes.Semi/Controls/VerificationCode.axaml new file mode 100644 index 0000000..ae38a54 --- /dev/null +++ b/src/Ursa.Themes.Semi/Controls/VerificationCode.axaml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ursa.Themes.Semi/Controls/_index.axaml b/src/Ursa.Themes.Semi/Controls/_index.axaml index ddb53fd..e79e02f 100644 --- a/src/Ursa.Themes.Semi/Controls/_index.axaml +++ b/src/Ursa.Themes.Semi/Controls/_index.axaml @@ -33,5 +33,6 @@ + diff --git a/src/Ursa/Controls/VerificationCode/VerificationCode.cs b/src/Ursa/Controls/VerificationCode/VerificationCode.cs new file mode 100644 index 0000000..0a82576 --- /dev/null +++ b/src/Ursa/Controls/VerificationCode/VerificationCode.cs @@ -0,0 +1,175 @@ +using System.Windows.Input; +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Metadata; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Utilities; +using Irihi.Avalonia.Shared.Helpers; + +namespace Ursa.Controls; + +[TemplatePart(PART_ItemsControl, typeof(ItemsControl))] +public class VerificationCode: TemplatedControl +{ + public const string PART_ItemsControl = "PART_ItemsControl"; + private ItemsControl? _itemsControl; + private int _currentIndex = 0; + + public static readonly StyledProperty CompleteCommandProperty = AvaloniaProperty.Register( + nameof(CompleteCommand)); + + public ICommand? CompleteCommand + { + get => GetValue(CompleteCommandProperty); + set => SetValue(CompleteCommandProperty, value); + } + + public static readonly StyledProperty CountProperty = AvaloniaProperty.Register( + nameof(Count)); + + public int Count + { + get => GetValue(CountProperty); + set => SetValue(CountProperty, value); + } + + public static readonly StyledProperty PasswordCharProperty = + AvaloniaProperty.Register( + nameof(PasswordChar)); + + public char PasswordChar + { + get => GetValue(PasswordCharProperty); + set => SetValue(PasswordCharProperty, value); + } + + public static readonly StyledProperty ModeProperty = + AvaloniaProperty.Register( + nameof(Mode), defaultValue: VerificationCodeMode.Digit | VerificationCodeMode.Letter); + + public VerificationCodeMode Mode + { + get => GetValue(ModeProperty); + set => SetValue(ModeProperty, value); + } + + public static readonly DirectProperty> DigitsProperty = AvaloniaProperty.RegisterDirect>( + nameof(Digits), o => o.Digits, (o, v) => o.Digits = v); + + private IList _digits = []; + internal IList Digits + { + get => _digits; + set => SetAndRaise(DigitsProperty, ref _digits, value); + } + + public static readonly RoutedEvent CompleteEvent = + RoutedEvent.Register( + nameof(Complete), RoutingStrategies.Bubble); + + public event EventHandler Complete + { + add => AddHandler(CompleteEvent, value); + remove => RemoveHandler(CompleteEvent, value); + } + + static VerificationCode() + { + CountProperty.Changed.AddClassHandler((code, args) => code.OnCountOfDigitChanged(args)); + FocusableProperty.OverrideDefaultValue(true); + } + + public VerificationCode() + { + InputMethod.SetIsInputMethodEnabled(this, false); + } + + private void OnCountOfDigitChanged(AvaloniaPropertyChangedEventArgs args) + { + var newValue = args.NewValue.Value; + if (newValue > 0) + { + Digits = new List(Enumerable.Repeat(string.Empty, newValue)); + } + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + _itemsControl = e.NameScope.Get(PART_ItemsControl); + PointerPressedEvent.AddHandler(OnControlPressed, RoutingStrategies.Tunnel, false, this); + } + + private void OnControlPressed(object sender, PointerPressedEventArgs e) + { + if (e.Source is Control t) + { + /* + var item = t.FindLogicalAncestorOfType(); + if (item != null) + { + item.Focus(); + _currentIndex = _itemsControl?.IndexFromContainer(item) ?? 0; + } + */ + _currentIndex = MathUtilities.Clamp(_currentIndex, 0, Count - 1); + _itemsControl?.ContainerFromIndex(_currentIndex)?.Focus(); + } + e.Handled = true; + } + + protected override void OnTextInput(TextInputEventArgs e) + { + base.OnTextInput(e); + if (e.Text?.Length == 1 && _currentIndex < Count) + { + var presenter = _itemsControl?.ContainerFromIndex(_currentIndex) as VerificationCodeItem; + if (presenter is null) return; + char c = e.Text[0]; + if (!Valid(c, this.Mode)) return; + presenter.Text = e.Text; + Digits[_currentIndex] = e.Text; + _currentIndex++; + _itemsControl?.ContainerFromIndex(_currentIndex)?.Focus(); + if (_currentIndex == Count) + { + CompleteCommand?.Execute(Digits); + RaiseEvent(new VerificationCodeCompleteEventArgs(Digits, CompleteEvent)); + } + } + } + + private bool Valid(char c, VerificationCodeMode mode) + { + bool isDigit = char.IsDigit(c); + bool isLetter = char.IsLetter(c); + return mode switch + { + VerificationCodeMode.Digit => isDigit, + VerificationCodeMode.Letter => isLetter, + VerificationCodeMode.Digit | VerificationCodeMode.Letter => isDigit || isLetter, + _ => true + }; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key == Key.Back && _currentIndex >= 0) + { + _currentIndex = MathUtilities.Clamp(_currentIndex, 0, Count - 1); + var presenter = _itemsControl?.ContainerFromIndex(_currentIndex) as VerificationCodeItem; + if (presenter is null) return; + Digits[_currentIndex] = string.Empty; + presenter.Text = string.Empty; + if (_currentIndex == 0) return; + _currentIndex--; + _itemsControl?.ContainerFromIndex(_currentIndex)?.Focus(); + } + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/VerificationCode/VerificationCodeCollection.cs b/src/Ursa/Controls/VerificationCode/VerificationCodeCollection.cs new file mode 100644 index 0000000..e403c87 --- /dev/null +++ b/src/Ursa/Controls/VerificationCode/VerificationCodeCollection.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Media; + +namespace Ursa.Controls; + +public class VerificationCodeCollection: ItemsControl +{ + protected override bool NeedsContainerOverride(object? item, int index, out object? recycleKey) + { + return NeedsContainer(item, out recycleKey); + } + + protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) + { + return new VerificationCodeItem() + { + [InputMethod.IsInputMethodEnabledProperty] = false, + }; + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/VerificationCode/VerificationCodeCompleteEventArgs.cs b/src/Ursa/Controls/VerificationCode/VerificationCodeCompleteEventArgs.cs new file mode 100644 index 0000000..e1422cf --- /dev/null +++ b/src/Ursa/Controls/VerificationCode/VerificationCodeCompleteEventArgs.cs @@ -0,0 +1,9 @@ +using Avalonia.Collections; +using Avalonia.Interactivity; + +namespace Ursa.Controls; + +public class VerificationCodeCompleteEventArgs(IList code, RoutedEvent? @event) : RoutedEventArgs(@event) +{ + public IList Code { get; } = code; +} \ No newline at end of file diff --git a/src/Ursa/Controls/VerificationCode/VerificationCodeItem.cs b/src/Ursa/Controls/VerificationCode/VerificationCodeItem.cs new file mode 100644 index 0000000..081bc7f --- /dev/null +++ b/src/Ursa/Controls/VerificationCode/VerificationCodeItem.cs @@ -0,0 +1,26 @@ +using Avalonia; +using Avalonia.Controls.Primitives; +using Avalonia.Data; + +namespace Ursa.Controls; + +public class VerificationCodeItem: TemplatedControl +{ + public static readonly StyledProperty TextProperty = AvaloniaProperty.Register( + nameof(Text), defaultBindingMode: BindingMode.TwoWay); + + public string Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public static readonly StyledProperty PasswordCharProperty = AvaloniaProperty.Register( + nameof(PasswordChar), defaultBindingMode: BindingMode.TwoWay); + + public char PasswordChar + { + get => GetValue(PasswordCharProperty); + set => SetValue(PasswordCharProperty, value); + } +} \ No newline at end of file diff --git a/src/Ursa/Controls/VerificationCode/VerificationCodeMode.cs b/src/Ursa/Controls/VerificationCode/VerificationCodeMode.cs new file mode 100644 index 0000000..6caf485 --- /dev/null +++ b/src/Ursa/Controls/VerificationCode/VerificationCodeMode.cs @@ -0,0 +1,8 @@ +namespace Ursa.Controls; + +[Flags] +public enum VerificationCodeMode +{ + Letter = 1, + Digit = 2, +} \ No newline at end of file