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