diff --git a/MarketAlly.MASpotlightTour.slnx b/MarketAlly.MASpotlightTour.slnx
new file mode 100644
index 0000000..b86c960
--- /dev/null
+++ b/MarketAlly.MASpotlightTour.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/MarketAlly.MASpotlightTour/ArrowIndicator.cs b/MarketAlly.MASpotlightTour/ArrowIndicator.cs
new file mode 100644
index 0000000..6589c49
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/ArrowIndicator.cs
@@ -0,0 +1,161 @@
+using Microsoft.Maui.Controls.Shapes;
+using Path = Microsoft.Maui.Controls.Shapes.Path;
+
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// An arrow indicator that points from the callout card toward the spotlight target.
+///
+public class ArrowIndicator : ContentView
+{
+ private readonly Path _arrowPath;
+
+ #region Bindable Properties
+
+ public static readonly BindableProperty ArrowColorProperty =
+ BindableProperty.Create(nameof(ArrowColor), typeof(Color), typeof(ArrowIndicator), Colors.White,
+ propertyChanged: (b, _, n) => ((ArrowIndicator)b).UpdateArrowColor((Color)n));
+
+ public Color ArrowColor
+ {
+ get => (Color)GetValue(ArrowColorProperty);
+ set => SetValue(ArrowColorProperty, value);
+ }
+
+ public static readonly BindableProperty ArrowSizeProperty =
+ BindableProperty.Create(nameof(ArrowSize), typeof(double), typeof(ArrowIndicator), 16.0);
+
+ public double ArrowSize
+ {
+ get => (double)GetValue(ArrowSizeProperty);
+ set => SetValue(ArrowSizeProperty, value);
+ }
+
+ public static readonly BindableProperty DirectionProperty =
+ BindableProperty.Create(nameof(Direction), typeof(CalloutPlacement), typeof(ArrowIndicator),
+ CalloutPlacement.Bottom,
+ propertyChanged: (b, _, _) => ((ArrowIndicator)b).UpdateArrow());
+
+ ///
+ /// The direction the arrow points (toward the target).
+ ///
+ public CalloutPlacement Direction
+ {
+ get => (CalloutPlacement)GetValue(DirectionProperty);
+ set => SetValue(DirectionProperty, value);
+ }
+
+ #endregion
+
+ public ArrowIndicator()
+ {
+ _arrowPath = new Path
+ {
+ Fill = new SolidColorBrush(ArrowColor),
+ Stroke = null
+ };
+
+ Content = _arrowPath;
+ UpdateArrow();
+ }
+
+ private void UpdateArrowColor(Color color)
+ {
+ _arrowPath.Fill = new SolidColorBrush(color);
+ }
+
+ private void UpdateArrow()
+ {
+ var size = ArrowSize;
+ var geometry = new PathGeometry();
+ var figure = new PathFigure { IsClosed = true };
+
+ switch (Direction)
+ {
+ case CalloutPlacement.Top:
+ // Arrow pointing up (callout is below target)
+ figure.StartPoint = new Point(size / 2, 0);
+ figure.Segments.Add(new LineSegment(new Point(size, size)));
+ figure.Segments.Add(new LineSegment(new Point(0, size)));
+ WidthRequest = size;
+ HeightRequest = size;
+ break;
+
+ case CalloutPlacement.Bottom:
+ // Arrow pointing down (callout is above target)
+ figure.StartPoint = new Point(0, 0);
+ figure.Segments.Add(new LineSegment(new Point(size, 0)));
+ figure.Segments.Add(new LineSegment(new Point(size / 2, size)));
+ WidthRequest = size;
+ HeightRequest = size;
+ break;
+
+ case CalloutPlacement.Left:
+ // Arrow pointing left (callout is to the right of target)
+ figure.StartPoint = new Point(0, size / 2);
+ figure.Segments.Add(new LineSegment(new Point(size, 0)));
+ figure.Segments.Add(new LineSegment(new Point(size, size)));
+ WidthRequest = size;
+ HeightRequest = size;
+ break;
+
+ case CalloutPlacement.Right:
+ // Arrow pointing right (callout is to the left of target)
+ figure.StartPoint = new Point(size, size / 2);
+ figure.Segments.Add(new LineSegment(new Point(0, 0)));
+ figure.Segments.Add(new LineSegment(new Point(0, size)));
+ WidthRequest = size;
+ HeightRequest = size;
+ break;
+
+ default:
+ // No arrow for Auto
+ IsVisible = false;
+ return;
+ }
+
+ geometry.Figures.Add(figure);
+ _arrowPath.Data = geometry;
+ IsVisible = true;
+ }
+
+ ///
+ /// Positions the arrow between the callout and spotlight.
+ ///
+ public void PositionBetween(Rect calloutBounds, Rect spotlightBounds, CalloutPlacement placement)
+ {
+ Direction = placement;
+ var size = ArrowSize;
+
+ switch (placement)
+ {
+ case CalloutPlacement.Top:
+ // Callout is above, arrow points up from bottom of callout toward target
+ TranslationX = calloutBounds.Center.X - size / 2;
+ TranslationY = calloutBounds.Bottom;
+ break;
+
+ case CalloutPlacement.Bottom:
+ // Callout is below, arrow points down from top of callout toward target
+ TranslationX = calloutBounds.Center.X - size / 2;
+ TranslationY = calloutBounds.Top - size;
+ break;
+
+ case CalloutPlacement.Left:
+ // Callout is to the left, arrow points left from right of callout toward target
+ TranslationX = calloutBounds.Right;
+ TranslationY = calloutBounds.Center.Y - size / 2;
+ break;
+
+ case CalloutPlacement.Right:
+ // Callout is to the right, arrow points right from left of callout toward target
+ TranslationX = calloutBounds.Left - size;
+ TranslationY = calloutBounds.Center.Y - size / 2;
+ break;
+
+ default:
+ IsVisible = false;
+ break;
+ }
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/CalloutCard.cs b/MarketAlly.MASpotlightTour/CalloutCard.cs
new file mode 100644
index 0000000..1226cfd
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/CalloutCard.cs
@@ -0,0 +1,460 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// A card that displays the title, description, and navigation buttons for an onboarding step.
+///
+public class CalloutCard : Border
+{
+ private readonly Label _titleLabel;
+ private readonly Label _descriptionLabel;
+ private readonly Button _previousButton;
+ private readonly Button _nextButton;
+ private readonly Button _closeButton;
+ private readonly Grid _buttonContainer;
+
+ #region Bindable Properties
+
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(CalloutCard), null,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._titleLabel.Text = (string?)n);
+
+ public string? Title
+ {
+ get => (string?)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(CalloutCard), null,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._descriptionLabel.Text = (string?)n);
+
+ public string? Description
+ {
+ get => (string?)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public static readonly BindableProperty ShowPreviousButtonProperty =
+ BindableProperty.Create(nameof(ShowPreviousButton), typeof(bool), typeof(CalloutCard), true,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._previousButton.IsVisible = (bool)n);
+
+ public bool ShowPreviousButton
+ {
+ get => (bool)GetValue(ShowPreviousButtonProperty);
+ set => SetValue(ShowPreviousButtonProperty, value);
+ }
+
+ public static readonly BindableProperty ShowNextButtonProperty =
+ BindableProperty.Create(nameof(ShowNextButton), typeof(bool), typeof(CalloutCard), true,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._nextButton.IsVisible = (bool)n);
+
+ public bool ShowNextButton
+ {
+ get => (bool)GetValue(ShowNextButtonProperty);
+ set => SetValue(ShowNextButtonProperty, value);
+ }
+
+ public static readonly BindableProperty ShowCloseButtonProperty =
+ BindableProperty.Create(nameof(ShowCloseButton), typeof(bool), typeof(CalloutCard), true,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._closeButton.IsVisible = (bool)n);
+
+ public bool ShowCloseButton
+ {
+ get => (bool)GetValue(ShowCloseButtonProperty);
+ set => SetValue(ShowCloseButtonProperty, value);
+ }
+
+ public static readonly BindableProperty PreviousButtonTextProperty =
+ BindableProperty.Create(nameof(PreviousButtonText), typeof(string), typeof(CalloutCard), "Previous",
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._previousButton.Text = (string?)n);
+
+ public string? PreviousButtonText
+ {
+ get => (string?)GetValue(PreviousButtonTextProperty);
+ set => SetValue(PreviousButtonTextProperty, value);
+ }
+
+ public static readonly BindableProperty NextButtonTextProperty =
+ BindableProperty.Create(nameof(NextButtonText), typeof(string), typeof(CalloutCard), "Next",
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._nextButton.Text = (string?)n);
+
+ public string? NextButtonText
+ {
+ get => (string?)GetValue(NextButtonTextProperty);
+ set => SetValue(NextButtonTextProperty, value);
+ }
+
+ public static readonly BindableProperty CloseButtonTextProperty =
+ BindableProperty.Create(nameof(CloseButtonText), typeof(string), typeof(CalloutCard), "Done",
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._closeButton.Text = (string?)n);
+
+ public string? CloseButtonText
+ {
+ get => (string?)GetValue(CloseButtonTextProperty);
+ set => SetValue(CloseButtonTextProperty, value);
+ }
+
+ public static readonly BindableProperty CardBackgroundColorProperty =
+ BindableProperty.Create(nameof(CardBackgroundColor), typeof(Color), typeof(CalloutCard), Colors.White,
+ propertyChanged: (b, _, n) => ((CalloutCard)b).BackgroundColor = (Color)n);
+
+ public Color CardBackgroundColor
+ {
+ get => (Color)GetValue(CardBackgroundColorProperty);
+ set => SetValue(CardBackgroundColorProperty, value);
+ }
+
+ public static readonly BindableProperty ShowNavigationButtonsProperty =
+ BindableProperty.Create(nameof(ShowNavigationButtons), typeof(bool), typeof(CalloutCard), true,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._buttonContainer.IsVisible = (bool)n);
+
+ public bool ShowNavigationButtons
+ {
+ get => (bool)GetValue(ShowNavigationButtonsProperty);
+ set => SetValue(ShowNavigationButtonsProperty, value);
+ }
+
+ public static readonly BindableProperty TitleColorProperty =
+ BindableProperty.Create(nameof(TitleColor), typeof(Color), typeof(CalloutCard), Colors.Black,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._titleLabel.TextColor = (Color)n);
+
+ public Color TitleColor
+ {
+ get => (Color)GetValue(TitleColorProperty);
+ set => SetValue(TitleColorProperty, value);
+ }
+
+ public static readonly BindableProperty DescriptionColorProperty =
+ BindableProperty.Create(nameof(DescriptionColor), typeof(Color), typeof(CalloutCard), Colors.DarkGray,
+ propertyChanged: (b, _, n) => ((CalloutCard)b)._descriptionLabel.TextColor = (Color)n);
+
+ public Color DescriptionColor
+ {
+ get => (Color)GetValue(DescriptionColorProperty);
+ set => SetValue(DescriptionColorProperty, value);
+ }
+
+ #endregion
+
+ #region Events
+
+ public event EventHandler? PreviousClicked;
+ public event EventHandler? NextClicked;
+ public event EventHandler? CloseClicked;
+
+ #endregion
+
+ public CalloutCard()
+ {
+ BackgroundColor = Colors.White;
+ StrokeShape = new RoundRectangle { CornerRadius = 12 };
+ Stroke = Colors.Transparent;
+ Padding = new Thickness(16);
+
+ // Add shadow
+ Shadow = new Shadow
+ {
+ Brush = new SolidColorBrush(Colors.Black),
+ Offset = new Point(0, 4),
+ Radius = 8,
+ Opacity = 0.3f
+ };
+
+ _titleLabel = new Label
+ {
+ FontAttributes = FontAttributes.Bold,
+ FontSize = 18,
+ TextColor = Colors.Black,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ _descriptionLabel = new Label
+ {
+ FontSize = 14,
+ TextColor = Colors.DarkGray,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ _previousButton = new Button
+ {
+ Text = "Previous",
+ BackgroundColor = Colors.Transparent,
+ TextColor = Colors.Gray,
+ BorderWidth = 0,
+ Padding = new Thickness(8, 4)
+ };
+ _previousButton.Clicked += (_, _) => PreviousClicked?.Invoke(this, EventArgs.Empty);
+
+ _nextButton = new Button
+ {
+ Text = "Next",
+ BackgroundColor = Color.FromArgb("#007AFF"),
+ TextColor = Colors.White,
+ CornerRadius = 8,
+ Padding = new Thickness(16, 8)
+ };
+ _nextButton.Clicked += (_, _) => NextClicked?.Invoke(this, EventArgs.Empty);
+
+ _closeButton = new Button
+ {
+ Text = "Done",
+ BackgroundColor = Color.FromArgb("#34C759"),
+ TextColor = Colors.White,
+ CornerRadius = 8,
+ Padding = new Thickness(16, 8),
+ IsVisible = false
+ };
+ _closeButton.Clicked += (_, _) => CloseClicked?.Invoke(this, EventArgs.Empty);
+
+ _buttonContainer = new Grid
+ {
+ ColumnDefinitions =
+ {
+ new ColumnDefinition(GridLength.Star),
+ new ColumnDefinition(GridLength.Auto),
+ new ColumnDefinition(GridLength.Auto)
+ },
+ ColumnSpacing = 8,
+ Margin = new Thickness(0, 12, 0, 0)
+ };
+
+ _buttonContainer.Add(_previousButton, 0);
+ _buttonContainer.Add(_nextButton, 1);
+ _buttonContainer.Add(_closeButton, 2);
+
+ Content = new VerticalStackLayout
+ {
+ Spacing = 8,
+ Children =
+ {
+ _titleLabel,
+ _descriptionLabel,
+ _buttonContainer
+ }
+ };
+ }
+
+ ///
+ /// Updates the card content and button visibility based on step position.
+ ///
+ public void UpdateForStep(string? title, string? description, bool isFirst, bool isLast)
+ {
+ Title = title;
+ Description = description;
+ ShowPreviousButton = !isFirst;
+ ShowNextButton = !isLast;
+ _closeButton.IsVisible = isLast || ShowCloseButton;
+ }
+
+ ///
+ /// Positions the card relative to a spotlight rectangle.
+ ///
+ public void PositionRelativeToSpotlight(
+ Rect spotlightRect,
+ Size containerSize,
+ CalloutPlacement placement,
+ double margin = 12)
+ {
+ var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
+ WidthRequest = cardWidth;
+
+ // Measure to get height
+ var measured = Measure(cardWidth, double.PositiveInfinity);
+ var cardHeight = measured.Height;
+
+ double x, y;
+
+ var effectivePlacement = placement == CalloutPlacement.Auto
+ ? DetermineAutoPlacement(spotlightRect, containerSize, cardHeight, margin)
+ : placement;
+
+ switch (effectivePlacement)
+ {
+ case CalloutPlacement.Top:
+ x = Math.Max(margin, Math.Min((spotlightRect.Center.X - cardWidth / 2), containerSize.Width - cardWidth - margin));
+ y = Math.Max(margin, spotlightRect.Top - cardHeight - margin);
+ break;
+
+ case CalloutPlacement.Bottom:
+ x = Math.Max(margin, Math.Min((spotlightRect.Center.X - cardWidth / 2), containerSize.Width - cardWidth - margin));
+ y = Math.Min(containerSize.Height - cardHeight - margin, spotlightRect.Bottom + margin);
+ break;
+
+ case CalloutPlacement.Left:
+ x = Math.Max(margin, spotlightRect.Left - cardWidth - margin);
+ y = ClampVertical(spotlightRect.Center.Y - cardHeight / 2, cardHeight, containerSize.Height, margin);
+ break;
+
+ case CalloutPlacement.Right:
+ x = Math.Min(containerSize.Width - cardWidth - margin, spotlightRect.Right + margin);
+ y = ClampVertical(spotlightRect.Center.Y - cardHeight / 2, cardHeight, containerSize.Height, margin);
+ break;
+
+ default:
+ x = (containerSize.Width - cardWidth) / 2;
+ y = containerSize.Height / 2 - cardHeight / 2;
+ break;
+ }
+
+ TranslationX = x;
+ TranslationY = y;
+ }
+
+ private static CalloutPlacement DetermineAutoPlacement(Rect spotlightRect, Size containerSize, double cardHeight, double margin)
+ {
+ var spaceBelow = containerSize.Height - spotlightRect.Bottom - margin;
+ var spaceAbove = spotlightRect.Top - margin;
+ var spaceRight = containerSize.Width - spotlightRect.Right - margin;
+ var spaceLeft = spotlightRect.Left - margin;
+
+ // Prefer bottom, then top, then right, then left
+ if (spaceBelow >= cardHeight + margin)
+ return CalloutPlacement.Bottom;
+ if (spaceAbove >= cardHeight + margin)
+ return CalloutPlacement.Top;
+ if (spaceRight >= 200)
+ return CalloutPlacement.Right;
+ if (spaceLeft >= 200)
+ return CalloutPlacement.Left;
+
+ // Default to bottom if nothing fits well
+ return CalloutPlacement.Bottom;
+ }
+
+ private static double ClampVertical(double y, double height, double containerHeight, double margin)
+ {
+ if (y < margin)
+ return margin;
+ if (y + height + margin > containerHeight)
+ return containerHeight - height - margin;
+ return y;
+ }
+
+ ///
+ /// Animates the card to a new position.
+ ///
+ public async Task AnimateToPositionAsync(double x, double y, uint duration = 250)
+ {
+ await this.TranslateTo(x, y, duration, Easing.CubicInOut);
+ }
+
+ ///
+ /// Positions the card in a specific screen corner.
+ ///
+ public void PositionInCorner(CalloutCorner corner, Size containerSize, double margin = 16)
+ {
+ var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
+ WidthRequest = cardWidth;
+
+ var measured = Measure(cardWidth, double.PositiveInfinity);
+ var cardHeight = measured.Height;
+
+ double x, y;
+
+ switch (corner)
+ {
+ case CalloutCorner.TopLeft:
+ x = margin;
+ y = margin;
+ break;
+
+ case CalloutCorner.TopRight:
+ x = containerSize.Width - cardWidth - margin;
+ y = margin;
+ break;
+
+ case CalloutCorner.BottomLeft:
+ x = margin;
+ y = containerSize.Height - cardHeight - margin;
+ break;
+
+ case CalloutCorner.BottomRight:
+ default:
+ x = containerSize.Width - cardWidth - margin;
+ y = containerSize.Height - cardHeight - margin;
+ break;
+ }
+
+ TranslationX = x;
+ TranslationY = y;
+ }
+
+ ///
+ /// Determines the best corner that doesn't overlap with the spotlight area.
+ ///
+ public static CalloutCorner DetermineBestCorner(Rect spotlightRect, Size containerSize, double cardWidth, double cardHeight, double margin = 16)
+ {
+ // Calculate the effective card size for collision checking
+ var actualCardWidth = Math.Min(containerSize.Width * 0.85, cardWidth);
+
+ // Check each corner and calculate overlap with spotlight
+ var corners = new[]
+ {
+ CalloutCorner.TopLeft,
+ CalloutCorner.TopRight,
+ CalloutCorner.BottomLeft,
+ CalloutCorner.BottomRight
+ };
+
+ CalloutCorner bestCorner = CalloutCorner.BottomLeft;
+ double minOverlap = double.MaxValue;
+
+ foreach (var corner in corners)
+ {
+ var cardRect = GetCornerRect(corner, containerSize, actualCardWidth, cardHeight, margin);
+ var overlap = CalculateOverlap(cardRect, spotlightRect);
+
+ if (overlap < minOverlap)
+ {
+ minOverlap = overlap;
+ bestCorner = corner;
+ }
+ }
+
+ return bestCorner;
+ }
+
+ private static Rect GetCornerRect(CalloutCorner corner, Size containerSize, double cardWidth, double cardHeight, double margin)
+ {
+ return corner switch
+ {
+ CalloutCorner.TopLeft => new Rect(margin, margin, cardWidth, cardHeight),
+ CalloutCorner.TopRight => new Rect(containerSize.Width - cardWidth - margin, margin, cardWidth, cardHeight),
+ CalloutCorner.BottomLeft => new Rect(margin, containerSize.Height - cardHeight - margin, cardWidth, cardHeight),
+ CalloutCorner.BottomRight => new Rect(containerSize.Width - cardWidth - margin, containerSize.Height - cardHeight - margin, cardWidth, cardHeight),
+ _ => new Rect(margin, containerSize.Height - cardHeight - margin, cardWidth, cardHeight)
+ };
+ }
+
+ private static double CalculateOverlap(Rect rect1, Rect rect2)
+ {
+ var xOverlap = Math.Max(0, Math.Min(rect1.Right, rect2.Right) - Math.Max(rect1.Left, rect2.Left));
+ var yOverlap = Math.Max(0, Math.Min(rect1.Bottom, rect2.Bottom) - Math.Max(rect1.Top, rect2.Top));
+ return xOverlap * yOverlap;
+ }
+
+ ///
+ /// Applies light or dark theme to the callout card.
+ ///
+ public void ApplyTheme(bool isDark)
+ {
+ if (isDark)
+ {
+ CardBackgroundColor = Color.FromArgb("#1C1C1E");
+ TitleColor = Colors.White;
+ DescriptionColor = Color.FromArgb("#ABABAB");
+ Stroke = Color.FromArgb("#3A3A3C");
+ _previousButton.TextColor = Color.FromArgb("#8E8E93");
+ }
+ else
+ {
+ CardBackgroundColor = Colors.White;
+ TitleColor = Colors.Black;
+ DescriptionColor = Colors.DarkGray;
+ Stroke = Colors.Transparent;
+ _previousButton.TextColor = Colors.Gray;
+ }
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/CornerNavigator.cs b/MarketAlly.MASpotlightTour/CornerNavigator.cs
new file mode 100644
index 0000000..a1fe312
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/CornerNavigator.cs
@@ -0,0 +1,311 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// A corner-positioned navigator with step indicator and quick nav buttons.
+///
+public class CornerNavigator : Border
+{
+ private readonly Label _stepIndicator;
+ private readonly Border _previousButton;
+ private readonly Border _nextButton;
+ private readonly Border _skipButton;
+
+ #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()
+ {
+ BackgroundColor = Color.FromArgb("#1C1C1E");
+ StrokeShape = new RoundRectangle { CornerRadius = 25 };
+ Stroke = Color.FromArgb("#3A3A3C");
+ 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("#3A3A3C")); // ❮
+ var prevTap = new TapGestureRecognizer();
+ prevTap.Tapped += (_, _) => { 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("#007AFF")); // ❯
+ var nextTap = new TapGestureRecognizer();
+ nextTap.Tapped += (_, _) => { if (_nextButton.Opacity > 0.5) NextClicked?.Invoke(this, EventArgs.Empty); };
+ _nextButton.GestureRecognizers.Add(nextTap);
+
+ // Done button - pill shaped
+ _skipButton = new Border
+ {
+ BackgroundColor = Color.FromArgb("#FF3B30"),
+ StrokeShape = new RoundRectangle { CornerRadius = 14 },
+ Stroke = Colors.Transparent,
+ Padding = new Thickness(14, 6),
+ Margin = new Thickness(8, 0, 0, 0),
+ Content = new Label
+ {
+ Text = "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);
+
+ var layout = new HorizontalStackLayout
+ {
+ Spacing = 4,
+ VerticalOptions = LayoutOptions.Center,
+ Children =
+ {
+ _previousButton,
+ _stepIndicator,
+ _nextButton,
+ _skipButton
+ }
+ };
+
+ Content = layout;
+ UpdateStepIndicator();
+ UpdatePlacement();
+ }
+
+ private Border CreateNavButton(string symbol, Color bgColor)
+ {
+ return new Border
+ {
+ BackgroundColor = bgColor,
+ StrokeShape = new RoundRectangle { CornerRadius = 18 },
+ Stroke = Colors.Transparent,
+ WidthRequest = 36,
+ HeightRequest = 36,
+ 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 ?? "Done";
+ }
+
+ private void UpdateStepIndicator()
+ {
+ _stepIndicator.Text = $"{CurrentStep + 1} / {TotalSteps}";
+
+ // Update button states
+ var canGoPrev = CurrentStep > 0;
+ _previousButton.Opacity = canGoPrev ? 1.0 : 0.3;
+
+ var canGoNext = CurrentStep < TotalSteps - 1;
+ _nextButton.Opacity = canGoNext ? 1.0 : 0.3;
+ }
+
+ private void UpdateNavigationButtonsVisibility(bool show)
+ {
+ _previousButton.IsVisible = show;
+ _nextButton.IsVisible = show;
+ }
+
+ private void UpdatePlacement()
+ {
+ 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:
+ default:
+ HorizontalOptions = LayoutOptions.End;
+ VerticalOptions = LayoutOptions.End;
+ Margin = new Thickness(0, 0, MarginFromEdge, MarginFromEdge);
+ break;
+ }
+ }
+
+ ///
+ /// Updates the navigator for a specific step.
+ ///
+ public void UpdateForStep(int currentIndex, int totalCount)
+ {
+ CurrentStep = currentIndex;
+ TotalSteps = totalCount;
+ }
+
+ ///
+ /// Applies light or dark theme to the corner navigator.
+ ///
+ 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;
+ }
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/Enums.cs b/MarketAlly.MASpotlightTour/Enums.cs
new file mode 100644
index 0000000..9c901d5
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/Enums.cs
@@ -0,0 +1,200 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// Specifies the visual theme for the tour components.
+///
+public enum TourTheme
+{
+ ///
+ /// Light theme with white backgrounds and dark text.
+ ///
+ Light,
+
+ ///
+ /// Dark theme with dark backgrounds and light text.
+ ///
+ Dark,
+
+ ///
+ /// Automatically follows the system theme.
+ ///
+ System
+}
+
+///
+/// Specifies where the callout card is positioned relative to the spotlight.
+///
+public enum CalloutPlacement
+{
+ ///
+ /// Automatically determine placement based on available space.
+ ///
+ Auto,
+
+ ///
+ /// Position callout above the spotlight.
+ ///
+ Top,
+
+ ///
+ /// Position callout below the spotlight.
+ ///
+ Bottom,
+
+ ///
+ /// Position callout to the left of the spotlight.
+ ///
+ Left,
+
+ ///
+ /// Position callout to the right of the spotlight.
+ ///
+ Right
+}
+
+///
+/// Specifies the shape of the spotlight cutout.
+///
+public enum SpotlightShape
+{
+ ///
+ /// Rectangular cutout matching the element bounds.
+ ///
+ Rectangle,
+
+ ///
+ /// Rounded rectangle cutout with configurable corner radius.
+ ///
+ RoundedRectangle,
+
+ ///
+ /// Circular cutout centered on the element.
+ ///
+ Circle
+}
+
+///
+/// Specifies behavior when the user taps on the spotlighted element.
+///
+public enum SpotlightTapBehavior
+{
+ ///
+ /// Tapping the spotlight does nothing; only nav buttons work.
+ ///
+ None,
+
+ ///
+ /// Tapping the spotlight advances to the next step.
+ ///
+ Advance,
+
+ ///
+ /// Tapping the spotlight closes the tour.
+ ///
+ Close,
+
+ ///
+ /// Allow interaction with the underlying element (pass-through).
+ ///
+ AllowInteraction
+}
+
+///
+/// Specifies the corner position for the navigator control.
+///
+public enum CornerNavigatorPlacement
+{
+ ///
+ /// Top-left corner of the screen.
+ ///
+ TopLeft,
+
+ ///
+ /// Top-right corner of the screen.
+ ///
+ TopRight,
+
+ ///
+ /// Bottom-left corner of the screen.
+ ///
+ BottomLeft,
+
+ ///
+ /// Bottom-right corner of the screen.
+ ///
+ BottomRight
+}
+
+///
+/// Specifies the positioning strategy for the callout card.
+///
+public enum CalloutPositionMode
+{
+ ///
+ /// Callout follows/positions relative to the highlighted element.
+ /// Uses CalloutPlacement to determine which side (Auto, Top, Bottom, Left, Right).
+ ///
+ Following,
+
+ ///
+ /// Callout is fixed in a specific screen corner.
+ /// Uses CalloutCorner to determine which corner.
+ ///
+ FixedCorner,
+
+ ///
+ /// Callout automatically positions in the screen corner that least
+ /// interferes with the highlighted element.
+ ///
+ AutoCorner
+}
+
+///
+/// Specifies which screen corner for fixed/auto corner positioning.
+///
+public enum CalloutCorner
+{
+ ///
+ /// Top-left corner of the screen.
+ ///
+ TopLeft,
+
+ ///
+ /// Top-right corner of the screen.
+ ///
+ TopRight,
+
+ ///
+ /// Bottom-left corner of the screen.
+ ///
+ BottomLeft,
+
+ ///
+ /// Bottom-right corner of the screen.
+ ///
+ BottomRight
+}
+
+///
+/// Specifies the display mode for the onboarding tour.
+///
+public enum TourDisplayMode
+{
+ ///
+ /// Full experience: dimmed overlay + spotlight cutout + callout card with nav buttons.
+ ///
+ SpotlightWithCallout,
+
+ ///
+ /// Callout cards only - no dimming, just floating callouts that point to elements.
+ /// Good for light-touch guidance without blocking interaction.
+ ///
+ CalloutOnly,
+
+ ///
+ /// Dimmed overlay with spotlight cutouts, but titles/descriptions shown as
+ /// inline labels positioned around the spotlight (no callout card).
+ /// Use corner navigator for navigation.
+ ///
+ SpotlightWithInlineLabel
+}
diff --git a/MarketAlly.MASpotlightTour/InlineLabel.cs b/MarketAlly.MASpotlightTour/InlineLabel.cs
new file mode 100644
index 0000000..a1bc084
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/InlineLabel.cs
@@ -0,0 +1,296 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// A simple label that displays title/description inline near the spotlight.
+/// Used in SpotlightWithInlineLabel mode.
+///
+public class InlineLabel : Border
+{
+ private readonly Label _titleLabel;
+ private readonly Label _descriptionLabel;
+
+ #region Bindable Properties
+
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.Create(nameof(Title), typeof(string), typeof(InlineLabel), null,
+ propertyChanged: (b, _, n) => ((InlineLabel)b)._titleLabel.Text = (string?)n);
+
+ public string? Title
+ {
+ get => (string?)GetValue(TitleProperty);
+ set => SetValue(TitleProperty, value);
+ }
+
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(InlineLabel), null,
+ propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.Text = (string?)n);
+
+ public string? Description
+ {
+ get => (string?)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public static readonly BindableProperty ShowDescriptionProperty =
+ BindableProperty.Create(nameof(ShowDescription), typeof(bool), typeof(InlineLabel), true,
+ propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.IsVisible = (bool)n);
+
+ public bool ShowDescription
+ {
+ get => (bool)GetValue(ShowDescriptionProperty);
+ set => SetValue(ShowDescriptionProperty, value);
+ }
+
+ public static readonly BindableProperty LabelBackgroundColorProperty =
+ BindableProperty.Create(nameof(LabelBackgroundColor), typeof(Color), typeof(InlineLabel), Colors.White,
+ propertyChanged: (b, _, n) => ((InlineLabel)b).BackgroundColor = (Color)n);
+
+ public Color LabelBackgroundColor
+ {
+ get => (Color)GetValue(LabelBackgroundColorProperty);
+ set => SetValue(LabelBackgroundColorProperty, value);
+ }
+
+ public static readonly BindableProperty TitleColorProperty =
+ BindableProperty.Create(nameof(TitleColor), typeof(Color), typeof(InlineLabel), Colors.Black,
+ propertyChanged: (b, _, n) => ((InlineLabel)b)._titleLabel.TextColor = (Color)n);
+
+ public Color TitleColor
+ {
+ get => (Color)GetValue(TitleColorProperty);
+ set => SetValue(TitleColorProperty, value);
+ }
+
+ public static readonly BindableProperty DescriptionColorProperty =
+ BindableProperty.Create(nameof(DescriptionColor), typeof(Color), typeof(InlineLabel), Colors.DarkGray,
+ propertyChanged: (b, _, n) => ((InlineLabel)b)._descriptionLabel.TextColor = (Color)n);
+
+ public Color DescriptionColor
+ {
+ get => (Color)GetValue(DescriptionColorProperty);
+ set => SetValue(DescriptionColorProperty, value);
+ }
+
+ #endregion
+
+ public InlineLabel()
+ {
+ BackgroundColor = Colors.White;
+ StrokeShape = new Microsoft.Maui.Controls.Shapes.RoundRectangle { CornerRadius = 8 };
+ Stroke = Colors.Transparent;
+ Padding = new Thickness(12, 8);
+ HorizontalOptions = LayoutOptions.Start;
+ VerticalOptions = LayoutOptions.Start;
+ MaximumWidthRequest = 300;
+
+ Shadow = new Shadow
+ {
+ Brush = new SolidColorBrush(Colors.Black),
+ Offset = new Point(0, 2),
+ Radius = 4,
+ Opacity = 0.25f
+ };
+
+ _titleLabel = new Label
+ {
+ FontAttributes = FontAttributes.Bold,
+ FontSize = 16,
+ TextColor = Colors.Black,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ _descriptionLabel = new Label
+ {
+ FontSize = 13,
+ TextColor = Colors.DarkGray,
+ LineBreakMode = LineBreakMode.WordWrap
+ };
+
+ Content = new VerticalStackLayout
+ {
+ Spacing = 4,
+ Children = { _titleLabel, _descriptionLabel }
+ };
+ }
+
+ ///
+ /// Updates the label content.
+ ///
+ public void SetContent(string? title, string? description)
+ {
+ Title = title;
+ Description = description;
+ _titleLabel.IsVisible = !string.IsNullOrWhiteSpace(title);
+ _descriptionLabel.IsVisible = ShowDescription && !string.IsNullOrWhiteSpace(description);
+ }
+
+ ///
+ /// Positions the label relative to a spotlight rectangle.
+ ///
+ public void PositionRelativeToSpotlight(
+ Rect spotlightRect,
+ Size containerSize,
+ CalloutPlacement placement,
+ double margin = 8)
+ {
+ var effectivePlacement = placement == CalloutPlacement.Auto
+ ? DetermineAutoPlacement(spotlightRect, containerSize, margin)
+ : placement;
+
+ double x, y;
+
+ switch (effectivePlacement)
+ {
+ case CalloutPlacement.Top:
+ x = Math.Max(margin, Math.Min(spotlightRect.Center.X - 100, containerSize.Width - 200 - margin));
+ y = Math.Max(margin, spotlightRect.Top - 60 - margin);
+ break;
+
+ case CalloutPlacement.Bottom:
+ x = Math.Max(margin, Math.Min(spotlightRect.Center.X - 100, containerSize.Width - 200 - margin));
+ y = Math.Min(containerSize.Height - 60 - margin, spotlightRect.Bottom + margin);
+ break;
+
+ case CalloutPlacement.Left:
+ x = Math.Max(margin, spotlightRect.Left - 200 - margin);
+ y = Math.Max(margin, Math.Min(spotlightRect.Center.Y - 30, containerSize.Height - 60 - margin));
+ break;
+
+ case CalloutPlacement.Right:
+ x = Math.Min(containerSize.Width - 200 - margin, spotlightRect.Right + margin);
+ y = Math.Max(margin, Math.Min(spotlightRect.Center.Y - 30, containerSize.Height - 60 - margin));
+ break;
+
+ default:
+ x = spotlightRect.Center.X - 100;
+ y = spotlightRect.Bottom + margin;
+ break;
+ }
+
+ TranslationX = x;
+ TranslationY = y;
+ }
+
+ private static CalloutPlacement DetermineAutoPlacement(Rect spotlightRect, Size containerSize, double margin)
+ {
+ var spaceBelow = containerSize.Height - spotlightRect.Bottom - margin;
+ var spaceAbove = spotlightRect.Top - margin;
+
+ if (spaceBelow >= 70)
+ return CalloutPlacement.Bottom;
+ if (spaceAbove >= 70)
+ return CalloutPlacement.Top;
+
+ return CalloutPlacement.Bottom;
+ }
+
+ ///
+ /// Positions the label in a specific screen corner.
+ ///
+ public void PositionInCorner(CalloutCorner corner, Size containerSize, double margin = 16)
+ {
+ var measured = Measure(MaximumWidthRequest, double.PositiveInfinity);
+ var labelWidth = Math.Min(measured.Width, MaximumWidthRequest);
+ var labelHeight = measured.Height;
+
+ double x, y;
+
+ switch (corner)
+ {
+ case CalloutCorner.TopLeft:
+ x = margin;
+ y = margin;
+ break;
+
+ case CalloutCorner.TopRight:
+ x = containerSize.Width - labelWidth - margin;
+ y = margin;
+ break;
+
+ case CalloutCorner.BottomLeft:
+ x = margin;
+ y = containerSize.Height - labelHeight - margin;
+ break;
+
+ case CalloutCorner.BottomRight:
+ default:
+ x = containerSize.Width - labelWidth - margin;
+ y = containerSize.Height - labelHeight - margin;
+ break;
+ }
+
+ TranslationX = x;
+ TranslationY = y;
+ }
+
+ ///
+ /// Determines the best corner that doesn't overlap with the spotlight area.
+ ///
+ public static CalloutCorner DetermineBestCorner(Rect spotlightRect, Size containerSize, double labelWidth, double labelHeight, double margin = 16)
+ {
+ var corners = new[]
+ {
+ CalloutCorner.TopLeft,
+ CalloutCorner.TopRight,
+ CalloutCorner.BottomLeft,
+ CalloutCorner.BottomRight
+ };
+
+ CalloutCorner bestCorner = CalloutCorner.BottomLeft;
+ double minOverlap = double.MaxValue;
+
+ foreach (var corner in corners)
+ {
+ var labelRect = GetCornerRect(corner, containerSize, labelWidth, labelHeight, margin);
+ var overlap = CalculateOverlap(labelRect, spotlightRect);
+
+ if (overlap < minOverlap)
+ {
+ minOverlap = overlap;
+ bestCorner = corner;
+ }
+ }
+
+ return bestCorner;
+ }
+
+ private static Rect GetCornerRect(CalloutCorner corner, Size containerSize, double width, double height, double margin)
+ {
+ return corner switch
+ {
+ CalloutCorner.TopLeft => new Rect(margin, margin, width, height),
+ CalloutCorner.TopRight => new Rect(containerSize.Width - width - margin, margin, width, height),
+ CalloutCorner.BottomLeft => new Rect(margin, containerSize.Height - height - margin, width, height),
+ CalloutCorner.BottomRight => new Rect(containerSize.Width - width - margin, containerSize.Height - height - margin, width, height),
+ _ => new Rect(margin, containerSize.Height - height - margin, width, height)
+ };
+ }
+
+ private static double CalculateOverlap(Rect rect1, Rect rect2)
+ {
+ var xOverlap = Math.Max(0, Math.Min(rect1.Right, rect2.Right) - Math.Max(rect1.Left, rect2.Left));
+ var yOverlap = Math.Max(0, Math.Min(rect1.Bottom, rect2.Bottom) - Math.Max(rect1.Top, rect2.Top));
+ return xOverlap * yOverlap;
+ }
+
+ ///
+ /// Applies light or dark theme to the inline label.
+ ///
+ public void ApplyTheme(bool isDark)
+ {
+ if (isDark)
+ {
+ LabelBackgroundColor = Color.FromArgb("#1C1C1E");
+ TitleColor = Colors.White;
+ DescriptionColor = Color.FromArgb("#ABABAB");
+ Stroke = Color.FromArgb("#3A3A3C");
+ }
+ else
+ {
+ LabelBackgroundColor = Colors.White;
+ TitleColor = Colors.Black;
+ DescriptionColor = Colors.DarkGray;
+ Stroke = Colors.Transparent;
+ }
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj b/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj
new file mode 100644
index 0000000..0d6cba1
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net9.0-android;net9.0-ios;net9.0-maccatalyst
+ $(TargetFrameworks);net9.0-windows10.0.19041.0
+
+
+ true
+ true
+ enable
+ enable
+ MarketAlly.MASpotlightTour
+ MarketAlly.MASpotlightTour
+
+ 15.0
+ 15.0
+ 21.0
+ 10.0.17763.0
+ 10.0.17763.0
+ 6.5
+
+
+
+
+
+
+
diff --git a/MarketAlly.MASpotlightTour/Onboarding.cs b/MarketAlly.MASpotlightTour/Onboarding.cs
new file mode 100644
index 0000000..4ba4c45
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/Onboarding.cs
@@ -0,0 +1,253 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// Provides attached properties for tagging UI elements as onboarding/tour steps.
+///
+public static class Onboarding
+{
+ #region StepKey
+
+ ///
+ /// Identifies the step with a unique key. Falls back to AutomationId if not set.
+ ///
+ public static readonly BindableProperty StepKeyProperty =
+ BindableProperty.CreateAttached(
+ "StepKey",
+ typeof(string),
+ typeof(Onboarding),
+ defaultValue: null);
+
+ public static string? GetStepKey(BindableObject view) =>
+ (string?)view.GetValue(StepKeyProperty);
+
+ public static void SetStepKey(BindableObject view, string? value) =>
+ view.SetValue(StepKeyProperty, value);
+
+ #endregion
+
+ #region Title
+
+ ///
+ /// The title text displayed in the callout for this step.
+ ///
+ public static readonly BindableProperty TitleProperty =
+ BindableProperty.CreateAttached(
+ "Title",
+ typeof(string),
+ typeof(Onboarding),
+ defaultValue: null);
+
+ public static string? GetTitle(BindableObject view) =>
+ (string?)view.GetValue(TitleProperty);
+
+ public static void SetTitle(BindableObject view, string? value) =>
+ view.SetValue(TitleProperty, value);
+
+ #endregion
+
+ #region Description
+
+ ///
+ /// The description text displayed in the callout for this step.
+ ///
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.CreateAttached(
+ "Description",
+ typeof(string),
+ typeof(Onboarding),
+ defaultValue: null);
+
+ public static string? GetDescription(BindableObject view) =>
+ (string?)view.GetValue(DescriptionProperty);
+
+ public static void SetDescription(BindableObject view, string? value) =>
+ view.SetValue(DescriptionProperty, value);
+
+ #endregion
+
+ #region Order
+
+ ///
+ /// The order/sequence number for this step within its group.
+ ///
+ public static readonly BindableProperty OrderProperty =
+ BindableProperty.CreateAttached(
+ "Order",
+ typeof(int),
+ typeof(Onboarding),
+ defaultValue: 0);
+
+ public static int GetOrder(BindableObject view) =>
+ (int)view.GetValue(OrderProperty);
+
+ public static void SetOrder(BindableObject view, int value) =>
+ view.SetValue(OrderProperty, value);
+
+ #endregion
+
+ #region Group
+
+ ///
+ /// The group name for organizing multiple tours on the same page.
+ ///
+ public static readonly BindableProperty GroupProperty =
+ BindableProperty.CreateAttached(
+ "Group",
+ typeof(string),
+ typeof(Onboarding),
+ defaultValue: null);
+
+ public static string? GetGroup(BindableObject view) =>
+ (string?)view.GetValue(GroupProperty);
+
+ public static void SetGroup(BindableObject view, string? value) =>
+ view.SetValue(GroupProperty, value);
+
+ #endregion
+
+ #region SpotlightEnabled
+
+ ///
+ /// Whether this element participates in spotlight cutouts.
+ ///
+ public static readonly BindableProperty SpotlightEnabledProperty =
+ BindableProperty.CreateAttached(
+ "SpotlightEnabled",
+ typeof(bool),
+ typeof(Onboarding),
+ defaultValue: true);
+
+ public static bool GetSpotlightEnabled(BindableObject view) =>
+ (bool)view.GetValue(SpotlightEnabledProperty);
+
+ public static void SetSpotlightEnabled(BindableObject view, bool value) =>
+ view.SetValue(SpotlightEnabledProperty, value);
+
+ #endregion
+
+ #region Placement
+
+ ///
+ /// Where to place the callout card relative to the spotlight.
+ ///
+ public static readonly BindableProperty PlacementProperty =
+ BindableProperty.CreateAttached(
+ "Placement",
+ typeof(CalloutPlacement),
+ typeof(Onboarding),
+ defaultValue: CalloutPlacement.Auto);
+
+ public static CalloutPlacement GetPlacement(BindableObject view) =>
+ (CalloutPlacement)view.GetValue(PlacementProperty);
+
+ public static void SetPlacement(BindableObject view, CalloutPlacement value) =>
+ view.SetValue(PlacementProperty, value);
+
+ #endregion
+
+ #region SpotlightShape
+
+ ///
+ /// The shape of the spotlight cutout for this step.
+ ///
+ public static readonly BindableProperty SpotlightShapeProperty =
+ BindableProperty.CreateAttached(
+ "SpotlightShape",
+ typeof(SpotlightShape),
+ typeof(Onboarding),
+ defaultValue: SpotlightShape.RoundedRectangle);
+
+ public static SpotlightShape GetSpotlightShape(BindableObject view) =>
+ (SpotlightShape)view.GetValue(SpotlightShapeProperty);
+
+ public static void SetSpotlightShape(BindableObject view, SpotlightShape value) =>
+ view.SetValue(SpotlightShapeProperty, value);
+
+ #endregion
+
+ #region SpotlightPadding
+
+ ///
+ /// Padding around the target element in the spotlight cutout.
+ ///
+ public static readonly BindableProperty SpotlightPaddingProperty =
+ BindableProperty.CreateAttached(
+ "SpotlightPadding",
+ typeof(Thickness),
+ typeof(Onboarding),
+ defaultValue: new Thickness(8));
+
+ public static Thickness GetSpotlightPadding(BindableObject view) =>
+ (Thickness)view.GetValue(SpotlightPaddingProperty);
+
+ public static void SetSpotlightPadding(BindableObject view, Thickness value) =>
+ view.SetValue(SpotlightPaddingProperty, value);
+
+ #endregion
+
+ #region SpotlightCornerRadius
+
+ ///
+ /// Corner radius for RoundedRectangle spotlight shape.
+ ///
+ public static readonly BindableProperty SpotlightCornerRadiusProperty =
+ BindableProperty.CreateAttached(
+ "SpotlightCornerRadius",
+ typeof(double),
+ typeof(Onboarding),
+ defaultValue: 8.0);
+
+ public static double GetSpotlightCornerRadius(BindableObject view) =>
+ (double)view.GetValue(SpotlightCornerRadiusProperty);
+
+ public static void SetSpotlightCornerRadius(BindableObject view, double value) =>
+ view.SetValue(SpotlightCornerRadiusProperty, value);
+
+ #endregion
+
+ #region TapBehavior
+
+ ///
+ /// Behavior when the user taps on the spotlighted element.
+ ///
+ public static readonly BindableProperty TapBehaviorProperty =
+ BindableProperty.CreateAttached(
+ "TapBehavior",
+ typeof(SpotlightTapBehavior),
+ typeof(Onboarding),
+ defaultValue: SpotlightTapBehavior.None);
+
+ public static SpotlightTapBehavior GetTapBehavior(BindableObject view) =>
+ (SpotlightTapBehavior)view.GetValue(TapBehaviorProperty);
+
+ public static void SetTapBehavior(BindableObject view, SpotlightTapBehavior value) =>
+ view.SetValue(TapBehaviorProperty, value);
+
+ #endregion
+
+ ///
+ /// Helper to get the effective step key, falling back to AutomationId.
+ ///
+ public static string? GetEffectiveStepKey(BindableObject view)
+ {
+ var stepKey = GetStepKey(view);
+ if (!string.IsNullOrWhiteSpace(stepKey))
+ return stepKey;
+
+ if (view is VisualElement ve && !string.IsNullOrWhiteSpace(ve.AutomationId))
+ return ve.AutomationId;
+
+ return null;
+ }
+
+ ///
+ /// Determines if an element is tagged as an onboarding step.
+ ///
+ public static bool IsOnboardingStep(BindableObject view)
+ {
+ // An element is considered a step if it has a StepKey, Title, or Description set
+ return !string.IsNullOrWhiteSpace(GetStepKey(view)) ||
+ !string.IsNullOrWhiteSpace(GetTitle(view)) ||
+ !string.IsNullOrWhiteSpace(GetDescription(view));
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/OnboardingHost.cs b/MarketAlly.MASpotlightTour/OnboardingHost.cs
new file mode 100644
index 0000000..0fca6dc
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/OnboardingHost.cs
@@ -0,0 +1,934 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// The main host control that orchestrates the onboarding tour experience.
+/// Add this as an overlay on your page to enable spotlight tours.
+///
+public class OnboardingHost : Grid
+{
+ private readonly SpotlightOverlay _spotlightOverlay;
+ private readonly CalloutCard _calloutCard;
+ private readonly CornerNavigator _cornerNavigator;
+ private readonly ArrowIndicator _arrowIndicator;
+ private readonly InlineLabel _inlineLabel;
+
+ private IReadOnlyList _steps = Array.Empty();
+ private int _currentIndex = -1;
+ private VisualElement? _root;
+ private bool _isRunning;
+ private CancellationTokenSource? _autoAdvanceCts;
+
+ #region Bindable Properties
+
+ ///
+ /// The display mode for the tour (SpotlightWithCallout, CalloutOnly, SpotlightWithInlineLabel).
+ ///
+ public static readonly BindableProperty DisplayModeProperty =
+ BindableProperty.Create(nameof(DisplayMode), typeof(TourDisplayMode), typeof(OnboardingHost),
+ TourDisplayMode.SpotlightWithCallout);
+
+ public TourDisplayMode DisplayMode
+ {
+ get => (TourDisplayMode)GetValue(DisplayModeProperty);
+ set => SetValue(DisplayModeProperty, value);
+ }
+
+ public static readonly BindableProperty ShowCornerNavigatorProperty =
+ BindableProperty.Create(nameof(ShowCornerNavigator), typeof(bool), typeof(OnboardingHost), true,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.IsVisible = (bool)n && ((OnboardingHost)b)._isRunning);
+
+ public bool ShowCornerNavigator
+ {
+ get => (bool)GetValue(ShowCornerNavigatorProperty);
+ set => SetValue(ShowCornerNavigatorProperty, value);
+ }
+
+ public static readonly BindableProperty CornerNavigatorPlacementProperty =
+ BindableProperty.Create(nameof(CornerNavigatorPlacement), typeof(CornerNavigatorPlacement), typeof(OnboardingHost),
+ CornerNavigatorPlacement.BottomRight,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.Placement = (CornerNavigatorPlacement)n);
+
+ public CornerNavigatorPlacement CornerNavigatorPlacement
+ {
+ get => (CornerNavigatorPlacement)GetValue(CornerNavigatorPlacementProperty);
+ set => SetValue(CornerNavigatorPlacementProperty, value);
+ }
+
+ public static readonly BindableProperty ShowSkipButtonProperty =
+ BindableProperty.Create(nameof(ShowSkipButton), typeof(bool), typeof(OnboardingHost), true,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._cornerNavigator.ShowSkipButton = (bool)n);
+
+ public bool ShowSkipButton
+ {
+ get => (bool)GetValue(ShowSkipButtonProperty);
+ set => SetValue(ShowSkipButtonProperty, value);
+ }
+
+ public static readonly BindableProperty ShowArrowProperty =
+ BindableProperty.Create(nameof(ShowArrow), typeof(bool), typeof(OnboardingHost), true,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._arrowIndicator.IsVisible = (bool)n && ((OnboardingHost)b)._isRunning);
+
+ public bool ShowArrow
+ {
+ get => (bool)GetValue(ShowArrowProperty);
+ set => SetValue(ShowArrowProperty, value);
+ }
+
+ public static readonly BindableProperty DimOpacityProperty =
+ BindableProperty.Create(nameof(DimOpacity), typeof(double), typeof(OnboardingHost), 0.6,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._spotlightOverlay.DimOpacity = (double)n);
+
+ public double DimOpacity
+ {
+ get => (double)GetValue(DimOpacityProperty);
+ set => SetValue(DimOpacityProperty, value);
+ }
+
+ public static readonly BindableProperty DimColorProperty =
+ BindableProperty.Create(nameof(DimColor), typeof(Color), typeof(OnboardingHost), Colors.Black,
+ propertyChanged: (b, _, n) => ((OnboardingHost)b)._spotlightOverlay.DimColor = (Color)n);
+
+ public Color DimColor
+ {
+ get => (Color)GetValue(DimColorProperty);
+ set => SetValue(DimColorProperty, value);
+ }
+
+ public static readonly BindableProperty AnimationDurationProperty =
+ BindableProperty.Create(nameof(AnimationDuration), typeof(uint), typeof(OnboardingHost), (uint)250);
+
+ public uint AnimationDuration
+ {
+ get => (uint)GetValue(AnimationDurationProperty);
+ set => SetValue(AnimationDurationProperty, value);
+ }
+
+ ///
+ /// The positioning strategy for the callout card (Following, FixedCorner, AutoCorner).
+ ///
+ public static readonly BindableProperty CalloutPositionModeProperty =
+ BindableProperty.Create(nameof(CalloutPositionMode), typeof(CalloutPositionMode), typeof(OnboardingHost),
+ CalloutPositionMode.Following);
+
+ public CalloutPositionMode CalloutPositionMode
+ {
+ get => (CalloutPositionMode)GetValue(CalloutPositionModeProperty);
+ set => SetValue(CalloutPositionModeProperty, value);
+ }
+
+ ///
+ /// The corner to use when CalloutPositionMode is FixedCorner.
+ ///
+ public static readonly BindableProperty CalloutCornerProperty =
+ BindableProperty.Create(nameof(CalloutCorner), typeof(CalloutCorner), typeof(OnboardingHost),
+ CalloutCorner.BottomLeft);
+
+ public CalloutCorner CalloutCorner
+ {
+ get => (CalloutCorner)GetValue(CalloutCornerProperty);
+ set => SetValue(CalloutCornerProperty, value);
+ }
+
+ ///
+ /// Margin from screen edge when using corner positioning (in device-independent units).
+ ///
+ public static readonly BindableProperty CalloutCornerMarginProperty =
+ BindableProperty.Create(nameof(CalloutCornerMargin), typeof(double), typeof(OnboardingHost), 16.0);
+
+ public double CalloutCornerMargin
+ {
+ get => (double)GetValue(CalloutCornerMarginProperty);
+ set => SetValue(CalloutCornerMarginProperty, value);
+ }
+
+ ///
+ /// The visual theme for the tour (Light, Dark, or System).
+ ///
+ public static readonly BindableProperty ThemeProperty =
+ BindableProperty.Create(nameof(Theme), typeof(TourTheme), typeof(OnboardingHost),
+ TourTheme.Light,
+ propertyChanged: (b, _, _) => ((OnboardingHost)b).ApplyTheme());
+
+ public TourTheme Theme
+ {
+ get => (TourTheme)GetValue(ThemeProperty);
+ set => SetValue(ThemeProperty, value);
+ }
+
+ public static readonly BindableProperty AnimationsEnabledProperty =
+ BindableProperty.Create(nameof(AnimationsEnabled), typeof(bool), typeof(OnboardingHost), true);
+
+ public bool AnimationsEnabled
+ {
+ get => (bool)GetValue(AnimationsEnabledProperty);
+ set => SetValue(AnimationsEnabledProperty, value);
+ }
+
+ ///
+ /// Optional delay in milliseconds before auto-starting the tour.
+ /// Set to 0 or negative to disable auto-start (default).
+ ///
+ public static readonly BindableProperty AutoStartDelayProperty =
+ BindableProperty.Create(nameof(AutoStartDelay), typeof(int), typeof(OnboardingHost), 0);
+
+ public int AutoStartDelay
+ {
+ get => (int)GetValue(AutoStartDelayProperty);
+ set => SetValue(AutoStartDelayProperty, value);
+ }
+
+ ///
+ /// The group to use for auto-start. If null, all steps are included.
+ ///
+ public static readonly BindableProperty AutoStartGroupProperty =
+ BindableProperty.Create(nameof(AutoStartGroup), typeof(string), typeof(OnboardingHost), null);
+
+ public string? AutoStartGroup
+ {
+ get => (string?)GetValue(AutoStartGroupProperty);
+ set => SetValue(AutoStartGroupProperty, value);
+ }
+
+ ///
+ /// Optional delay in milliseconds before auto-advancing to the next step.
+ /// Set to 0 or negative to disable auto-advance (default).
+ /// When enabled, the tour automatically moves to the next step after this delay.
+ ///
+ public static readonly BindableProperty AutoAdvanceDelayProperty =
+ BindableProperty.Create(nameof(AutoAdvanceDelay), typeof(int), typeof(OnboardingHost), 0);
+
+ public int AutoAdvanceDelay
+ {
+ get => (int)GetValue(AutoAdvanceDelayProperty);
+ set => SetValue(AutoAdvanceDelayProperty, value);
+ }
+
+ #endregion
+
+ #region Events
+
+ ///
+ /// Raised when the tour starts.
+ ///
+ public event EventHandler? TourStarted;
+
+ ///
+ /// Raised when the tour completes (user reaches the end).
+ ///
+ public event EventHandler? TourCompleted;
+
+ ///
+ /// Raised when the tour is skipped.
+ ///
+ public event EventHandler? TourSkipped;
+
+ ///
+ /// Raised when moving to a new step.
+ ///
+ public event EventHandler? StepChanged;
+
+ ///
+ /// Raised when the tour ends (either completed or skipped).
+ ///
+ public event EventHandler? TourEnded;
+
+ #endregion
+
+ #region Read-only Properties
+
+ ///
+ /// Gets whether a tour is currently running.
+ ///
+ public bool IsRunning => _isRunning;
+
+ ///
+ /// Gets the current step index (0-based).
+ ///
+ public int CurrentStepIndex => _currentIndex;
+
+ ///
+ /// Gets the total number of steps.
+ ///
+ public int TotalSteps => _steps.Count;
+
+ ///
+ /// Gets the current step, or null if no tour is running.
+ ///
+ public OnboardingStep? CurrentStep => _currentIndex >= 0 && _currentIndex < _steps.Count
+ ? _steps[_currentIndex]
+ : null;
+
+ #endregion
+
+ public OnboardingHost()
+ {
+ IsVisible = false;
+ InputTransparent = false;
+ BackgroundColor = Colors.Transparent;
+ HorizontalOptions = LayoutOptions.Fill;
+ VerticalOptions = LayoutOptions.Fill;
+
+ _spotlightOverlay = new SpotlightOverlay
+ {
+ IsVisible = false,
+ HorizontalOptions = LayoutOptions.Fill,
+ VerticalOptions = LayoutOptions.Fill
+ };
+ _calloutCard = new CalloutCard
+ {
+ IsVisible = false,
+ HorizontalOptions = LayoutOptions.Start,
+ VerticalOptions = LayoutOptions.Start
+ };
+ _arrowIndicator = new ArrowIndicator
+ {
+ IsVisible = false,
+ HorizontalOptions = LayoutOptions.Start,
+ VerticalOptions = LayoutOptions.Start
+ };
+ _inlineLabel = new InlineLabel
+ {
+ IsVisible = false,
+ HorizontalOptions = LayoutOptions.Start,
+ VerticalOptions = LayoutOptions.Start
+ };
+ _cornerNavigator = new CornerNavigator { IsVisible = false };
+
+ // Wire up events
+ _calloutCard.PreviousClicked += (_, _) => GoToPreviousStep();
+ _calloutCard.NextClicked += (_, _) => GoToNextStep();
+ _calloutCard.CloseClicked += (_, _) => CompleteTour();
+
+ _cornerNavigator.PreviousClicked += (_, _) => GoToPreviousStep();
+ _cornerNavigator.NextClicked += (_, _) => GoToNextStep();
+ _cornerNavigator.SkipClicked += (_, _) => SkipTour();
+
+ _spotlightOverlay.SpotlightTapped += OnSpotlightTapped;
+ _spotlightOverlay.DimmedAreaTapped += OnDimmedAreaTapped;
+
+ // Layer order: spotlight overlay (bottom), arrow, inline label, callout card, corner navigator (top)
+ Children.Add(_spotlightOverlay);
+ Children.Add(_arrowIndicator);
+ Children.Add(_inlineLabel);
+ Children.Add(_calloutCard);
+ Children.Add(_cornerNavigator);
+
+ // Handle auto-start when added to visual tree
+ Loaded += OnLoaded;
+ }
+
+ private async void OnLoaded(object? sender, EventArgs e)
+ {
+ Loaded -= OnLoaded;
+
+ if (AutoStartDelay > 0)
+ {
+ await Task.Delay(AutoStartDelay);
+ await AutoStartAsync();
+ }
+ }
+
+ private async Task AutoStartAsync()
+ {
+ if (_isRunning)
+ return;
+
+ // Find the root - walk up to find Page or use parent
+ var root = FindAutoStartRoot();
+ if (root == null)
+ return;
+
+ await StartTourAsync(root, AutoStartGroup);
+ }
+
+ private VisualElement? FindAutoStartRoot()
+ {
+ Element? current = Parent;
+ VisualElement? lastVisual = null;
+
+ while (current != null)
+ {
+ if (current is ContentPage contentPage)
+ return contentPage.Content as VisualElement ?? contentPage;
+
+ if (current is VisualElement ve)
+ lastVisual = ve;
+
+ current = current.Parent;
+ }
+
+ return lastVisual;
+ }
+
+ ///
+ /// Stops any currently running tour.
+ ///
+ public void StopTour()
+ {
+ if (_isRunning)
+ EndTour();
+ }
+
+ ///
+ /// Starts the onboarding tour by scanning for tagged elements.
+ ///
+ /// The root element to scan (typically the Page or a layout).
+ /// Optional group filter.
+ public async Task StartTourAsync(VisualElement root, string? group = null)
+ {
+ // Stop any existing tour first
+ if (_isRunning)
+ EndTour();
+
+ _root = root;
+ _steps = OnboardingScanner.FindSteps(root, group);
+
+ if (_steps.Count == 0)
+ return;
+
+ _isRunning = true;
+ _currentIndex = -1;
+ _cornerNavigator.TotalSteps = _steps.Count;
+
+ // Apply current theme to all components
+ ApplyTheme();
+
+ // Ensure layout is complete before positioning
+ await WaitForLayoutAsync();
+
+ TourStarted?.Invoke(this, EventArgs.Empty);
+
+ // Position first step before showing anything
+ await GoToStepAsync(0);
+
+ // Now show everything based on display mode
+ ShowElementsForDisplayMode();
+ IsVisible = true;
+ }
+
+ private void ShowElementsForDisplayMode()
+ {
+ // Hide callout card buttons when corner navigator is visible (avoid duplicate nav)
+ _calloutCard.ShowNavigationButtons = !ShowCornerNavigator;
+
+ switch (DisplayMode)
+ {
+ case TourDisplayMode.SpotlightWithCallout:
+ _spotlightOverlay.IsVisible = true;
+ _calloutCard.IsVisible = true;
+ _inlineLabel.IsVisible = false;
+ _cornerNavigator.IsVisible = ShowCornerNavigator;
+ InputTransparent = false;
+ break;
+
+ case TourDisplayMode.CalloutOnly:
+ _spotlightOverlay.IsVisible = false;
+ _calloutCard.IsVisible = true;
+ _inlineLabel.IsVisible = false;
+ _cornerNavigator.IsVisible = ShowCornerNavigator;
+ // Allow interaction with underlying elements in callout-only mode
+ InputTransparent = true;
+ _calloutCard.InputTransparent = false;
+ _cornerNavigator.InputTransparent = false;
+ break;
+
+ case TourDisplayMode.SpotlightWithInlineLabel:
+ _spotlightOverlay.IsVisible = true;
+ _calloutCard.IsVisible = false;
+ _inlineLabel.IsVisible = true;
+ _cornerNavigator.IsVisible = true; // Always show nav in this mode
+ InputTransparent = false;
+ break;
+ }
+ }
+
+ private async Task WaitForLayoutAsync()
+ {
+ // If already laid out, skip waiting
+ if (Width > 0 && Height > 0)
+ return;
+
+ // Make host temporarily visible to trigger layout
+ IsVisible = true;
+
+ // Wait for layout pass
+ var tcs = new TaskCompletionSource();
+
+ void OnSizeChanged(object? sender, EventArgs e)
+ {
+ if (Width > 0 && Height > 0)
+ {
+ SizeChanged -= OnSizeChanged;
+ tcs.TrySetResult(true);
+ }
+ }
+
+ SizeChanged += OnSizeChanged;
+
+ // Timeout after 500ms
+ var timeoutTask = Task.Delay(500);
+ await Task.WhenAny(tcs.Task, timeoutTask);
+
+ SizeChanged -= OnSizeChanged;
+ IsVisible = false;
+ }
+
+ ///
+ /// Starts the tour from a specific step key.
+ ///
+ public async Task StartTourFromStepAsync(VisualElement root, string stepKey, string? group = null)
+ {
+ // Stop any existing tour first
+ if (_isRunning)
+ EndTour();
+
+ _root = root;
+ _steps = OnboardingScanner.FindSteps(root, group);
+
+ if (_steps.Count == 0)
+ return;
+
+ var startIndex = _steps
+ .Select((s, i) => new { Step = s, Index = i })
+ .FirstOrDefault(x => string.Equals(x.Step.StepKey, stepKey, StringComparison.OrdinalIgnoreCase))
+ ?.Index ?? 0;
+
+ _isRunning = true;
+ _currentIndex = -1;
+ _cornerNavigator.TotalSteps = _steps.Count;
+
+ // Apply current theme to all components
+ ApplyTheme();
+
+ // Ensure layout is complete before positioning
+ await WaitForLayoutAsync();
+
+ TourStarted?.Invoke(this, EventArgs.Empty);
+
+ // Position first step before showing anything
+ await GoToStepAsync(startIndex);
+
+ // Now show everything based on display mode
+ ShowElementsForDisplayMode();
+ IsVisible = true;
+ }
+
+ ///
+ /// Navigates to a specific step by index.
+ ///
+ public async Task GoToStepAsync(int index)
+ {
+ if (!_isRunning || index < 0 || index >= _steps.Count || _root == null)
+ return;
+
+ var previousIndex = _currentIndex;
+ _currentIndex = index;
+ var step = _steps[index];
+
+ // Ensure element is visible (scroll if needed)
+ await step.Target.EnsureVisibleAsync(AnimationsEnabled);
+
+ // Brief delay to allow scroll to settle (only if we scrolled)
+ if (AnimationsEnabled)
+ await Task.Delay(20);
+
+ // Calculate bounds
+ var bounds = step.Target.GetAbsoluteBoundsWithPadding(_root, step.SpotlightPadding);
+
+ // Determine effective placement
+ var placement = step.Placement;
+ if (placement == CalloutPlacement.Auto)
+ {
+ placement = DetermineAutoPlacement(bounds);
+ }
+
+ var isFirst = index == 0;
+ var isLast = index == _steps.Count - 1;
+
+ // Handle different display modes
+ switch (DisplayMode)
+ {
+ case TourDisplayMode.SpotlightWithCallout:
+ await UpdateSpotlightWithCallout(step, bounds, placement, isFirst, isLast, previousIndex);
+ break;
+
+ case TourDisplayMode.CalloutOnly:
+ await UpdateCalloutOnly(step, bounds, placement, isFirst, isLast);
+ break;
+
+ case TourDisplayMode.SpotlightWithInlineLabel:
+ await UpdateSpotlightWithInlineLabel(step, bounds, placement, previousIndex);
+ break;
+ }
+
+ // Update corner navigator
+ _cornerNavigator.UpdateForStep(index, _steps.Count);
+
+ // Raise event
+ StepChanged?.Invoke(this, new OnboardingStepEventArgs(step, index, _steps.Count));
+
+ // Start auto-advance timer if enabled
+ StartAutoAdvanceTimer();
+ }
+
+ private void StartAutoAdvanceTimer()
+ {
+ // Cancel any existing timer
+ CancelAutoAdvanceTimer();
+
+ if (AutoAdvanceDelay <= 0 || !_isRunning)
+ return;
+
+ _autoAdvanceCts = new CancellationTokenSource();
+ var token = _autoAdvanceCts.Token;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(AutoAdvanceDelay, token);
+
+ if (!token.IsCancellationRequested && _isRunning)
+ {
+ // Must dispatch to UI thread
+ MainThread.BeginInvokeOnMainThread(() =>
+ {
+ if (_isRunning)
+ GoToNextStep();
+ });
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ // Timer was cancelled, ignore
+ }
+ }, token);
+ }
+
+ private void CancelAutoAdvanceTimer()
+ {
+ _autoAdvanceCts?.Cancel();
+ _autoAdvanceCts?.Dispose();
+ _autoAdvanceCts = null;
+ }
+
+ private async Task UpdateSpotlightWithCallout(OnboardingStep step, Rect bounds, CalloutPlacement placement, bool isFirst, bool isLast, int previousIndex)
+ {
+ // Update spotlight
+ if (AnimationsEnabled && previousIndex >= 0)
+ {
+ await _spotlightOverlay.AnimateToAsync(bounds, step.SpotlightShape, step.SpotlightCornerRadius, AnimationDuration);
+ }
+ else
+ {
+ _spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
+ }
+
+ // Update callout card content
+ _calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
+
+ // Position callout based on CalloutPositionMode
+ PositionCalloutCard(bounds, placement);
+
+ // Position arrow (only useful in Following mode)
+ if (ShowArrow && CalloutPositionMode == CalloutPositionMode.Following)
+ {
+ _arrowIndicator.IsVisible = true;
+ _arrowIndicator.ArrowColor = _calloutCard.CardBackgroundColor;
+
+ var calloutBounds = new Rect(
+ _calloutCard.TranslationX,
+ _calloutCard.TranslationY,
+ _calloutCard.Width > 0 ? _calloutCard.Width : _calloutCard.WidthRequest,
+ _calloutCard.Height > 0 ? _calloutCard.Height : 100);
+
+ _arrowIndicator.PositionBetween(calloutBounds, bounds, placement);
+ }
+ else
+ {
+ // Hide arrow in corner modes
+ _arrowIndicator.IsVisible = false;
+ }
+
+ // Hide inline label in this mode
+ _inlineLabel.IsVisible = false;
+ }
+
+ private void PositionCalloutCard(Rect spotlightBounds, CalloutPlacement placement)
+ {
+ var containerSize = new Size(Width, Height);
+
+ switch (CalloutPositionMode)
+ {
+ case CalloutPositionMode.Following:
+ _calloutCard.PositionRelativeToSpotlight(spotlightBounds, containerSize, placement);
+ break;
+
+ case CalloutPositionMode.FixedCorner:
+ _calloutCard.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin);
+ break;
+
+ case CalloutPositionMode.AutoCorner:
+ var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
+ var measured = _calloutCard.Measure(cardWidth, double.PositiveInfinity);
+ var bestCorner = CalloutCard.DetermineBestCorner(
+ spotlightBounds, containerSize, cardWidth, measured.Height, CalloutCornerMargin);
+ _calloutCard.PositionInCorner(bestCorner, containerSize, CalloutCornerMargin);
+ break;
+ }
+ }
+
+ private async Task UpdateCalloutOnly(OnboardingStep step, Rect bounds, CalloutPlacement placement, bool isFirst, bool isLast)
+ {
+ // No spotlight/dimming in this mode
+ _spotlightOverlay.ClearSpotlight();
+
+ // Update callout card content
+ _calloutCard.UpdateForStep(step.Title, step.Description, isFirst, isLast);
+
+ // Position callout based on CalloutPositionMode
+ PositionCalloutCard(bounds, placement);
+
+ // Position arrow pointing to target (only in Following mode)
+ if (ShowArrow && CalloutPositionMode == CalloutPositionMode.Following)
+ {
+ _arrowIndicator.IsVisible = true;
+ _arrowIndicator.ArrowColor = _calloutCard.CardBackgroundColor;
+
+ var calloutBounds = new Rect(
+ _calloutCard.TranslationX,
+ _calloutCard.TranslationY,
+ _calloutCard.Width > 0 ? _calloutCard.Width : _calloutCard.WidthRequest,
+ _calloutCard.Height > 0 ? _calloutCard.Height : 100);
+
+ _arrowIndicator.PositionBetween(calloutBounds, bounds, placement);
+ }
+ else
+ {
+ // Hide arrow in corner modes
+ _arrowIndicator.IsVisible = false;
+ }
+
+ // Hide inline label in this mode
+ _inlineLabel.IsVisible = false;
+
+ await Task.CompletedTask;
+ }
+
+ private async Task UpdateSpotlightWithInlineLabel(OnboardingStep step, Rect bounds, CalloutPlacement placement, int previousIndex)
+ {
+ // Update spotlight
+ if (AnimationsEnabled && previousIndex >= 0)
+ {
+ await _spotlightOverlay.AnimateToAsync(bounds, step.SpotlightShape, step.SpotlightCornerRadius, AnimationDuration);
+ }
+ else
+ {
+ _spotlightOverlay.SetSpotlight(bounds, step.SpotlightShape, step.SpotlightCornerRadius);
+ }
+
+ // Hide callout card, show inline label
+ _calloutCard.IsVisible = false;
+ _arrowIndicator.IsVisible = false;
+
+ _inlineLabel.SetContent(step.Title, step.Description);
+
+ // Position inline label based on CalloutPositionMode
+ PositionInlineLabel(bounds, placement);
+
+ _inlineLabel.IsVisible = true;
+ }
+
+ private void PositionInlineLabel(Rect spotlightBounds, CalloutPlacement placement)
+ {
+ var containerSize = new Size(Width, Height);
+
+ switch (CalloutPositionMode)
+ {
+ case CalloutPositionMode.Following:
+ _inlineLabel.PositionRelativeToSpotlight(spotlightBounds, containerSize, placement);
+ break;
+
+ case CalloutPositionMode.FixedCorner:
+ _inlineLabel.PositionInCorner(CalloutCorner, containerSize, CalloutCornerMargin);
+ break;
+
+ case CalloutPositionMode.AutoCorner:
+ var measured = _inlineLabel.Measure(_inlineLabel.MaximumWidthRequest, double.PositiveInfinity);
+ var bestCorner = InlineLabel.DetermineBestCorner(
+ spotlightBounds, containerSize, measured.Width, measured.Height, CalloutCornerMargin);
+ _inlineLabel.PositionInCorner(bestCorner, containerSize, CalloutCornerMargin);
+ break;
+ }
+ }
+
+ ///
+ /// Advances to the next step.
+ ///
+ public async void GoToNextStep()
+ {
+ if (_currentIndex < _steps.Count - 1)
+ {
+ await GoToStepAsync(_currentIndex + 1);
+ }
+ else
+ {
+ CompleteTour();
+ }
+ }
+
+ ///
+ /// Returns to the previous step.
+ ///
+ public async void GoToPreviousStep()
+ {
+ if (_currentIndex > 0)
+ {
+ await GoToStepAsync(_currentIndex - 1);
+ }
+ }
+
+ ///
+ /// Completes the tour (user finished all steps).
+ ///
+ public void CompleteTour()
+ {
+ if (!_isRunning)
+ return;
+
+ EndTour();
+ TourCompleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Skips/cancels the tour.
+ ///
+ public void SkipTour()
+ {
+ if (!_isRunning)
+ return;
+
+ EndTour();
+ TourSkipped?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void EndTour()
+ {
+ CancelAutoAdvanceTimer();
+
+ _isRunning = false;
+ _currentIndex = -1;
+ _steps = Array.Empty();
+ _root = null;
+
+ IsVisible = false;
+ InputTransparent = false;
+ _spotlightOverlay.IsVisible = false;
+ _calloutCard.IsVisible = false;
+ _inlineLabel.IsVisible = false;
+ _cornerNavigator.IsVisible = false;
+ _arrowIndicator.IsVisible = false;
+ _spotlightOverlay.ClearSpotlight();
+
+ TourEnded?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void OnSpotlightTapped(object? sender, EventArgs e)
+ {
+ var step = CurrentStep;
+ if (step == null)
+ return;
+
+ switch (step.TapBehavior)
+ {
+ case SpotlightTapBehavior.Advance:
+ GoToNextStep();
+ break;
+
+ case SpotlightTapBehavior.Close:
+ SkipTour();
+ break;
+
+ case SpotlightTapBehavior.AllowInteraction:
+ // TODO: Pass through the tap to the underlying element
+ break;
+
+ case SpotlightTapBehavior.None:
+ default:
+ // Do nothing
+ break;
+ }
+ }
+
+ private void OnDimmedAreaTapped(object? sender, EventArgs e)
+ {
+ // Optionally advance or do nothing when tapping outside
+ // Currently does nothing - could be made configurable
+ }
+
+ private CalloutPlacement DetermineAutoPlacement(Rect spotlightRect)
+ {
+ var containerHeight = Height;
+ var containerWidth = Width;
+ var margin = 12.0;
+ var estimatedCardHeight = 150.0;
+
+ var spaceBelow = containerHeight - spotlightRect.Bottom - margin;
+ var spaceAbove = spotlightRect.Top - margin;
+ var spaceRight = containerWidth - spotlightRect.Right - margin;
+ var spaceLeft = spotlightRect.Left - margin;
+
+ if (spaceBelow >= estimatedCardHeight)
+ return CalloutPlacement.Bottom;
+ if (spaceAbove >= estimatedCardHeight)
+ return CalloutPlacement.Top;
+ if (spaceRight >= 200)
+ return CalloutPlacement.Right;
+ if (spaceLeft >= 200)
+ return CalloutPlacement.Left;
+
+ return CalloutPlacement.Bottom;
+ }
+
+ private void ApplyTheme()
+ {
+ var isDark = GetEffectiveTheme() == TourTheme.Dark;
+
+ // Apply theme to callout card
+ _calloutCard.ApplyTheme(isDark);
+
+ // Apply theme to corner navigator
+ _cornerNavigator.ApplyTheme(isDark);
+
+ // Apply theme to inline label
+ _inlineLabel.ApplyTheme(isDark);
+ }
+
+ private TourTheme GetEffectiveTheme()
+ {
+ if (Theme == TourTheme.System)
+ {
+ return Application.Current?.RequestedTheme == AppTheme.Dark
+ ? TourTheme.Dark
+ : TourTheme.Light;
+ }
+ return Theme;
+ }
+}
+
+///
+/// Event args for step change events.
+///
+public class OnboardingStepEventArgs : EventArgs
+{
+ public OnboardingStep Step { get; }
+ public int StepIndex { get; }
+ public int TotalSteps { get; }
+
+ public OnboardingStepEventArgs(OnboardingStep step, int stepIndex, int totalSteps)
+ {
+ Step = step;
+ StepIndex = stepIndex;
+ TotalSteps = totalSteps;
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/OnboardingScanner.cs b/MarketAlly.MASpotlightTour/OnboardingScanner.cs
new file mode 100644
index 0000000..c80ca0b
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/OnboardingScanner.cs
@@ -0,0 +1,89 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// Scans the visual tree to find elements tagged with onboarding properties.
+///
+public static class OnboardingScanner
+{
+ ///
+ /// Finds all onboarding steps within the given root element.
+ ///
+ /// The root element to scan.
+ /// Optional group filter. If null, returns all steps.
+ /// Ordered list of onboarding steps.
+ public static IReadOnlyList FindSteps(Element root, string? group = null)
+ {
+ var steps = new List();
+
+ root.TraverseVisualTree(ve =>
+ {
+ if (!Onboarding.IsOnboardingStep(ve))
+ return;
+
+ var step = OnboardingStep.FromElement(ve);
+
+ // Filter by group if specified
+ if (group != null && !string.Equals(step.Group, group, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ steps.Add(step);
+ });
+
+ // Sort by Order, then by visual tree position (implicit from traversal order)
+ return steps
+ .OrderBy(s => s.Order)
+ .ThenBy(s => steps.IndexOf(s))
+ .ToList()
+ .AsReadOnly();
+ }
+
+ ///
+ /// Finds a specific step by its key.
+ ///
+ public static OnboardingStep? FindStepByKey(Element root, string stepKey)
+ {
+ OnboardingStep? found = null;
+
+ root.TraverseVisualTree(ve =>
+ {
+ if (found != null)
+ return;
+
+ var key = Onboarding.GetEffectiveStepKey(ve);
+ if (string.Equals(key, stepKey, StringComparison.OrdinalIgnoreCase))
+ {
+ found = OnboardingStep.FromElement(ve);
+ }
+ });
+
+ return found;
+ }
+
+ ///
+ /// Gets all unique group names found in the visual tree.
+ ///
+ public static IReadOnlyList GetGroups(Element root)
+ {
+ var groups = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ root.TraverseVisualTree(ve =>
+ {
+ if (!Onboarding.IsOnboardingStep(ve))
+ return;
+
+ var group = Onboarding.GetGroup(ve);
+ if (!string.IsNullOrWhiteSpace(group))
+ groups.Add(group);
+ });
+
+ return groups.OrderBy(g => g).ToList().AsReadOnly();
+ }
+
+ ///
+ /// Counts the total number of steps, optionally filtered by group.
+ ///
+ public static int CountSteps(Element root, string? group = null)
+ {
+ return FindSteps(root, group).Count;
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/OnboardingStep.cs b/MarketAlly.MASpotlightTour/OnboardingStep.cs
new file mode 100644
index 0000000..26aa998
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/OnboardingStep.cs
@@ -0,0 +1,89 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// Represents a single step in an onboarding tour.
+///
+public class OnboardingStep
+{
+ ///
+ /// The target visual element for this step.
+ ///
+ public required VisualElement Target { get; init; }
+
+ ///
+ /// Unique key identifying this step.
+ ///
+ public string? StepKey { get; init; }
+
+ ///
+ /// Title text displayed in the callout.
+ ///
+ public string? Title { get; init; }
+
+ ///
+ /// Description text displayed in the callout.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Order/sequence number within the group.
+ ///
+ public int Order { get; init; }
+
+ ///
+ /// Group name for organizing multiple tours.
+ ///
+ public string? Group { get; init; }
+
+ ///
+ /// Whether spotlight cutout is enabled for this step.
+ ///
+ public bool SpotlightEnabled { get; init; } = true;
+
+ ///
+ /// Callout placement relative to the spotlight.
+ ///
+ public CalloutPlacement Placement { get; init; } = CalloutPlacement.Auto;
+
+ ///
+ /// Shape of the spotlight cutout.
+ ///
+ public SpotlightShape SpotlightShape { get; init; } = SpotlightShape.RoundedRectangle;
+
+ ///
+ /// Padding around the target in the spotlight cutout.
+ ///
+ public Thickness SpotlightPadding { get; init; } = new(8);
+
+ ///
+ /// Corner radius for RoundedRectangle shape.
+ ///
+ public double SpotlightCornerRadius { get; init; } = 8.0;
+
+ ///
+ /// Behavior when user taps the spotlight.
+ ///
+ public SpotlightTapBehavior TapBehavior { get; init; } = SpotlightTapBehavior.None;
+
+ ///
+ /// Creates an OnboardingStep from a tagged VisualElement.
+ ///
+ public static OnboardingStep FromElement(VisualElement element)
+ {
+ return new OnboardingStep
+ {
+ Target = element,
+ StepKey = Onboarding.GetEffectiveStepKey(element),
+ Title = Onboarding.GetTitle(element),
+ Description = Onboarding.GetDescription(element),
+ Order = Onboarding.GetOrder(element),
+ Group = Onboarding.GetGroup(element),
+ SpotlightEnabled = Onboarding.GetSpotlightEnabled(element),
+ Placement = Onboarding.GetPlacement(element),
+ SpotlightShape = Onboarding.GetSpotlightShape(element),
+ SpotlightPadding = Onboarding.GetSpotlightPadding(element),
+ SpotlightCornerRadius = Onboarding.GetSpotlightCornerRadius(element),
+ TapBehavior = Onboarding.GetTapBehavior(element)
+ };
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/SpotlightOverlay.cs b/MarketAlly.MASpotlightTour/SpotlightOverlay.cs
new file mode 100644
index 0000000..b1a686b
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/SpotlightOverlay.cs
@@ -0,0 +1,293 @@
+using Microsoft.Maui.Controls.Shapes;
+using Path = Microsoft.Maui.Controls.Shapes.Path;
+
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// A visual overlay that dims the screen with a spotlight cutout.
+///
+public class SpotlightOverlay : Grid
+{
+ private readonly Path _dimPath;
+ private Rect _spotlightRect;
+ private SpotlightShape _spotlightShape = SpotlightShape.RoundedRectangle;
+ private double _cornerRadius = 8.0;
+
+ ///
+ /// The opacity of the dimmed area (0-1).
+ ///
+ public static readonly BindableProperty DimOpacityProperty =
+ BindableProperty.Create(
+ nameof(DimOpacity),
+ typeof(double),
+ typeof(SpotlightOverlay),
+ 0.6,
+ propertyChanged: (b, _, _) => ((SpotlightOverlay)b).UpdatePath());
+
+ public double DimOpacity
+ {
+ get => (double)GetValue(DimOpacityProperty);
+ set => SetValue(DimOpacityProperty, value);
+ }
+
+ ///
+ /// The color of the dimmed overlay.
+ ///
+ public static readonly BindableProperty DimColorProperty =
+ BindableProperty.Create(
+ nameof(DimColor),
+ typeof(Color),
+ typeof(SpotlightOverlay),
+ Colors.Black,
+ propertyChanged: (b, _, _) => ((SpotlightOverlay)b).UpdatePath());
+
+ public Color DimColor
+ {
+ get => (Color)GetValue(DimColorProperty);
+ set => SetValue(DimColorProperty, value);
+ }
+
+ ///
+ /// Event raised when the spotlight area is tapped.
+ ///
+ public event EventHandler? SpotlightTapped;
+
+ ///
+ /// Event raised when the dimmed area (outside spotlight) is tapped.
+ ///
+ public event EventHandler? DimmedAreaTapped;
+
+ public SpotlightOverlay()
+ {
+ BackgroundColor = Colors.Transparent;
+ InputTransparent = false;
+ HorizontalOptions = LayoutOptions.Fill;
+ VerticalOptions = LayoutOptions.Fill;
+
+ _dimPath = new Path
+ {
+ Fill = new SolidColorBrush(DimColor.WithAlpha((float)DimOpacity)),
+ InputTransparent = true,
+ HorizontalOptions = LayoutOptions.Fill,
+ VerticalOptions = LayoutOptions.Fill
+ };
+
+ Children.Add(_dimPath);
+
+ SizeChanged += (_, _) => UpdatePath();
+
+ // Handle taps
+ var tapGesture = new TapGestureRecognizer();
+ tapGesture.Tapped += OnOverlayTapped;
+ GestureRecognizers.Add(tapGesture);
+ }
+
+ private void OnOverlayTapped(object? sender, TappedEventArgs e)
+ {
+ var position = e.GetPosition(this);
+ if (position.HasValue && _spotlightRect.Contains(position.Value))
+ {
+ SpotlightTapped?.Invoke(this, EventArgs.Empty);
+ }
+ else
+ {
+ DimmedAreaTapped?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ ///
+ /// Sets the spotlight cutout rectangle with shape options.
+ ///
+ public void SetSpotlight(Rect rect, SpotlightShape shape = SpotlightShape.RoundedRectangle, double cornerRadius = 8.0)
+ {
+ _spotlightRect = rect;
+ _spotlightShape = shape;
+ _cornerRadius = cornerRadius;
+ UpdatePath();
+ }
+
+ ///
+ /// Clears the spotlight (full dim).
+ ///
+ public void ClearSpotlight()
+ {
+ _spotlightRect = Rect.Zero;
+ UpdatePath();
+ }
+
+ ///
+ /// Gets the current spotlight rectangle.
+ ///
+ public Rect SpotlightRect => _spotlightRect;
+
+ private void UpdatePath()
+ {
+ if (Width <= 0 || Height <= 0)
+ return;
+
+ _dimPath.Fill = new SolidColorBrush(DimColor.WithAlpha((float)DimOpacity));
+
+ var geometry = new PathGeometry
+ {
+ FillRule = FillRule.EvenOdd
+ };
+
+ // Outer full-screen rectangle
+ var outerFigure = new PathFigure
+ {
+ StartPoint = new Point(0, 0),
+ IsClosed = true
+ };
+ outerFigure.Segments.Add(new LineSegment(new Point(Width, 0)));
+ outerFigure.Segments.Add(new LineSegment(new Point(Width, Height)));
+ outerFigure.Segments.Add(new LineSegment(new Point(0, Height)));
+ outerFigure.Segments.Add(new LineSegment(new Point(0, 0)));
+ geometry.Figures.Add(outerFigure);
+
+ // Inner spotlight hole (if valid)
+ if (_spotlightRect.Width > 0 && _spotlightRect.Height > 0)
+ {
+ var innerFigure = CreateSpotlightFigure();
+ geometry.Figures.Add(innerFigure);
+ }
+
+ _dimPath.Data = geometry;
+ }
+
+ private PathFigure CreateSpotlightFigure()
+ {
+ return _spotlightShape switch
+ {
+ SpotlightShape.Rectangle => CreateRectangleFigure(),
+ SpotlightShape.RoundedRectangle => CreateRoundedRectangleFigure(),
+ SpotlightShape.Circle => CreateCircleFigure(),
+ _ => CreateRoundedRectangleFigure()
+ };
+ }
+
+ private PathFigure CreateRectangleFigure()
+ {
+ var figure = new PathFigure
+ {
+ StartPoint = new Point(_spotlightRect.Left, _spotlightRect.Top),
+ IsClosed = true
+ };
+ figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Right, _spotlightRect.Top)));
+ figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Right, _spotlightRect.Bottom)));
+ figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Left, _spotlightRect.Bottom)));
+ figure.Segments.Add(new LineSegment(new Point(_spotlightRect.Left, _spotlightRect.Top)));
+ return figure;
+ }
+
+ private PathFigure CreateRoundedRectangleFigure()
+ {
+ var r = Math.Min(_cornerRadius, Math.Min(_spotlightRect.Width / 2, _spotlightRect.Height / 2));
+ var rect = _spotlightRect;
+
+ var figure = new PathFigure
+ {
+ StartPoint = new Point(rect.Left + r, rect.Top),
+ IsClosed = true
+ };
+
+ // Top edge
+ figure.Segments.Add(new LineSegment(new Point(rect.Right - r, rect.Top)));
+ // Top-right corner
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(rect.Right, rect.Top + r),
+ Size = new Size(r, r),
+ SweepDirection = SweepDirection.Clockwise
+ });
+ // Right edge
+ figure.Segments.Add(new LineSegment(new Point(rect.Right, rect.Bottom - r)));
+ // Bottom-right corner
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(rect.Right - r, rect.Bottom),
+ Size = new Size(r, r),
+ SweepDirection = SweepDirection.Clockwise
+ });
+ // Bottom edge
+ figure.Segments.Add(new LineSegment(new Point(rect.Left + r, rect.Bottom)));
+ // Bottom-left corner
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(rect.Left, rect.Bottom - r),
+ Size = new Size(r, r),
+ SweepDirection = SweepDirection.Clockwise
+ });
+ // Left edge
+ figure.Segments.Add(new LineSegment(new Point(rect.Left, rect.Top + r)));
+ // Top-left corner
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(rect.Left + r, rect.Top),
+ Size = new Size(r, r),
+ SweepDirection = SweepDirection.Clockwise
+ });
+
+ return figure;
+ }
+
+ private PathFigure CreateCircleFigure()
+ {
+ var center = new Point(_spotlightRect.Center.X, _spotlightRect.Center.Y);
+ // Use the diagonal to create a circle that encompasses the rectangle
+ var radius = Math.Sqrt(_spotlightRect.Width * _spotlightRect.Width + _spotlightRect.Height * _spotlightRect.Height) / 2;
+ // But cap it to not be excessively large
+ radius = Math.Min(radius, Math.Max(_spotlightRect.Width, _spotlightRect.Height) * 0.75);
+
+ var figure = new PathFigure
+ {
+ StartPoint = new Point(center.X - radius, center.Y),
+ IsClosed = true
+ };
+
+ // Upper half
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(center.X + radius, center.Y),
+ Size = new Size(radius, radius),
+ SweepDirection = SweepDirection.Clockwise,
+ IsLargeArc = true
+ });
+
+ // Lower half
+ figure.Segments.Add(new ArcSegment
+ {
+ Point = new Point(center.X - radius, center.Y),
+ Size = new Size(radius, radius),
+ SweepDirection = SweepDirection.Clockwise,
+ IsLargeArc = true
+ });
+
+ return figure;
+ }
+
+ ///
+ /// Animates the spotlight to a new position.
+ ///
+ public async Task AnimateToAsync(Rect targetRect, SpotlightShape shape, double cornerRadius, uint duration = 250)
+ {
+ var startRect = _spotlightRect;
+ _spotlightShape = shape;
+ _cornerRadius = cornerRadius;
+
+ var animation = new Animation(v =>
+ {
+ _spotlightRect = new Rect(
+ startRect.X + (targetRect.X - startRect.X) * v,
+ startRect.Y + (targetRect.Y - startRect.Y) * v,
+ startRect.Width + (targetRect.Width - startRect.Width) * v,
+ startRect.Height + (targetRect.Height - startRect.Height) * v);
+ UpdatePath();
+ });
+
+ var tcs = new TaskCompletionSource();
+ animation.Commit(this, "SpotlightAnimation", length: duration, easing: Easing.CubicInOut,
+ finished: (_, _) => tcs.SetResult(true));
+
+ await tcs.Task;
+ }
+}
diff --git a/MarketAlly.MASpotlightTour/VisualElementExtensions.cs b/MarketAlly.MASpotlightTour/VisualElementExtensions.cs
new file mode 100644
index 0000000..dbe6f0a
--- /dev/null
+++ b/MarketAlly.MASpotlightTour/VisualElementExtensions.cs
@@ -0,0 +1,137 @@
+namespace MarketAlly.MASpotlightTour;
+
+///
+/// Extension methods for VisualElement bounds calculations.
+///
+public static class VisualElementExtensions
+{
+ ///
+ /// Gets the absolute bounds of a view relative to a root element.
+ ///
+ /// The target view.
+ /// The root element to calculate bounds relative to.
+ /// The bounds rectangle relative to the root.
+ public static Rect GetAbsoluteBoundsRelativeTo(this VisualElement view, VisualElement root)
+ {
+ if (view == null || root == null)
+ return Rect.Zero;
+
+ double x = view.X;
+ double y = view.Y;
+ double width = view.Width;
+ double height = view.Height;
+
+ Element? parent = view.Parent;
+ while (parent is VisualElement ve && ve != root)
+ {
+ x += ve.X;
+ y += ve.Y;
+
+ // Account for ScrollView scroll position
+ if (ve is ScrollView scrollView)
+ {
+ x -= scrollView.ScrollX;
+ y -= scrollView.ScrollY;
+ }
+
+ parent = parent.Parent;
+ }
+
+ return new Rect(x, y, width, height);
+ }
+
+ ///
+ /// Gets the absolute bounds with padding applied.
+ ///
+ public static Rect GetAbsoluteBoundsWithPadding(this VisualElement view, VisualElement root, Thickness padding)
+ {
+ var bounds = view.GetAbsoluteBoundsRelativeTo(root);
+
+ return new Rect(
+ bounds.X - padding.Left,
+ bounds.Y - padding.Top,
+ bounds.Width + padding.Left + padding.Right,
+ bounds.Height + padding.Top + padding.Bottom);
+ }
+
+ ///
+ /// Checks if the element is currently visible within the viewport.
+ ///
+ public static bool IsVisibleInViewport(this VisualElement view, VisualElement root)
+ {
+ var bounds = view.GetAbsoluteBoundsRelativeTo(root);
+ var rootBounds = new Rect(0, 0, root.Width, root.Height);
+
+ return bounds.IntersectsWith(rootBounds);
+ }
+
+ ///
+ /// Finds the nearest ScrollView ancestor.
+ ///
+ public static ScrollView? FindAncestorScrollView(this VisualElement view)
+ {
+ Element? parent = view.Parent;
+ while (parent != null)
+ {
+ if (parent is ScrollView scrollView)
+ return scrollView;
+ parent = parent.Parent;
+ }
+ return null;
+ }
+
+ ///
+ /// Ensures the element is visible by scrolling if necessary.
+ ///
+ public static async Task EnsureVisibleAsync(this VisualElement view, bool animate = true)
+ {
+ var scrollView = view.FindAncestorScrollView();
+ if (scrollView != null)
+ {
+ await scrollView.ScrollToAsync(view, ScrollToPosition.MakeVisible, animate);
+ }
+ }
+
+ ///
+ /// Gets the center point of the element relative to root.
+ ///
+ public static Point GetCenterRelativeTo(this VisualElement view, VisualElement root)
+ {
+ var bounds = view.GetAbsoluteBoundsRelativeTo(root);
+ return new Point(bounds.X + bounds.Width / 2, bounds.Y + bounds.Height / 2);
+ }
+
+ ///
+ /// Traverses the visual tree and invokes an action on each VisualElement.
+ ///
+ public static void TraverseVisualTree(this Element root, Action action)
+ {
+ if (root is VisualElement ve)
+ action(ve);
+
+ if (root is IVisualTreeElement vte)
+ {
+ foreach (var child in vte.GetVisualChildren())
+ {
+ if (child is Element element)
+ TraverseVisualTree(element, action);
+ }
+ }
+ }
+
+ ///
+ /// Finds all VisualElements matching a predicate.
+ ///
+ public static IEnumerable FindAll(this Element root, Func predicate)
+ {
+ var results = new List();
+
+ root.TraverseVisualTree(ve =>
+ {
+ if (predicate(ve))
+ results.Add(ve);
+ });
+
+ return results;
+ }
+}
diff --git a/Test.SpotlightTour/App.xaml b/Test.SpotlightTour/App.xaml
new file mode 100644
index 0000000..dcf7b5d
--- /dev/null
+++ b/Test.SpotlightTour/App.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/App.xaml.cs b/Test.SpotlightTour/App.xaml.cs
new file mode 100644
index 0000000..3baf91f
--- /dev/null
+++ b/Test.SpotlightTour/App.xaml.cs
@@ -0,0 +1,22 @@
+namespace Test.SpotlightTour
+{
+ public partial class App : Application
+ {
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ var window = new Window(new AppShell());
+
+#if WINDOWS
+ window.Width = 700;
+ window.Height = 900;
+#endif
+
+ return window;
+ }
+ }
+}
\ No newline at end of file
diff --git a/Test.SpotlightTour/AppShell.xaml b/Test.SpotlightTour/AppShell.xaml
new file mode 100644
index 0000000..bef72e7
--- /dev/null
+++ b/Test.SpotlightTour/AppShell.xaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/AppShell.xaml.cs b/Test.SpotlightTour/AppShell.xaml.cs
new file mode 100644
index 0000000..904e268
--- /dev/null
+++ b/Test.SpotlightTour/AppShell.xaml.cs
@@ -0,0 +1,10 @@
+namespace Test.SpotlightTour
+{
+ public partial class AppShell : Shell
+ {
+ public AppShell()
+ {
+ InitializeComponent();
+ }
+ }
+}
diff --git a/Test.SpotlightTour/GlobalXmlns.cs b/Test.SpotlightTour/GlobalXmlns.cs
new file mode 100644
index 0000000..5d7c04f
--- /dev/null
+++ b/Test.SpotlightTour/GlobalXmlns.cs
@@ -0,0 +1,2 @@
+[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/2021/maui", "Test.SpotlightTour")]
+[assembly: XmlnsDefinition("http://schemas.microsoft.com/dotnet/2021/maui", "Test.SpotlightTour.Pages")]
diff --git a/Test.SpotlightTour/MainPage.xaml b/Test.SpotlightTour/MainPage.xaml
new file mode 100644
index 0000000..6491a8a
--- /dev/null
+++ b/Test.SpotlightTour/MainPage.xaml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/MainPage.xaml.cs b/Test.SpotlightTour/MainPage.xaml.cs
new file mode 100644
index 0000000..fabdca5
--- /dev/null
+++ b/Test.SpotlightTour/MainPage.xaml.cs
@@ -0,0 +1,30 @@
+namespace Test.SpotlightTour;
+
+public partial class MainPage : ContentPage
+{
+ public MainPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnBasicTourTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//BasicTourPage");
+
+ private async void OnShapesTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//ShapesPage");
+
+ private async void OnAutoStartTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//AutoStartPage");
+
+ private async void OnScrollDemoTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//ScrollDemoPage");
+
+ private async void OnDisplayModesTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//DisplayModesPage");
+
+ private async void OnPositioningModesTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//PositioningModesPage");
+
+ private async void OnThemesTapped(object? sender, EventArgs e)
+ => await Shell.Current.GoToAsync("//ThemesPage");
+}
diff --git a/Test.SpotlightTour/MauiProgram.cs b/Test.SpotlightTour/MauiProgram.cs
new file mode 100644
index 0000000..3f7b574
--- /dev/null
+++ b/Test.SpotlightTour/MauiProgram.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.Logging;
+
+namespace Test.SpotlightTour
+{
+ public static class MauiProgram
+ {
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+ builder
+ .UseMauiApp()
+ .ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+#if DEBUG
+ builder.Logging.AddDebug();
+#endif
+
+ return builder.Build();
+ }
+ }
+}
diff --git a/Test.SpotlightTour/Pages/AutoStartPage.xaml b/Test.SpotlightTour/Pages/AutoStartPage.xaml
new file mode 100644
index 0000000..7b70f26
--- /dev/null
+++ b/Test.SpotlightTour/Pages/AutoStartPage.xaml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/AutoStartPage.xaml.cs b/Test.SpotlightTour/Pages/AutoStartPage.xaml.cs
new file mode 100644
index 0000000..b934712
--- /dev/null
+++ b/Test.SpotlightTour/Pages/AutoStartPage.xaml.cs
@@ -0,0 +1,21 @@
+namespace Test.SpotlightTour.Pages;
+
+public partial class AutoStartPage : ContentPage
+{
+ public AutoStartPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnManualTourClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.AutoAdvanceDelay = 0; // Disable auto-advance
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnAutoAdvanceTourClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.AutoAdvanceDelay = 3000; // 3 seconds per step
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/BasicTourPage.xaml b/Test.SpotlightTour/Pages/BasicTourPage.xaml
new file mode 100644
index 0000000..eb9bd8d
--- /dev/null
+++ b/Test.SpotlightTour/Pages/BasicTourPage.xaml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/BasicTourPage.xaml.cs b/Test.SpotlightTour/Pages/BasicTourPage.xaml.cs
new file mode 100644
index 0000000..535559b
--- /dev/null
+++ b/Test.SpotlightTour/Pages/BasicTourPage.xaml.cs
@@ -0,0 +1,14 @@
+namespace Test.SpotlightTour.Pages;
+
+public partial class BasicTourPage : ContentPage
+{
+ public BasicTourPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnStartTourClicked(object? sender, EventArgs e)
+ {
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/DisplayModesPage.xaml b/Test.SpotlightTour/Pages/DisplayModesPage.xaml
new file mode 100644
index 0000000..9cba3f2
--- /dev/null
+++ b/Test.SpotlightTour/Pages/DisplayModesPage.xaml
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/DisplayModesPage.xaml.cs b/Test.SpotlightTour/Pages/DisplayModesPage.xaml.cs
new file mode 100644
index 0000000..c71e6cc
--- /dev/null
+++ b/Test.SpotlightTour/Pages/DisplayModesPage.xaml.cs
@@ -0,0 +1,29 @@
+using MarketAlly.MASpotlightTour;
+
+namespace Test.SpotlightTour.Pages;
+
+public partial class DisplayModesPage : ContentPage
+{
+ public DisplayModesPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnSpotlightCalloutClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnCalloutOnlyClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.DisplayMode = TourDisplayMode.CalloutOnly;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnInlineLabelClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithInlineLabel;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/PositioningModesPage.xaml b/Test.SpotlightTour/Pages/PositioningModesPage.xaml
new file mode 100644
index 0000000..dcd041e
--- /dev/null
+++ b/Test.SpotlightTour/Pages/PositioningModesPage.xaml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/PositioningModesPage.xaml.cs b/Test.SpotlightTour/Pages/PositioningModesPage.xaml.cs
new file mode 100644
index 0000000..28b9a7f
--- /dev/null
+++ b/Test.SpotlightTour/Pages/PositioningModesPage.xaml.cs
@@ -0,0 +1,33 @@
+using MarketAlly.MASpotlightTour;
+
+namespace Test.SpotlightTour.Pages;
+
+public partial class PositioningModesPage : ContentPage
+{
+ public PositioningModesPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnFollowingModeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.CalloutPositionMode = CalloutPositionMode.Following;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnFixedCornerModeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.CalloutPositionMode = CalloutPositionMode.FixedCorner;
+ OnboardingHost.CalloutCorner = CalloutCorner.BottomLeft;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnAutoCornerModeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.CalloutPositionMode = CalloutPositionMode.AutoCorner;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/ScrollDemoPage.xaml b/Test.SpotlightTour/Pages/ScrollDemoPage.xaml
new file mode 100644
index 0000000..d535d32
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ScrollDemoPage.xaml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/ScrollDemoPage.xaml.cs b/Test.SpotlightTour/Pages/ScrollDemoPage.xaml.cs
new file mode 100644
index 0000000..9782a42
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ScrollDemoPage.xaml.cs
@@ -0,0 +1,14 @@
+namespace Test.SpotlightTour.Pages;
+
+public partial class ScrollDemoPage : ContentPage
+{
+ public ScrollDemoPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnStartTourClicked(object? sender, EventArgs e)
+ {
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/ShapesPage.xaml b/Test.SpotlightTour/Pages/ShapesPage.xaml
new file mode 100644
index 0000000..9d45f98
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ShapesPage.xaml
@@ -0,0 +1,79 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/ShapesPage.xaml.cs b/Test.SpotlightTour/Pages/ShapesPage.xaml.cs
new file mode 100644
index 0000000..2586149
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ShapesPage.xaml.cs
@@ -0,0 +1,14 @@
+namespace Test.SpotlightTour.Pages;
+
+public partial class ShapesPage : ContentPage
+{
+ public ShapesPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnStartTourClicked(object? sender, EventArgs e)
+ {
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Pages/ThemesPage.xaml b/Test.SpotlightTour/Pages/ThemesPage.xaml
new file mode 100644
index 0000000..df39b19
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ThemesPage.xaml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Pages/ThemesPage.xaml.cs b/Test.SpotlightTour/Pages/ThemesPage.xaml.cs
new file mode 100644
index 0000000..6c114c0
--- /dev/null
+++ b/Test.SpotlightTour/Pages/ThemesPage.xaml.cs
@@ -0,0 +1,32 @@
+using MarketAlly.MASpotlightTour;
+
+namespace Test.SpotlightTour.Pages;
+
+public partial class ThemesPage : ContentPage
+{
+ public ThemesPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void OnLightThemeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.Theme = TourTheme.Light;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnDarkThemeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.Theme = TourTheme.Dark;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+
+ private async void OnSystemThemeClicked(object? sender, EventArgs e)
+ {
+ OnboardingHost.Theme = TourTheme.System;
+ OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
+ await OnboardingHost.StartTourAsync(this.Content);
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/Android/AndroidManifest.xml b/Test.SpotlightTour/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 0000000..e9937ad
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Platforms/Android/MainActivity.cs b/Test.SpotlightTour/Platforms/Android/MainActivity.cs
new file mode 100644
index 0000000..355a776
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Android/MainActivity.cs
@@ -0,0 +1,11 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace Test.SpotlightTour
+{
+ [Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
+ public class MainActivity : MauiAppCompatActivity
+ {
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/Android/MainApplication.cs b/Test.SpotlightTour/Platforms/Android/MainApplication.cs
new file mode 100644
index 0000000..d26b790
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Android/MainApplication.cs
@@ -0,0 +1,16 @@
+using Android.App;
+using Android.Runtime;
+
+namespace Test.SpotlightTour
+{
+ [Application]
+ public class MainApplication : MauiApplication
+ {
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/Android/Resources/values/colors.xml b/Test.SpotlightTour/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 0000000..c04d749
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Platforms/MacCatalyst/AppDelegate.cs b/Test.SpotlightTour/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 0000000..125aeab
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,10 @@
+using Foundation;
+
+namespace Test.SpotlightTour
+{
+ [Register("AppDelegate")]
+ public class AppDelegate : MauiUIApplicationDelegate
+ {
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/MacCatalyst/Entitlements.plist b/Test.SpotlightTour/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 0000000..de4adc9
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/Test.SpotlightTour/Platforms/MacCatalyst/Info.plist b/Test.SpotlightTour/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 0000000..7268977
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/Test.SpotlightTour/Platforms/MacCatalyst/Program.cs b/Test.SpotlightTour/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 0000000..a96bee4
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,16 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace Test.SpotlightTour
+{
+ public class Program
+ {
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/Tizen/Main.cs b/Test.SpotlightTour/Platforms/Tizen/Main.cs
new file mode 100644
index 0000000..a9e5259
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Tizen/Main.cs
@@ -0,0 +1,17 @@
+using Microsoft.Maui;
+using Microsoft.Maui.Hosting;
+using System;
+
+namespace Test.SpotlightTour
+{
+ internal class Program : MauiApplication
+ {
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+
+ static void Main(string[] args)
+ {
+ var app = new Program();
+ app.Run(args);
+ }
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/Tizen/tizen-manifest.xml b/Test.SpotlightTour/Platforms/Tizen/tizen-manifest.xml
new file mode 100644
index 0000000..08a584b
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Tizen/tizen-manifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+ maui-appicon-placeholder
+
+
+
+
+ http://tizen.org/privilege/internet
+
+
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Platforms/Windows/App.xaml b/Test.SpotlightTour/Platforms/Windows/App.xaml
new file mode 100644
index 0000000..076f87d
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/Test.SpotlightTour/Platforms/Windows/App.xaml.cs b/Test.SpotlightTour/Platforms/Windows/App.xaml.cs
new file mode 100644
index 0000000..cf1b5a2
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,25 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace Test.SpotlightTour.WinUI
+{
+ ///
+ /// Provides application-specific behavior to supplement the default Application class.
+ ///
+ public partial class App : MauiWinUIApplication
+ {
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+ }
+
+}
diff --git a/Test.SpotlightTour/Platforms/Windows/Package.appxmanifest b/Test.SpotlightTour/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 0000000..277e068
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Platforms/Windows/app.manifest b/Test.SpotlightTour/Platforms/Windows/app.manifest
new file mode 100644
index 0000000..2f9305c
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/Windows/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/Test.SpotlightTour/Platforms/iOS/AppDelegate.cs b/Test.SpotlightTour/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 0000000..125aeab
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,10 @@
+using Foundation;
+
+namespace Test.SpotlightTour
+{
+ [Register("AppDelegate")]
+ public class AppDelegate : MauiUIApplicationDelegate
+ {
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/iOS/Info.plist b/Test.SpotlightTour/Platforms/iOS/Info.plist
new file mode 100644
index 0000000..0004a4f
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/iOS/Info.plist
@@ -0,0 +1,32 @@
+
+
+
+
+ LSRequiresIPhoneOS
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/Test.SpotlightTour/Platforms/iOS/Program.cs b/Test.SpotlightTour/Platforms/iOS/Program.cs
new file mode 100644
index 0000000..a96bee4
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/iOS/Program.cs
@@ -0,0 +1,16 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace Test.SpotlightTour
+{
+ public class Program
+ {
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+ }
+}
diff --git a/Test.SpotlightTour/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/Test.SpotlightTour/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..24ab3b4
--- /dev/null
+++ b/Test.SpotlightTour/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Properties/launchSettings.json b/Test.SpotlightTour/Properties/launchSettings.json
new file mode 100644
index 0000000..4f85793
--- /dev/null
+++ b/Test.SpotlightTour/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Windows Machine": {
+ "commandName": "Project",
+ "nativeDebugging": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/Test.SpotlightTour/Resources/AppIcon/appicon.svg b/Test.SpotlightTour/Resources/AppIcon/appicon.svg
new file mode 100644
index 0000000..9d63b65
--- /dev/null
+++ b/Test.SpotlightTour/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Resources/AppIcon/appiconfg.svg b/Test.SpotlightTour/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/Test.SpotlightTour/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Resources/Fonts/OpenSans-Regular.ttf b/Test.SpotlightTour/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 0000000..bf60ae5
Binary files /dev/null and b/Test.SpotlightTour/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/Test.SpotlightTour/Resources/Fonts/OpenSans-Semibold.ttf b/Test.SpotlightTour/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 0000000..ad71502
Binary files /dev/null and b/Test.SpotlightTour/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/Test.SpotlightTour/Resources/Images/dotnet_bot.png b/Test.SpotlightTour/Resources/Images/dotnet_bot.png
new file mode 100644
index 0000000..1d1b981
Binary files /dev/null and b/Test.SpotlightTour/Resources/Images/dotnet_bot.png differ
diff --git a/Test.SpotlightTour/Resources/Raw/AboutAssets.txt b/Test.SpotlightTour/Resources/Raw/AboutAssets.txt
new file mode 100644
index 0000000..89dc758
--- /dev/null
+++ b/Test.SpotlightTour/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/Test.SpotlightTour/Resources/Splash/splash.svg b/Test.SpotlightTour/Resources/Splash/splash.svg
new file mode 100644
index 0000000..21dfb25
--- /dev/null
+++ b/Test.SpotlightTour/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Resources/Styles/Colors.xaml b/Test.SpotlightTour/Resources/Styles/Colors.xaml
new file mode 100644
index 0000000..30307a5
--- /dev/null
+++ b/Test.SpotlightTour/Resources/Styles/Colors.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #212121
+ #141414
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Test.SpotlightTour/Resources/Styles/Styles.xaml b/Test.SpotlightTour/Resources/Styles/Styles.xaml
new file mode 100644
index 0000000..d4dded0
--- /dev/null
+++ b/Test.SpotlightTour/Resources/Styles/Styles.xaml
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Test.SpotlightTour/Test.SpotlightTour.csproj b/Test.SpotlightTour/Test.SpotlightTour.csproj
new file mode 100644
index 0000000..f0c811a
--- /dev/null
+++ b/Test.SpotlightTour/Test.SpotlightTour.csproj
@@ -0,0 +1,71 @@
+
+
+
+ net9.0-android;net9.0-ios;net9.0-maccatalyst
+ $(TargetFrameworks);net9.0-windows10.0.19041.0
+
+
+
+
+
+
+ Exe
+ Test.SpotlightTour
+ true
+ true
+ enable
+ enable
+
+
+ Test.SpotlightTour
+
+
+ com.companyname.test.spotlighttour
+
+
+ 1.0
+ 1
+
+
+ None
+
+ 15.0
+ 15.0
+ 21.0
+ 10.0.17763.0
+ 10.0.17763.0
+ 6.5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+