Files

543 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}