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 @@ + + + + + + + +