543 lines
20 KiB
C#
543 lines
20 KiB
C#
using Microsoft.Maui.Controls.Shapes;
|
||
using MarketAlly.SpotlightTour.Maui.Animations;
|
||
using MarketAlly.SpotlightTour.Maui.Localization;
|
||
|
||
namespace MarketAlly.SpotlightTour.Maui;
|
||
|
||
/// <summary>
|
||
/// A corner-positioned navigator with step indicator and quick nav buttons.
|
||
/// </summary>
|
||
public class CornerNavigator : Border
|
||
{
|
||
private readonly Label _stepIndicator;
|
||
private readonly Border _previousButton;
|
||
private readonly Border _nextButton;
|
||
private readonly Border _skipButton;
|
||
private readonly HorizontalStackLayout _layout;
|
||
|
||
#region Bindable Properties
|
||
|
||
public static readonly BindableProperty CurrentStepProperty =
|
||
BindableProperty.Create(nameof(CurrentStep), typeof(int), typeof(CornerNavigator), 0,
|
||
propertyChanged: (b, _, _) => ((CornerNavigator)b).UpdateStepIndicator());
|
||
|
||
public int CurrentStep
|
||
{
|
||
get => (int)GetValue(CurrentStepProperty);
|
||
set => SetValue(CurrentStepProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty TotalStepsProperty =
|
||
BindableProperty.Create(nameof(TotalSteps), typeof(int), typeof(CornerNavigator), 0,
|
||
propertyChanged: (b, _, _) => ((CornerNavigator)b).UpdateStepIndicator());
|
||
|
||
public int TotalSteps
|
||
{
|
||
get => (int)GetValue(TotalStepsProperty);
|
||
set => SetValue(TotalStepsProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty PlacementProperty =
|
||
BindableProperty.Create(nameof(Placement), typeof(CornerNavigatorPlacement), typeof(CornerNavigator),
|
||
CornerNavigatorPlacement.BottomRight,
|
||
propertyChanged: (b, _, _) => ((CornerNavigator)b).UpdatePlacement());
|
||
|
||
public CornerNavigatorPlacement Placement
|
||
{
|
||
get => (CornerNavigatorPlacement)GetValue(PlacementProperty);
|
||
set => SetValue(PlacementProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty ShowSkipButtonProperty =
|
||
BindableProperty.Create(nameof(ShowSkipButton), typeof(bool), typeof(CornerNavigator), true,
|
||
propertyChanged: (b, _, n) => ((CornerNavigator)b)._skipButton.IsVisible = (bool)n);
|
||
|
||
public bool ShowSkipButton
|
||
{
|
||
get => (bool)GetValue(ShowSkipButtonProperty);
|
||
set => SetValue(ShowSkipButtonProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty ShowNavigationButtonsProperty =
|
||
BindableProperty.Create(nameof(ShowNavigationButtons), typeof(bool), typeof(CornerNavigator), true,
|
||
propertyChanged: (b, _, n) => ((CornerNavigator)b).UpdateNavigationButtonsVisibility((bool)n));
|
||
|
||
public bool ShowNavigationButtons
|
||
{
|
||
get => (bool)GetValue(ShowNavigationButtonsProperty);
|
||
set => SetValue(ShowNavigationButtonsProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty MarginFromEdgeProperty =
|
||
BindableProperty.Create(nameof(MarginFromEdge), typeof(double), typeof(CornerNavigator), 16.0,
|
||
propertyChanged: (b, _, _) => ((CornerNavigator)b).UpdatePlacement());
|
||
|
||
public double MarginFromEdge
|
||
{
|
||
get => (double)GetValue(MarginFromEdgeProperty);
|
||
set => SetValue(MarginFromEdgeProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty NavigatorBackgroundColorProperty =
|
||
BindableProperty.Create(nameof(NavigatorBackgroundColor), typeof(Color), typeof(CornerNavigator),
|
||
Color.FromArgb("#1C1C1E"),
|
||
propertyChanged: (b, _, n) => ((CornerNavigator)b).BackgroundColor = (Color)n);
|
||
|
||
public Color NavigatorBackgroundColor
|
||
{
|
||
get => (Color)GetValue(NavigatorBackgroundColorProperty);
|
||
set => SetValue(NavigatorBackgroundColorProperty, value);
|
||
}
|
||
|
||
public static readonly BindableProperty SkipButtonTextProperty =
|
||
BindableProperty.Create(nameof(SkipButtonText), typeof(string), typeof(CornerNavigator), "Done",
|
||
propertyChanged: (b, _, n) => ((CornerNavigator)b).UpdateSkipButtonText((string?)n));
|
||
|
||
public string? SkipButtonText
|
||
{
|
||
get => (string?)GetValue(SkipButtonTextProperty);
|
||
set => SetValue(SkipButtonTextProperty, value);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Events
|
||
|
||
public event EventHandler? PreviousClicked;
|
||
public event EventHandler? NextClicked;
|
||
public event EventHandler? SkipClicked;
|
||
|
||
#endregion
|
||
|
||
public CornerNavigator() : this(TourConfiguration.Default) { }
|
||
|
||
public CornerNavigator(TourConfiguration config)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(config);
|
||
|
||
BackgroundColor = Color.FromArgb(config.DarkBackgroundColor);
|
||
StrokeShape = new RoundRectangle { CornerRadius = 25 };
|
||
Stroke = Color.FromArgb(config.DarkBorderColor);
|
||
StrokeThickness = 1;
|
||
Padding = new Thickness(6);
|
||
HorizontalOptions = LayoutOptions.Start;
|
||
VerticalOptions = LayoutOptions.Start;
|
||
|
||
Shadow = new Shadow
|
||
{
|
||
Brush = new SolidColorBrush(Colors.Black),
|
||
Offset = new Point(0, 4),
|
||
Radius = 12,
|
||
Opacity = 0.4f
|
||
};
|
||
|
||
// Previous button - circular with arrow
|
||
_previousButton = CreateNavButton("\u276E", Color.FromArgb(config.DarkBorderColor), config); // ❮
|
||
var prevTap = new TapGestureRecognizer();
|
||
prevTap.Tapped += (_, _) => {
|
||
System.Diagnostics.Debug.WriteLine($"[CornerNavigator] Previous button tapped, opacity={_previousButton.Opacity}");
|
||
if (_previousButton.Opacity > 0.5) PreviousClicked?.Invoke(this, EventArgs.Empty);
|
||
};
|
||
_previousButton.GestureRecognizers.Add(prevTap);
|
||
|
||
// Step indicator
|
||
_stepIndicator = new Label
|
||
{
|
||
TextColor = Colors.White,
|
||
FontSize = 14,
|
||
FontAttributes = FontAttributes.Bold,
|
||
VerticalOptions = LayoutOptions.Center,
|
||
HorizontalOptions = LayoutOptions.Center,
|
||
Margin = new Thickness(12, 0)
|
||
};
|
||
|
||
// Next button - circular with arrow, highlighted
|
||
_nextButton = CreateNavButton("\u276F", Color.FromArgb(config.PrimaryAccentColor), config); // ❯
|
||
var nextTap = new TapGestureRecognizer();
|
||
nextTap.Tapped += (_, _) => {
|
||
System.Diagnostics.Debug.WriteLine($"[CornerNavigator] Next button tapped, opacity={_nextButton.Opacity}");
|
||
if (_nextButton.Opacity > 0.5) NextClicked?.Invoke(this, EventArgs.Empty);
|
||
};
|
||
_nextButton.GestureRecognizers.Add(nextTap);
|
||
|
||
// Done button - pill shaped
|
||
_skipButton = new Border
|
||
{
|
||
BackgroundColor = Color.FromArgb(config.DangerColor),
|
||
StrokeShape = new RoundRectangle { CornerRadius = 14 },
|
||
Stroke = Colors.Transparent,
|
||
Padding = new Thickness(14, 6),
|
||
Margin = new Thickness(8, 0, 0, 0),
|
||
Content = new Label
|
||
{
|
||
Text = TourStrings.Done,
|
||
TextColor = Colors.White,
|
||
FontSize = 13,
|
||
FontAttributes = FontAttributes.Bold,
|
||
VerticalOptions = LayoutOptions.Center,
|
||
HorizontalOptions = LayoutOptions.Center
|
||
}
|
||
};
|
||
var skipTap = new TapGestureRecognizer();
|
||
skipTap.Tapped += (_, _) => SkipClicked?.Invoke(this, EventArgs.Empty);
|
||
_skipButton.GestureRecognizers.Add(skipTap);
|
||
|
||
_layout = new HorizontalStackLayout
|
||
{
|
||
Spacing = 4,
|
||
VerticalOptions = LayoutOptions.Center,
|
||
Children =
|
||
{
|
||
_previousButton,
|
||
_stepIndicator,
|
||
_nextButton,
|
||
_skipButton
|
||
}
|
||
};
|
||
|
||
Content = _layout;
|
||
UpdateStepIndicator();
|
||
UpdatePlacement();
|
||
|
||
// Subscribe to culture changes
|
||
TourStrings.CultureChanged += OnCultureChanged;
|
||
}
|
||
|
||
private static Border CreateNavButton(string symbol, Color bgColor, TourConfiguration config)
|
||
{
|
||
return new Border
|
||
{
|
||
BackgroundColor = bgColor,
|
||
StrokeShape = new RoundRectangle { CornerRadius = (float)config.NavButtonCornerRadius },
|
||
Stroke = Colors.Transparent,
|
||
WidthRequest = config.NavButtonSize,
|
||
HeightRequest = config.NavButtonSize,
|
||
Content = new Label
|
||
{
|
||
Text = symbol,
|
||
TextColor = Colors.White,
|
||
FontSize = 16,
|
||
FontAttributes = FontAttributes.Bold,
|
||
HorizontalOptions = LayoutOptions.Center,
|
||
VerticalOptions = LayoutOptions.Center
|
||
}
|
||
};
|
||
}
|
||
|
||
private void UpdateSkipButtonText(string? text)
|
||
{
|
||
if (_skipButton.Content is Label label)
|
||
label.Text = text ?? TourStrings.Done;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates text when culture changes.
|
||
/// </summary>
|
||
private void OnCultureChanged(object? sender, EventArgs e)
|
||
{
|
||
UpdateLocalizedText();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates all localized text in the navigator.
|
||
/// </summary>
|
||
public void UpdateLocalizedText()
|
||
{
|
||
UpdateStepIndicator();
|
||
if (_skipButton.Content is Label label && SkipButtonText == null)
|
||
label.Text = TourStrings.Done;
|
||
}
|
||
|
||
private void UpdateStepIndicator()
|
||
{
|
||
UpdateStepIndicatorCore(CurrentStep, TotalSteps);
|
||
}
|
||
|
||
private void UpdateStepIndicatorCore(int currentStep, int totalSteps)
|
||
{
|
||
// Ensure UI updates happen on the main thread
|
||
if (!MainThread.IsMainThread)
|
||
{
|
||
MainThread.BeginInvokeOnMainThread(() => UpdateStepIndicatorCore(currentStep, totalSteps));
|
||
return;
|
||
}
|
||
|
||
var config = TourConfiguration.Default;
|
||
|
||
// Debug: Use hardcoded format to rule out localization issues
|
||
_stepIndicator.Text = $"{currentStep + 1} / {totalSteps}";
|
||
|
||
System.Diagnostics.Debug.WriteLine($"[CornerNavigator] UpdateStepIndicatorCore called: step={currentStep}, total={totalSteps}, text={_stepIndicator.Text}");
|
||
|
||
// Update button states
|
||
var canGoPrev = currentStep > 0;
|
||
_previousButton.Opacity = canGoPrev ? 1.0 : config.DisabledButtonOpacity;
|
||
|
||
var canGoNext = currentStep < totalSteps - 1;
|
||
_nextButton.Opacity = canGoNext ? 1.0 : config.DisabledButtonOpacity;
|
||
}
|
||
|
||
private void UpdateNavigationButtonsVisibility(bool show)
|
||
{
|
||
_previousButton.IsVisible = show;
|
||
_nextButton.IsVisible = show;
|
||
}
|
||
|
||
private void UpdatePlacement()
|
||
{
|
||
ApplyPlacement(Placement);
|
||
}
|
||
|
||
private void ApplyPlacement(CornerNavigatorPlacement placement)
|
||
{
|
||
switch (placement)
|
||
{
|
||
case CornerNavigatorPlacement.TopLeft:
|
||
HorizontalOptions = LayoutOptions.Start;
|
||
VerticalOptions = LayoutOptions.Start;
|
||
Margin = new Thickness(MarginFromEdge, MarginFromEdge, 0, 0);
|
||
break;
|
||
|
||
case CornerNavigatorPlacement.TopRight:
|
||
HorizontalOptions = LayoutOptions.End;
|
||
VerticalOptions = LayoutOptions.Start;
|
||
Margin = new Thickness(0, MarginFromEdge, MarginFromEdge, 0);
|
||
break;
|
||
|
||
case CornerNavigatorPlacement.BottomLeft:
|
||
HorizontalOptions = LayoutOptions.Start;
|
||
VerticalOptions = LayoutOptions.End;
|
||
Margin = new Thickness(MarginFromEdge, 0, 0, MarginFromEdge);
|
||
break;
|
||
|
||
case CornerNavigatorPlacement.BottomRight:
|
||
case CornerNavigatorPlacement.Auto:
|
||
default:
|
||
HorizontalOptions = LayoutOptions.End;
|
||
VerticalOptions = LayoutOptions.End;
|
||
Margin = new Thickness(0, 0, MarginFromEdge, MarginFromEdge);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates placement to avoid overlapping with the spotlight bounds and callout card.
|
||
/// Only has effect when Placement is set to Auto.
|
||
/// </summary>
|
||
public void UpdateAutoPlacement(Rect spotlightBounds, Size containerSize, Rect? calloutBounds = null, CalloutCorner? calloutCorner = null)
|
||
{
|
||
// Ensure UI updates happen on the main thread
|
||
if (!MainThread.IsMainThread)
|
||
{
|
||
MainThread.BeginInvokeOnMainThread(() => UpdateAutoPlacement(spotlightBounds, containerSize, calloutBounds, calloutCorner));
|
||
return;
|
||
}
|
||
|
||
if (Placement != CornerNavigatorPlacement.Auto)
|
||
{
|
||
ApplyPlacement(Placement);
|
||
return;
|
||
}
|
||
|
||
var config = TourConfiguration.Default;
|
||
var navWidth = config.NavigatorEstimatedWidth;
|
||
var navHeight = config.NavigatorEstimatedHeight;
|
||
var margin = MarginFromEdge;
|
||
|
||
// If callout is in a corner, try to stack with it (same side, opposite vertical)
|
||
if (calloutCorner.HasValue)
|
||
{
|
||
// First try: complement corner (same side, opposite vertical)
|
||
var complementCorner = GetComplementCorner(calloutCorner.Value);
|
||
var complementRect = GetCornerRect(complementCorner, containerSize, navWidth, navHeight, margin);
|
||
var complementOverlap = CalculateOverlap(complementRect, spotlightBounds);
|
||
|
||
if (complementOverlap < config.MinOverlapThreshold)
|
||
{
|
||
ApplyPlacement(complementCorner);
|
||
return;
|
||
}
|
||
|
||
// Second try: opposite horizontal, same vertical as callout
|
||
var oppositeCorner = GetOppositeHorizontalCorner(calloutCorner.Value);
|
||
var oppositeRect = GetCornerRect(oppositeCorner, containerSize, navWidth, navHeight, margin);
|
||
var oppositeOverlap = CalculateOverlap(oppositeRect, spotlightBounds);
|
||
|
||
if (oppositeOverlap < complementOverlap)
|
||
{
|
||
ApplyPlacement(oppositeCorner);
|
||
return;
|
||
}
|
||
|
||
// If complement is still better despite overlap, use it
|
||
if (complementOverlap < config.MaxOverlapThreshold)
|
||
{
|
||
ApplyPlacement(complementCorner);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Fallback: find best corner avoiding both spotlight and callout
|
||
var bestCorner = DetermineBestCorner(spotlightBounds, containerSize, calloutBounds);
|
||
ApplyPlacement(bestCorner);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the complementary corner (same side, opposite vertical position).
|
||
/// </summary>
|
||
private static CornerNavigatorPlacement GetComplementCorner(CalloutCorner calloutCorner)
|
||
{
|
||
return SpotlightGeometry.GetComplementCorner(calloutCorner);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the corner on the opposite horizontal side but same vertical position.
|
||
/// </summary>
|
||
private static CornerNavigatorPlacement GetOppositeHorizontalCorner(CalloutCorner calloutCorner)
|
||
{
|
||
return SpotlightGeometry.GetOppositeHorizontalCorner(calloutCorner);
|
||
}
|
||
|
||
private static Rect GetCornerRect(CornerNavigatorPlacement corner, Size containerSize, double width, double height, double margin)
|
||
{
|
||
return SpotlightGeometry.GetCornerRect(corner, containerSize, width, height, margin);
|
||
}
|
||
|
||
private CornerNavigatorPlacement DetermineBestCorner(Rect spotlightBounds, Size containerSize, Rect? calloutBounds)
|
||
{
|
||
var config = TourConfiguration.Default;
|
||
return SpotlightGeometry.DetermineBestNavigatorPlacement(
|
||
spotlightBounds,
|
||
containerSize,
|
||
config.NavigatorEstimatedWidth,
|
||
config.NavigatorEstimatedHeight,
|
||
MarginFromEdge,
|
||
calloutBounds);
|
||
}
|
||
|
||
private static double CalculateOverlap(Rect a, Rect b)
|
||
{
|
||
return SpotlightGeometry.CalculateOverlap(a, b);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates the navigator for a specific step.
|
||
/// </summary>
|
||
public void UpdateForStep(int currentIndex, int totalCount)
|
||
{
|
||
System.Diagnostics.Debug.WriteLine($"[CornerNavigator] UpdateForStep called: index={currentIndex}, total={totalCount}, IsMainThread={MainThread.IsMainThread}");
|
||
|
||
// Store values in properties for external access
|
||
SetValue(CurrentStepProperty, currentIndex);
|
||
SetValue(TotalStepsProperty, totalCount);
|
||
|
||
// Directly update UI with the passed values (bypasses property getters)
|
||
UpdateStepIndicatorCore(currentIndex, totalCount);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies light or dark theme to the corner navigator.
|
||
/// </summary>
|
||
public void ApplyTheme(bool isDark)
|
||
{
|
||
if (isDark)
|
||
{
|
||
NavigatorBackgroundColor = Color.FromArgb("#1C1C1E");
|
||
Stroke = Color.FromArgb("#3A3A3C");
|
||
_stepIndicator.TextColor = Colors.White;
|
||
_previousButton.BackgroundColor = Color.FromArgb("#3A3A3C");
|
||
if (_previousButton.Content is Label prevLabel)
|
||
prevLabel.TextColor = Colors.White;
|
||
_nextButton.BackgroundColor = Color.FromArgb("#0A84FF");
|
||
if (_nextButton.Content is Label nextLabel)
|
||
nextLabel.TextColor = Colors.White;
|
||
_skipButton.BackgroundColor = Color.FromArgb("#FF453A");
|
||
if (_skipButton.Content is Label skipLabel)
|
||
skipLabel.TextColor = Colors.White;
|
||
}
|
||
else
|
||
{
|
||
NavigatorBackgroundColor = Colors.White;
|
||
Stroke = Color.FromArgb("#E5E5EA");
|
||
_stepIndicator.TextColor = Colors.Black;
|
||
_previousButton.BackgroundColor = Color.FromArgb("#E5E5EA");
|
||
if (_previousButton.Content is Label prevLabel)
|
||
prevLabel.TextColor = Color.FromArgb("#3A3A3C");
|
||
_nextButton.BackgroundColor = Color.FromArgb("#007AFF");
|
||
if (_nextButton.Content is Label nextLabel)
|
||
nextLabel.TextColor = Colors.White;
|
||
_skipButton.BackgroundColor = Color.FromArgb("#FF3B30");
|
||
if (_skipButton.Content is Label skipLabel)
|
||
skipLabel.TextColor = Colors.White;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies RTL or LTR flow direction to the navigator layout.
|
||
/// </summary>
|
||
/// <param name="isRtl">True for right-to-left layout.</param>
|
||
public void ApplyFlowDirection(bool isRtl)
|
||
{
|
||
// Ensure UI updates happen on the main thread
|
||
if (!MainThread.IsMainThread)
|
||
{
|
||
MainThread.BeginInvokeOnMainThread(() => ApplyFlowDirection(isRtl));
|
||
return;
|
||
}
|
||
|
||
// In RTL, we reverse the button order:
|
||
// LTR: [Previous] [1/6] [Next] [Skip]
|
||
// RTL: [Skip] [Next] [1/6] [Previous]
|
||
_layout.Children.Clear();
|
||
|
||
if (isRtl)
|
||
{
|
||
_layout.Children.Add(_skipButton);
|
||
_layout.Children.Add(_nextButton);
|
||
_layout.Children.Add(_stepIndicator);
|
||
_layout.Children.Add(_previousButton);
|
||
}
|
||
else
|
||
{
|
||
_layout.Children.Add(_previousButton);
|
||
_layout.Children.Add(_stepIndicator);
|
||
_layout.Children.Add(_nextButton);
|
||
_layout.Children.Add(_skipButton);
|
||
}
|
||
}
|
||
|
||
#region Animations
|
||
|
||
/// <summary>
|
||
/// Shows the navigator with an entrance animation.
|
||
/// </summary>
|
||
/// <param name="config">Animation configuration to use.</param>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
public async Task ShowWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
|
||
{
|
||
config ??= AnimationConfiguration.Default;
|
||
await AnimationHelper.PlayEntranceAsync(this, config.NavigatorEntrance, config, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hides the navigator with an exit animation.
|
||
/// </summary>
|
||
/// <param name="config">Animation configuration to use.</param>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
public async Task HideWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
|
||
{
|
||
config ??= AnimationConfiguration.Default;
|
||
await AnimationHelper.PlayExitAsync(this, config.NavigatorExit, config, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resets all animation properties to default.
|
||
/// </summary>
|
||
public void ResetAnimationState()
|
||
{
|
||
AnimationHelper.ResetElement(this);
|
||
}
|
||
|
||
#endregion
|
||
}
|