Initial pass

This commit is contained in:
2025-12-08 19:15:33 -05:00
parent 319e9e0633
commit e8b9fb116b
64 changed files with 5352 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<Solution>
<Project Path="MarketAlly.MASpotlightTour/MASpotlightTour.Maui.csproj">
<Deploy />
</Project>
<Project Path="Test.SpotlightTour/Test.SpotlightTour.csproj" Id="ab7d572f-0431-45f1-95ed-1096d93b2ce0">
<Deploy />
</Project>
</Solution>

View File

@@ -0,0 +1,161 @@
using Microsoft.Maui.Controls.Shapes;
using Path = Microsoft.Maui.Controls.Shapes.Path;
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// An arrow indicator that points from the callout card toward the spotlight target.
/// </summary>
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());
/// <summary>
/// The direction the arrow points (toward the target).
/// </summary>
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;
}
/// <summary>
/// Positions the arrow between the callout and spotlight.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,460 @@
using Microsoft.Maui.Controls.Shapes;
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// A card that displays the title, description, and navigation buttons for an onboarding step.
/// </summary>
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
}
};
}
/// <summary>
/// Updates the card content and button visibility based on step position.
/// </summary>
public void UpdateForStep(string? title, string? description, bool isFirst, bool isLast)
{
Title = title;
Description = description;
ShowPreviousButton = !isFirst;
ShowNextButton = !isLast;
_closeButton.IsVisible = isLast || ShowCloseButton;
}
/// <summary>
/// Positions the card relative to a spotlight rectangle.
/// </summary>
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;
}
/// <summary>
/// Animates the card to a new position.
/// </summary>
public async Task AnimateToPositionAsync(double x, double y, uint duration = 250)
{
await this.TranslateTo(x, y, duration, Easing.CubicInOut);
}
/// <summary>
/// Positions the card in a specific screen corner.
/// </summary>
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;
}
/// <summary>
/// Determines the best corner that doesn't overlap with the spotlight area.
/// </summary>
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;
}
/// <summary>
/// Applies light or dark theme to the callout card.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,311 @@
using Microsoft.Maui.Controls.Shapes;
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// A corner-positioned navigator with step indicator and quick nav buttons.
/// </summary>
public class CornerNavigator : Border
{
private readonly Label _stepIndicator;
private readonly Border _previousButton;
private readonly Border _nextButton;
private readonly Border _skipButton;
#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;
}
}
/// <summary>
/// Updates the navigator for a specific step.
/// </summary>
public void UpdateForStep(int currentIndex, int totalCount)
{
CurrentStep = currentIndex;
TotalSteps = totalCount;
}
/// <summary>
/// Applies light or dark theme to the corner navigator.
/// </summary>
public void ApplyTheme(bool isDark)
{
if (isDark)
{
NavigatorBackgroundColor = Color.FromArgb("#1C1C1E");
Stroke = Color.FromArgb("#3A3A3C");
_stepIndicator.TextColor = Colors.White;
_previousButton.BackgroundColor = Color.FromArgb("#3A3A3C");
if (_previousButton.Content is Label prevLabel)
prevLabel.TextColor = Colors.White;
_nextButton.BackgroundColor = Color.FromArgb("#0A84FF");
if (_nextButton.Content is Label nextLabel)
nextLabel.TextColor = Colors.White;
_skipButton.BackgroundColor = Color.FromArgb("#FF453A");
if (_skipButton.Content is Label skipLabel)
skipLabel.TextColor = Colors.White;
}
else
{
NavigatorBackgroundColor = Colors.White;
Stroke = Color.FromArgb("#E5E5EA");
_stepIndicator.TextColor = Colors.Black;
_previousButton.BackgroundColor = Color.FromArgb("#E5E5EA");
if (_previousButton.Content is Label prevLabel)
prevLabel.TextColor = Color.FromArgb("#3A3A3C");
_nextButton.BackgroundColor = Color.FromArgb("#007AFF");
if (_nextButton.Content is Label nextLabel)
nextLabel.TextColor = Colors.White;
_skipButton.BackgroundColor = Color.FromArgb("#FF3B30");
if (_skipButton.Content is Label skipLabel)
skipLabel.TextColor = Colors.White;
}
}
}

View File

@@ -0,0 +1,200 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// Specifies the visual theme for the tour components.
/// </summary>
public enum TourTheme
{
/// <summary>
/// Light theme with white backgrounds and dark text.
/// </summary>
Light,
/// <summary>
/// Dark theme with dark backgrounds and light text.
/// </summary>
Dark,
/// <summary>
/// Automatically follows the system theme.
/// </summary>
System
}
/// <summary>
/// Specifies where the callout card is positioned relative to the spotlight.
/// </summary>
public enum CalloutPlacement
{
/// <summary>
/// Automatically determine placement based on available space.
/// </summary>
Auto,
/// <summary>
/// Position callout above the spotlight.
/// </summary>
Top,
/// <summary>
/// Position callout below the spotlight.
/// </summary>
Bottom,
/// <summary>
/// Position callout to the left of the spotlight.
/// </summary>
Left,
/// <summary>
/// Position callout to the right of the spotlight.
/// </summary>
Right
}
/// <summary>
/// Specifies the shape of the spotlight cutout.
/// </summary>
public enum SpotlightShape
{
/// <summary>
/// Rectangular cutout matching the element bounds.
/// </summary>
Rectangle,
/// <summary>
/// Rounded rectangle cutout with configurable corner radius.
/// </summary>
RoundedRectangle,
/// <summary>
/// Circular cutout centered on the element.
/// </summary>
Circle
}
/// <summary>
/// Specifies behavior when the user taps on the spotlighted element.
/// </summary>
public enum SpotlightTapBehavior
{
/// <summary>
/// Tapping the spotlight does nothing; only nav buttons work.
/// </summary>
None,
/// <summary>
/// Tapping the spotlight advances to the next step.
/// </summary>
Advance,
/// <summary>
/// Tapping the spotlight closes the tour.
/// </summary>
Close,
/// <summary>
/// Allow interaction with the underlying element (pass-through).
/// </summary>
AllowInteraction
}
/// <summary>
/// Specifies the corner position for the navigator control.
/// </summary>
public enum CornerNavigatorPlacement
{
/// <summary>
/// Top-left corner of the screen.
/// </summary>
TopLeft,
/// <summary>
/// Top-right corner of the screen.
/// </summary>
TopRight,
/// <summary>
/// Bottom-left corner of the screen.
/// </summary>
BottomLeft,
/// <summary>
/// Bottom-right corner of the screen.
/// </summary>
BottomRight
}
/// <summary>
/// Specifies the positioning strategy for the callout card.
/// </summary>
public enum CalloutPositionMode
{
/// <summary>
/// Callout follows/positions relative to the highlighted element.
/// Uses CalloutPlacement to determine which side (Auto, Top, Bottom, Left, Right).
/// </summary>
Following,
/// <summary>
/// Callout is fixed in a specific screen corner.
/// Uses CalloutCorner to determine which corner.
/// </summary>
FixedCorner,
/// <summary>
/// Callout automatically positions in the screen corner that least
/// interferes with the highlighted element.
/// </summary>
AutoCorner
}
/// <summary>
/// Specifies which screen corner for fixed/auto corner positioning.
/// </summary>
public enum CalloutCorner
{
/// <summary>
/// Top-left corner of the screen.
/// </summary>
TopLeft,
/// <summary>
/// Top-right corner of the screen.
/// </summary>
TopRight,
/// <summary>
/// Bottom-left corner of the screen.
/// </summary>
BottomLeft,
/// <summary>
/// Bottom-right corner of the screen.
/// </summary>
BottomRight
}
/// <summary>
/// Specifies the display mode for the onboarding tour.
/// </summary>
public enum TourDisplayMode
{
/// <summary>
/// Full experience: dimmed overlay + spotlight cutout + callout card with nav buttons.
/// </summary>
SpotlightWithCallout,
/// <summary>
/// Callout cards only - no dimming, just floating callouts that point to elements.
/// Good for light-touch guidance without blocking interaction.
/// </summary>
CalloutOnly,
/// <summary>
/// Dimmed overlay with spotlight cutouts, but titles/descriptions shown as
/// inline labels positioned around the spotlight (no callout card).
/// Use corner navigator for navigation.
/// </summary>
SpotlightWithInlineLabel
}

View File

@@ -0,0 +1,296 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// A simple label that displays title/description inline near the spotlight.
/// Used in SpotlightWithInlineLabel mode.
/// </summary>
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 }
};
}
/// <summary>
/// Updates the label content.
/// </summary>
public void SetContent(string? title, string? description)
{
Title = title;
Description = description;
_titleLabel.IsVisible = !string.IsNullOrWhiteSpace(title);
_descriptionLabel.IsVisible = ShowDescription && !string.IsNullOrWhiteSpace(description);
}
/// <summary>
/// Positions the label relative to a spotlight rectangle.
/// </summary>
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;
}
/// <summary>
/// Positions the label in a specific screen corner.
/// </summary>
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;
}
/// <summary>
/// Determines the best corner that doesn't overlap with the spotlight area.
/// </summary>
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;
}
/// <summary>
/// Applies light or dark theme to the inline label.
/// </summary>
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;
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MarketAlly.MASpotlightTour</AssemblyName>
<RootNamespace>MarketAlly.MASpotlightTour</RootNamespace>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,253 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// Provides attached properties for tagging UI elements as onboarding/tour steps.
/// </summary>
public static class Onboarding
{
#region StepKey
/// <summary>
/// Identifies the step with a unique key. Falls back to AutomationId if not set.
/// </summary>
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
/// <summary>
/// The title text displayed in the callout for this step.
/// </summary>
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
/// <summary>
/// The description text displayed in the callout for this step.
/// </summary>
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
/// <summary>
/// The order/sequence number for this step within its group.
/// </summary>
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
/// <summary>
/// The group name for organizing multiple tours on the same page.
/// </summary>
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
/// <summary>
/// Whether this element participates in spotlight cutouts.
/// </summary>
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
/// <summary>
/// Where to place the callout card relative to the spotlight.
/// </summary>
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
/// <summary>
/// The shape of the spotlight cutout for this step.
/// </summary>
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
/// <summary>
/// Padding around the target element in the spotlight cutout.
/// </summary>
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
/// <summary>
/// Corner radius for RoundedRectangle spotlight shape.
/// </summary>
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
/// <summary>
/// Behavior when the user taps on the spotlighted element.
/// </summary>
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
/// <summary>
/// Helper to get the effective step key, falling back to AutomationId.
/// </summary>
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;
}
/// <summary>
/// Determines if an element is tagged as an onboarding step.
/// </summary>
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));
}
}

View File

@@ -0,0 +1,934 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// The main host control that orchestrates the onboarding tour experience.
/// Add this as an overlay on your page to enable spotlight tours.
/// </summary>
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<OnboardingStep> _steps = Array.Empty<OnboardingStep>();
private int _currentIndex = -1;
private VisualElement? _root;
private bool _isRunning;
private CancellationTokenSource? _autoAdvanceCts;
#region Bindable Properties
/// <summary>
/// The display mode for the tour (SpotlightWithCallout, CalloutOnly, SpotlightWithInlineLabel).
/// </summary>
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);
}
/// <summary>
/// The positioning strategy for the callout card (Following, FixedCorner, AutoCorner).
/// </summary>
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);
}
/// <summary>
/// The corner to use when CalloutPositionMode is FixedCorner.
/// </summary>
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);
}
/// <summary>
/// Margin from screen edge when using corner positioning (in device-independent units).
/// </summary>
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);
}
/// <summary>
/// The visual theme for the tour (Light, Dark, or System).
/// </summary>
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);
}
/// <summary>
/// Optional delay in milliseconds before auto-starting the tour.
/// Set to 0 or negative to disable auto-start (default).
/// </summary>
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);
}
/// <summary>
/// The group to use for auto-start. If null, all steps are included.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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
/// <summary>
/// Raised when the tour starts.
/// </summary>
public event EventHandler? TourStarted;
/// <summary>
/// Raised when the tour completes (user reaches the end).
/// </summary>
public event EventHandler? TourCompleted;
/// <summary>
/// Raised when the tour is skipped.
/// </summary>
public event EventHandler? TourSkipped;
/// <summary>
/// Raised when moving to a new step.
/// </summary>
public event EventHandler<OnboardingStepEventArgs>? StepChanged;
/// <summary>
/// Raised when the tour ends (either completed or skipped).
/// </summary>
public event EventHandler? TourEnded;
#endregion
#region Read-only Properties
/// <summary>
/// Gets whether a tour is currently running.
/// </summary>
public bool IsRunning => _isRunning;
/// <summary>
/// Gets the current step index (0-based).
/// </summary>
public int CurrentStepIndex => _currentIndex;
/// <summary>
/// Gets the total number of steps.
/// </summary>
public int TotalSteps => _steps.Count;
/// <summary>
/// Gets the current step, or null if no tour is running.
/// </summary>
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;
}
/// <summary>
/// Stops any currently running tour.
/// </summary>
public void StopTour()
{
if (_isRunning)
EndTour();
}
/// <summary>
/// Starts the onboarding tour by scanning for tagged elements.
/// </summary>
/// <param name="root">The root element to scan (typically the Page or a layout).</param>
/// <param name="group">Optional group filter.</param>
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<bool>();
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;
}
/// <summary>
/// Starts the tour from a specific step key.
/// </summary>
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;
}
/// <summary>
/// Navigates to a specific step by index.
/// </summary>
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;
}
}
/// <summary>
/// Advances to the next step.
/// </summary>
public async void GoToNextStep()
{
if (_currentIndex < _steps.Count - 1)
{
await GoToStepAsync(_currentIndex + 1);
}
else
{
CompleteTour();
}
}
/// <summary>
/// Returns to the previous step.
/// </summary>
public async void GoToPreviousStep()
{
if (_currentIndex > 0)
{
await GoToStepAsync(_currentIndex - 1);
}
}
/// <summary>
/// Completes the tour (user finished all steps).
/// </summary>
public void CompleteTour()
{
if (!_isRunning)
return;
EndTour();
TourCompleted?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Skips/cancels the tour.
/// </summary>
public void SkipTour()
{
if (!_isRunning)
return;
EndTour();
TourSkipped?.Invoke(this, EventArgs.Empty);
}
private void EndTour()
{
CancelAutoAdvanceTimer();
_isRunning = false;
_currentIndex = -1;
_steps = Array.Empty<OnboardingStep>();
_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;
}
}
/// <summary>
/// Event args for step change events.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,89 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// Scans the visual tree to find elements tagged with onboarding properties.
/// </summary>
public static class OnboardingScanner
{
/// <summary>
/// Finds all onboarding steps within the given root element.
/// </summary>
/// <param name="root">The root element to scan.</param>
/// <param name="group">Optional group filter. If null, returns all steps.</param>
/// <returns>Ordered list of onboarding steps.</returns>
public static IReadOnlyList<OnboardingStep> FindSteps(Element root, string? group = null)
{
var steps = new List<OnboardingStep>();
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();
}
/// <summary>
/// Finds a specific step by its key.
/// </summary>
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;
}
/// <summary>
/// Gets all unique group names found in the visual tree.
/// </summary>
public static IReadOnlyList<string> GetGroups(Element root)
{
var groups = new HashSet<string>(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();
}
/// <summary>
/// Counts the total number of steps, optionally filtered by group.
/// </summary>
public static int CountSteps(Element root, string? group = null)
{
return FindSteps(root, group).Count;
}
}

View File

@@ -0,0 +1,89 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// Represents a single step in an onboarding tour.
/// </summary>
public class OnboardingStep
{
/// <summary>
/// The target visual element for this step.
/// </summary>
public required VisualElement Target { get; init; }
/// <summary>
/// Unique key identifying this step.
/// </summary>
public string? StepKey { get; init; }
/// <summary>
/// Title text displayed in the callout.
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Description text displayed in the callout.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Order/sequence number within the group.
/// </summary>
public int Order { get; init; }
/// <summary>
/// Group name for organizing multiple tours.
/// </summary>
public string? Group { get; init; }
/// <summary>
/// Whether spotlight cutout is enabled for this step.
/// </summary>
public bool SpotlightEnabled { get; init; } = true;
/// <summary>
/// Callout placement relative to the spotlight.
/// </summary>
public CalloutPlacement Placement { get; init; } = CalloutPlacement.Auto;
/// <summary>
/// Shape of the spotlight cutout.
/// </summary>
public SpotlightShape SpotlightShape { get; init; } = SpotlightShape.RoundedRectangle;
/// <summary>
/// Padding around the target in the spotlight cutout.
/// </summary>
public Thickness SpotlightPadding { get; init; } = new(8);
/// <summary>
/// Corner radius for RoundedRectangle shape.
/// </summary>
public double SpotlightCornerRadius { get; init; } = 8.0;
/// <summary>
/// Behavior when user taps the spotlight.
/// </summary>
public SpotlightTapBehavior TapBehavior { get; init; } = SpotlightTapBehavior.None;
/// <summary>
/// Creates an OnboardingStep from a tagged VisualElement.
/// </summary>
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)
};
}
}

View File

@@ -0,0 +1,293 @@
using Microsoft.Maui.Controls.Shapes;
using Path = Microsoft.Maui.Controls.Shapes.Path;
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// A visual overlay that dims the screen with a spotlight cutout.
/// </summary>
public class SpotlightOverlay : Grid
{
private readonly Path _dimPath;
private Rect _spotlightRect;
private SpotlightShape _spotlightShape = SpotlightShape.RoundedRectangle;
private double _cornerRadius = 8.0;
/// <summary>
/// The opacity of the dimmed area (0-1).
/// </summary>
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);
}
/// <summary>
/// The color of the dimmed overlay.
/// </summary>
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);
}
/// <summary>
/// Event raised when the spotlight area is tapped.
/// </summary>
public event EventHandler? SpotlightTapped;
/// <summary>
/// Event raised when the dimmed area (outside spotlight) is tapped.
/// </summary>
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);
}
}
/// <summary>
/// Sets the spotlight cutout rectangle with shape options.
/// </summary>
public void SetSpotlight(Rect rect, SpotlightShape shape = SpotlightShape.RoundedRectangle, double cornerRadius = 8.0)
{
_spotlightRect = rect;
_spotlightShape = shape;
_cornerRadius = cornerRadius;
UpdatePath();
}
/// <summary>
/// Clears the spotlight (full dim).
/// </summary>
public void ClearSpotlight()
{
_spotlightRect = Rect.Zero;
UpdatePath();
}
/// <summary>
/// Gets the current spotlight rectangle.
/// </summary>
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;
}
/// <summary>
/// Animates the spotlight to a new position.
/// </summary>
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<bool>();
animation.Commit(this, "SpotlightAnimation", length: duration, easing: Easing.CubicInOut,
finished: (_, _) => tcs.SetResult(true));
await tcs.Task;
}
}

View File

@@ -0,0 +1,137 @@
namespace MarketAlly.MASpotlightTour;
/// <summary>
/// Extension methods for VisualElement bounds calculations.
/// </summary>
public static class VisualElementExtensions
{
/// <summary>
/// Gets the absolute bounds of a view relative to a root element.
/// </summary>
/// <param name="view">The target view.</param>
/// <param name="root">The root element to calculate bounds relative to.</param>
/// <returns>The bounds rectangle relative to the root.</returns>
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);
}
/// <summary>
/// Gets the absolute bounds with padding applied.
/// </summary>
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);
}
/// <summary>
/// Checks if the element is currently visible within the viewport.
/// </summary>
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);
}
/// <summary>
/// Finds the nearest ScrollView ancestor.
/// </summary>
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;
}
/// <summary>
/// Ensures the element is visible by scrolling if necessary.
/// </summary>
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);
}
}
/// <summary>
/// Gets the center point of the element relative to root.
/// </summary>
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);
}
/// <summary>
/// Traverses the visual tree and invokes an action on each VisualElement.
/// </summary>
public static void TraverseVisualTree(this Element root, Action<VisualElement> 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);
}
}
}
/// <summary>
/// Finds all VisualElements matching a predicate.
/// </summary>
public static IEnumerable<VisualElement> FindAll(this Element root, Func<VisualElement, bool> predicate)
{
var results = new List<VisualElement>();
root.TraverseVisualTree(ve =>
{
if (predicate(ve))
results.Add(ve);
});
return results;
}
}

View File

@@ -0,0 +1,14 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Test.SpotlightTour"
x:Class="Test.SpotlightTour.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="Test.SpotlightTour.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Test.SpotlightTour"
xmlns:pages="clr-namespace:Test.SpotlightTour.Pages"
Title="MASpotlightTour Demo">
<FlyoutItem Title="Home" Icon="{OnPlatform Default=icon_home.png}">
<ShellContent
Title="Home"
ContentTemplate="{DataTemplate local:MainPage}"
Route="MainPage" />
</FlyoutItem>
<FlyoutItem Title="Basic Tour" Icon="{OnPlatform Default=icon_tour.png}">
<ShellContent
Title="Basic Tour"
ContentTemplate="{DataTemplate pages:BasicTourPage}"
Route="BasicTourPage" />
</FlyoutItem>
<FlyoutItem Title="Spotlight Shapes" Icon="{OnPlatform Default=icon_shapes.png}">
<ShellContent
Title="Spotlight Shapes"
ContentTemplate="{DataTemplate pages:ShapesPage}"
Route="ShapesPage" />
</FlyoutItem>
<FlyoutItem Title="Auto Features" Icon="{OnPlatform Default=icon_auto.png}">
<ShellContent
Title="Auto Features"
ContentTemplate="{DataTemplate pages:AutoStartPage}"
Route="AutoStartPage" />
</FlyoutItem>
<FlyoutItem Title="Scroll Support" Icon="{OnPlatform Default=icon_scroll.png}">
<ShellContent
Title="Scroll Support"
ContentTemplate="{DataTemplate pages:ScrollDemoPage}"
Route="ScrollDemoPage" />
</FlyoutItem>
<FlyoutItem Title="Display Modes" Icon="{OnPlatform Default=icon_modes.png}">
<ShellContent
Title="Display Modes"
ContentTemplate="{DataTemplate pages:DisplayModesPage}"
Route="DisplayModesPage" />
</FlyoutItem>
<FlyoutItem Title="Positioning Modes" Icon="{OnPlatform Default=icon_position.png}">
<ShellContent
Title="Positioning Modes"
ContentTemplate="{DataTemplate pages:PositioningModesPage}"
Route="PositioningModesPage" />
</FlyoutItem>
<FlyoutItem Title="Themes" Icon="{OnPlatform Default=icon_theme.png}">
<ShellContent
Title="Themes"
ContentTemplate="{DataTemplate pages:ThemesPage}"
Route="ThemesPage" />
</FlyoutItem>
</Shell>

View File

@@ -0,0 +1,10 @@
namespace Test.SpotlightTour
{
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}
}

View File

@@ -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")]

View File

@@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Test.SpotlightTour.MainPage"
Title="MASpotlightTour Demo">
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Image
Source="dotnet_bot.png"
HeightRequest="150"
Aspect="AspectFit" />
<Label
Text="MASpotlightTour"
FontSize="28"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label
Text="A XAML-driven onboarding and coach-marks system for .NET MAUI"
FontSize="16"
HorizontalTextAlignment="Center"
TextColor="Gray" />
<BoxView HeightRequest="20" Color="Transparent" />
<Label
Text="Select a demo:"
FontSize="18"
FontAttributes="Bold" />
<Frame BackgroundColor="{StaticResource Primary}" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnBasicTourTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Basic Tour" FontAttributes="Bold" TextColor="White" />
<Label Text="Simple spotlight tour with manual trigger button" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#9C27B0" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnShapesTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Spotlight Shapes" FontAttributes="Bold" TextColor="White" />
<Label Text="Rectangle, RoundedRectangle, and Circle shapes" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#FF9800" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnAutoStartTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Auto Features" FontAttributes="Bold" TextColor="White" />
<Label Text="Auto-start and auto-advance for hands-free demos" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#2196F3" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnScrollDemoTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Scroll Support" FontAttributes="Bold" TextColor="White" />
<Label Text="Auto-scroll to off-screen elements" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#607D8B" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnDisplayModesTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Display Modes" FontAttributes="Bold" TextColor="White" />
<Label Text="SpotlightWithCallout, CalloutOnly, SpotlightWithInlineLabel" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#00BCD4" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnPositioningModesTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Positioning Modes" FontAttributes="Bold" TextColor="White" />
<Label Text="Following, FixedCorner, and AutoCorner callout positioning" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#795548" Padding="15" CornerRadius="8">
<Frame.GestureRecognizers>
<TapGestureRecognizer Tapped="OnThemesTapped" />
</Frame.GestureRecognizers>
<VerticalStackLayout Spacing="5">
<Label Text="Themes" FontAttributes="Bold" TextColor="White" />
<Label Text="Light, Dark, and System theme support" TextColor="White" FontSize="12" />
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@@ -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");
}

View File

@@ -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<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
}

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.AutoStartPage"
Title="Auto Features">
<Grid>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Label
Text="Auto Features Demo"
FontSize="24"
FontAttributes="Bold" />
<Label
Text="Automatic tour start and step advancement for hands-free demos."
TextColor="Gray" />
<!-- Manual Tour Button -->
<Frame BackgroundColor="#4CAF50" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="Manual Tour" FontAttributes="Bold" TextColor="White" FontSize="16" />
<Label Text="Standard tour with manual navigation." TextColor="White" FontSize="13" />
<Button Text="Start Manual Tour"
BackgroundColor="White"
TextColor="#4CAF50"
Clicked="OnManualTourClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<!-- Auto-Advance Tour Button -->
<Frame BackgroundColor="#2196F3" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="Auto-Advance Tour" FontAttributes="Bold" TextColor="White" FontSize="16" />
<Label Text="Each step advances automatically after 3 seconds." TextColor="White" FontSize="13" />
<Button Text="Start Auto-Advance Tour"
BackgroundColor="White"
TextColor="#2196F3"
Clicked="OnAutoAdvanceTourClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<BoxView HeightRequest="10" Color="Transparent" />
<!-- Sample elements to highlight -->
<Label Text="Sample Elements:" FontSize="18" FontAttributes="Bold" />
<Frame
BackgroundColor="#E8F5E9"
Padding="20"
CornerRadius="8"
onboard:Onboarding.StepKey="step1"
onboard:Onboarding.Title="First Feature"
onboard:Onboarding.Description="This is the first feature in your app. In auto-advance mode, the tour will move to the next step automatically."
onboard:Onboarding.Order="1">
<Label Text="Feature 1" FontAttributes="Bold" HorizontalOptions="Center" TextColor="#2E7D32" />
</Frame>
<Frame
BackgroundColor="#E3F2FD"
Padding="20"
CornerRadius="8"
onboard:Onboarding.StepKey="step2"
onboard:Onboarding.Title="Second Feature"
onboard:Onboarding.Description="The second feature is highlighted here. Watch the timer - it will advance automatically!"
onboard:Onboarding.Order="2">
<Label Text="Feature 2" FontAttributes="Bold" HorizontalOptions="Center" TextColor="#1565C0" />
</Frame>
<Frame
BackgroundColor="#FFF3E0"
Padding="20"
CornerRadius="8"
onboard:Onboarding.StepKey="step3"
onboard:Onboarding.Title="Third Feature"
onboard:Onboarding.Description="Almost done! This is the third feature. One more to go."
onboard:Onboarding.Order="3">
<Label Text="Feature 3" FontAttributes="Bold" HorizontalOptions="Center" TextColor="#E65100" />
</Frame>
<Frame
BackgroundColor="#FCE4EC"
Padding="20"
CornerRadius="8"
onboard:Onboarding.StepKey="step4"
onboard:Onboarding.Title="Final Feature"
onboard:Onboarding.Description="This is the last step. The tour will complete automatically or you can press Done."
onboard:Onboarding.Order="4">
<Label Text="Feature 4" FontAttributes="Bold" HorizontalOptions="Center" TextColor="#AD1457" />
</Frame>
</VerticalStackLayout>
</ScrollView>
<onboard:OnboardingHost
x:Name="OnboardingHost"
ShowCornerNavigator="True"
CornerNavigatorPlacement="BottomRight" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,21 @@
namespace Test.SpotlightTour.Pages;
public partial class AutoStartPage : ContentPage
{
public AutoStartPage()
{
InitializeComponent();
}
private async void OnManualTourClicked(object? sender, EventArgs e)
{
OnboardingHost.AutoAdvanceDelay = 0; // Disable auto-advance
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnAutoAdvanceTourClicked(object? sender, EventArgs e)
{
OnboardingHost.AutoAdvanceDelay = 3000; // 3 seconds per step
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.BasicTourPage"
Title="Basic Tour">
<Grid>
<VerticalStackLayout Padding="20" Spacing="20">
<Label
Text="Basic Tour Demo"
FontSize="24"
FontAttributes="Bold"
onboard:Onboarding.Title="Welcome!"
onboard:Onboarding.Description="This demo shows a simple spotlight tour triggered by a button."
onboard:Onboarding.Order="1" />
<Label
Text="Tap the button below to start the tour. Each element with onboarding properties will be highlighted."
TextColor="Gray" />
<Button
x:Name="StartTourBtn"
Text="Start Tour"
Clicked="OnStartTourClicked"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
onboard:Onboarding.Title="Start Button"
onboard:Onboarding.Description="This button triggers the spotlight tour manually."
onboard:Onboarding.Order="2" />
<Frame
BackgroundColor="#E3F2FD"
Padding="15"
CornerRadius="8"
onboard:Onboarding.Title="Info Card"
onboard:Onboarding.Description="Cards can also be highlighted to explain UI sections."
onboard:Onboarding.Order="3">
<Label Text="This is an info card that will be highlighted during the tour." />
</Frame>
<HorizontalStackLayout Spacing="10" HorizontalOptions="Center">
<Button
Text="Action 1"
BackgroundColor="#4CAF50"
TextColor="White"
onboard:Onboarding.Title="Action Button"
onboard:Onboarding.Description="Individual buttons in a row can be highlighted. This is the last step!"
onboard:Onboarding.Order="4" />
<Button Text="Action 2" BackgroundColor="Gray" TextColor="White" />
</HorizontalStackLayout>
</VerticalStackLayout>
<!-- Onboarding Host -->
<onboard:OnboardingHost
x:Name="OnboardingHost"
ShowCornerNavigator="True"
ShowSkipButton="True"
DimOpacity="0.7" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,14 @@
namespace Test.SpotlightTour.Pages;
public partial class BasicTourPage : ContentPage
{
public BasicTourPage()
{
InitializeComponent();
}
private async void OnStartTourClicked(object? sender, EventArgs e)
{
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.DisplayModesPage"
Title="Display Modes">
<Grid>
<VerticalStackLayout Padding="20" Spacing="15">
<Label
Text="Tour Display Modes"
FontSize="24"
FontAttributes="Bold"
onboard:Onboarding.Title="Display Modes"
onboard:Onboarding.Description="Three different ways to present your onboarding tour."
onboard:Onboarding.Order="1" />
<Label Text="Choose a mode to see how it looks:" TextColor="Gray" />
<!-- Mode selection buttons -->
<Button
x:Name="SpotlightCalloutBtn"
Text="Spotlight + Callout Card"
Clicked="OnSpotlightCalloutClicked"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
onboard:Onboarding.Title="Full Experience"
onboard:Onboarding.Description="Dim overlay + spotlight cutout + callout card with nav buttons. Best for focused tutorials."
onboard:Onboarding.Order="2" />
<Button
x:Name="CalloutOnlyBtn"
Text="Callout Only (No Dimming)"
Clicked="OnCalloutOnlyClicked"
BackgroundColor="#9C27B0"
TextColor="White"
onboard:Onboarding.Title="Light Touch"
onboard:Onboarding.Description="Floating callouts without dimming. Users can still interact with the app. Great for subtle hints."
onboard:Onboarding.Order="3" />
<Button
x:Name="InlineLabelBtn"
Text="Spotlight + Inline Labels"
Clicked="OnInlineLabelClicked"
BackgroundColor="#FF9800"
TextColor="White"
onboard:Onboarding.Title="Minimal UI"
onboard:Onboarding.Description="Spotlight with simple labels. Use corner navigator for nav. Clean and minimal."
onboard:Onboarding.Order="4" />
<BoxView HeightRequest="20" Color="Transparent" />
<!-- Sample elements to highlight -->
<Frame
BackgroundColor="#E8F5E9"
Padding="15"
CornerRadius="8"
onboard:Onboarding.Title="Sample Card"
onboard:Onboarding.Description="This card demonstrates how each mode highlights elements differently."
onboard:Onboarding.Order="5">
<Label Text="Sample content card for the tour demo." />
</Frame>
<HorizontalStackLayout Spacing="10" HorizontalOptions="Center">
<Button
Text="Save"
BackgroundColor="#4CAF50"
TextColor="White"
onboard:Onboarding.Title="Final Step"
onboard:Onboarding.Description="Tour complete! Try a different mode to compare."
onboard:Onboarding.Order="6" />
<Button Text="Cancel" BackgroundColor="Gray" TextColor="White" />
</HorizontalStackLayout>
</VerticalStackLayout>
<!-- Onboarding Host - DisplayMode set in code -->
<onboard:OnboardingHost
x:Name="OnboardingHost"
ShowCornerNavigator="True"
CornerNavigatorPlacement="TopRight"
ShowArrow="True"
DimOpacity="0.7" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,29 @@
using MarketAlly.MASpotlightTour;
namespace Test.SpotlightTour.Pages;
public partial class DisplayModesPage : ContentPage
{
public DisplayModesPage()
{
InitializeComponent();
}
private async void OnSpotlightCalloutClicked(object? sender, EventArgs e)
{
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnCalloutOnlyClicked(object? sender, EventArgs e)
{
OnboardingHost.DisplayMode = TourDisplayMode.CalloutOnly;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnInlineLabelClicked(object? sender, EventArgs e)
{
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithInlineLabel;
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,137 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:spot="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.PositioningModesPage"
Title="Callout Positioning Modes">
<Grid>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Label Text="Callout Positioning Modes"
FontSize="24"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="Choose how the callout dialog is positioned relative to highlighted elements."
FontSize="14"
TextColor="Gray"
HorizontalTextAlignment="Center" />
<BoxView HeightRequest="20" Color="Transparent" />
<!-- Following Mode -->
<Frame BackgroundColor="#4CAF50" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="Following Mode (Default)" FontAttributes="Bold" TextColor="White" FontSize="16" />
<Label Text="Callout positions itself near the highlighted element (top, bottom, left, or right based on available space)."
TextColor="White" FontSize="13" />
<Button Text="Try Following Mode"
BackgroundColor="White"
TextColor="#4CAF50"
Clicked="OnFollowingModeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<!-- Fixed Corner Mode -->
<Frame BackgroundColor="#2196F3" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="Fixed Corner Mode" FontAttributes="Bold" TextColor="White" FontSize="16" />
<Label Text="Callout always appears in a specific screen corner (Bottom-Left in this demo)."
TextColor="White" FontSize="13" />
<Button Text="Try Fixed Corner Mode"
BackgroundColor="White"
TextColor="#2196F3"
Clicked="OnFixedCornerModeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<!-- Auto Corner Mode -->
<Frame BackgroundColor="#9C27B0" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="Auto Corner Mode" FontAttributes="Bold" TextColor="White" FontSize="16" />
<Label Text="Callout automatically picks the corner that won't interfere with the highlighted element."
TextColor="White" FontSize="13" />
<Button Text="Try Auto Corner Mode"
BackgroundColor="White"
TextColor="#9C27B0"
Clicked="OnAutoCornerModeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<BoxView HeightRequest="20" Color="Transparent" />
<!-- Sample elements to highlight -->
<Label Text="Sample Elements to Highlight:"
FontSize="18"
FontAttributes="Bold" />
<Grid ColumnDefinitions="*,*" RowDefinitions="Auto,Auto" ColumnSpacing="15" RowSpacing="15">
<Frame Grid.Row="0" Grid.Column="0"
BackgroundColor="#E3F2FD"
Padding="20"
CornerRadius="8"
spot:Onboarding.StepKey="topLeft"
spot:Onboarding.Title="Top Left Element"
spot:Onboarding.Description="This element is in the top-left area. Watch how the callout positions itself."
spot:Onboarding.Order="1">
<Label Text="Top Left" HorizontalOptions="Center" TextColor="#1976D2" FontAttributes="Bold" />
</Frame>
<Frame Grid.Row="0" Grid.Column="1"
BackgroundColor="#E8F5E9"
Padding="20"
CornerRadius="8"
spot:Onboarding.StepKey="topRight"
spot:Onboarding.Title="Top Right Element"
spot:Onboarding.Description="This element is in the top-right area. The callout adapts its position."
spot:Onboarding.Order="2">
<Label Text="Top Right" HorizontalOptions="Center" TextColor="#388E3C" FontAttributes="Bold" />
</Frame>
<Frame Grid.Row="1" Grid.Column="0"
BackgroundColor="#FFF3E0"
Padding="20"
CornerRadius="8"
spot:Onboarding.StepKey="bottomLeft"
spot:Onboarding.Title="Bottom Left Element"
spot:Onboarding.Description="This element is in the bottom-left area. Notice how positioning changes."
spot:Onboarding.Order="3">
<Label Text="Bottom Left" HorizontalOptions="Center" TextColor="#F57C00" FontAttributes="Bold" />
</Frame>
<Frame Grid.Row="1" Grid.Column="1"
BackgroundColor="#FCE4EC"
Padding="20"
CornerRadius="8"
spot:Onboarding.StepKey="bottomRight"
spot:Onboarding.Title="Bottom Right Element"
spot:Onboarding.Description="This element is in the bottom-right area. Each mode handles this differently."
spot:Onboarding.Order="4">
<Label Text="Bottom Right" HorizontalOptions="Center" TextColor="#C2185B" FontAttributes="Bold" />
</Frame>
</Grid>
<Frame BackgroundColor="#F5F5F5"
Padding="20"
CornerRadius="8"
spot:Onboarding.StepKey="center"
spot:Onboarding.Title="Center Element"
spot:Onboarding.Description="A center element demonstrates how auto-corner mode picks the best position."
spot:Onboarding.Order="5">
<Label Text="Center Element" HorizontalOptions="Center" TextColor="#616161" FontAttributes="Bold" />
</Frame>
</VerticalStackLayout>
</ScrollView>
<spot:OnboardingHost x:Name="OnboardingHost"
ShowCornerNavigator="True"
CornerNavigatorPlacement="BottomRight" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,33 @@
using MarketAlly.MASpotlightTour;
namespace Test.SpotlightTour.Pages;
public partial class PositioningModesPage : ContentPage
{
public PositioningModesPage()
{
InitializeComponent();
}
private async void OnFollowingModeClicked(object? sender, EventArgs e)
{
OnboardingHost.CalloutPositionMode = CalloutPositionMode.Following;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnFixedCornerModeClicked(object? sender, EventArgs e)
{
OnboardingHost.CalloutPositionMode = CalloutPositionMode.FixedCorner;
OnboardingHost.CalloutCorner = CalloutCorner.BottomLeft;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnAutoCornerModeClicked(object? sender, EventArgs e)
{
OnboardingHost.CalloutPositionMode = CalloutPositionMode.AutoCorner;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.ScrollDemoPage"
Title="Scroll Support">
<Grid>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Label
Text="Scroll Support Demo"
FontSize="24"
FontAttributes="Bold"
onboard:Onboarding.Title="Scroll Demo"
onboard:Onboarding.Description="This tour demonstrates auto-scrolling to off-screen elements."
onboard:Onboarding.Order="1" />
<Button
x:Name="StartTourBtn"
Text="Start Scroll Tour"
Clicked="OnStartTourClicked"
BackgroundColor="{StaticResource Primary}"
TextColor="White"
onboard:Onboarding.Title="Start Here"
onboard:Onboarding.Description="Click Next to see auto-scroll in action."
onboard:Onboarding.Order="2" />
<Frame BackgroundColor="#E8F5E9" Padding="15" CornerRadius="8">
<Label Text="This element is visible at the top of the page." />
</Frame>
<!-- Spacer to push content below fold -->
<BoxView HeightRequest="400" Color="Transparent" />
<Label
Text="Scroll down to see more..."
HorizontalOptions="Center"
TextColor="Gray" />
<BoxView HeightRequest="400" Color="Transparent" />
<!-- Element below the fold -->
<Frame
BackgroundColor="#E3F2FD"
Padding="20"
CornerRadius="8"
onboard:Onboarding.Title="Below the Fold"
onboard:Onboarding.Description="The tour automatically scrolled to show this element!"
onboard:Onboarding.Order="3"
onboard:Onboarding.Placement="Top">
<VerticalStackLayout Spacing="10">
<Label Text="Hidden Element" FontAttributes="Bold" FontSize="18" />
<Label Text="This element was below the visible area. The OnboardingHost automatically scrolled to bring it into view." />
</VerticalStackLayout>
</Frame>
<BoxView HeightRequest="200" Color="Transparent" />
<Frame
BackgroundColor="#FCE4EC"
Padding="20"
CornerRadius="8"
onboard:Onboarding.Title="Even Further Down"
onboard:Onboarding.Description="Scrolling works for any element in a ScrollView. Tour complete!"
onboard:Onboarding.Order="4"
onboard:Onboarding.Placement="Top">
<Label Text="Another hidden element at the bottom." HorizontalOptions="Center" />
</Frame>
<BoxView HeightRequest="100" Color="Transparent" />
</VerticalStackLayout>
</ScrollView>
<!-- Onboarding Host -->
<onboard:OnboardingHost
x:Name="OnboardingHost"
ShowCornerNavigator="True"
ShowArrow="True"
DimOpacity="0.7"
AnimationsEnabled="True" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,14 @@
namespace Test.SpotlightTour.Pages;
public partial class ScrollDemoPage : ContentPage
{
public ScrollDemoPage()
{
InitializeComponent();
}
private async void OnStartTourClicked(object? sender, EventArgs e)
{
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:onboard="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.ShapesPage"
Title="Spotlight Shapes">
<Grid>
<VerticalStackLayout Padding="20" Spacing="25">
<Label
Text="Spotlight Shapes"
FontSize="24"
FontAttributes="Bold" />
<Button
x:Name="StartTourBtn"
Text="Start Shapes Tour"
Clicked="OnStartTourClicked"
BackgroundColor="{StaticResource Primary}"
TextColor="White" />
<!-- Rectangle Shape -->
<Frame
BackgroundColor="#FFCDD2"
Padding="20"
CornerRadius="0"
onboard:Onboarding.Title="Rectangle Shape"
onboard:Onboarding.Description="SpotlightShape='Rectangle' creates a sharp-cornered cutout."
onboard:Onboarding.SpotlightShape="Rectangle"
onboard:Onboarding.Order="1">
<Label Text="Rectangle Spotlight" FontAttributes="Bold" HorizontalOptions="Center" />
</Frame>
<!-- RoundedRectangle Shape -->
<Frame
BackgroundColor="#C8E6C9"
Padding="20"
CornerRadius="12"
onboard:Onboarding.Title="Rounded Rectangle"
onboard:Onboarding.Description="SpotlightShape='RoundedRectangle' with SpotlightCornerRadius='12' for smooth corners."
onboard:Onboarding.SpotlightShape="RoundedRectangle"
onboard:Onboarding.SpotlightCornerRadius="12"
onboard:Onboarding.Order="2">
<Label Text="Rounded Rectangle Spotlight" FontAttributes="Bold" HorizontalOptions="Center" />
</Frame>
<!-- Circle Shape -->
<Grid
HeightRequest="120"
WidthRequest="120"
HorizontalOptions="Center"
onboard:Onboarding.Title="Circle Shape"
onboard:Onboarding.Description="SpotlightShape='Circle' creates a circular cutout around the element."
onboard:Onboarding.SpotlightShape="Circle"
onboard:Onboarding.SpotlightPadding="20"
onboard:Onboarding.Order="3">
<Ellipse
Fill="#BBDEFB"
WidthRequest="120"
HeightRequest="120" />
<Label
Text="Circle"
FontAttributes="Bold"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Grid>
</VerticalStackLayout>
<!-- Onboarding Host -->
<onboard:OnboardingHost
x:Name="OnboardingHost"
ShowCornerNavigator="True"
ShowArrow="True"
DimOpacity="0.75" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,14 @@
namespace Test.SpotlightTour.Pages;
public partial class ShapesPage : ContentPage
{
public ShapesPage()
{
InitializeComponent();
}
private async void OnStartTourClicked(object? sender, EventArgs e)
{
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,151 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:spot="clr-namespace:MarketAlly.MASpotlightTour;assembly=MarketAlly.MASpotlightTour"
x:Class="Test.SpotlightTour.Pages.ThemesPage"
Title="Light &amp; Dark Themes">
<Grid>
<ScrollView>
<VerticalStackLayout Padding="20" Spacing="20">
<Label Text="Theme Support"
FontSize="24"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="The tour components support light and dark themes to match your app's appearance."
FontSize="14"
TextColor="Gray"
HorizontalTextAlignment="Center" />
<BoxView HeightRequest="20" Color="Transparent" />
<!-- Light Theme -->
<Frame BackgroundColor="#FAFAFA" BorderColor="#E0E0E0" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<HorizontalStackLayout Spacing="8">
<BoxView WidthRequest="24" HeightRequest="24" Color="#FFFFFF" CornerRadius="4" />
<Label Text="Light Theme" FontAttributes="Bold" TextColor="#212121" FontSize="16" VerticalOptions="Center" />
</HorizontalStackLayout>
<Label Text="White backgrounds with dark text. Clean and bright appearance for light mode apps."
TextColor="#616161" FontSize="13" />
<Button Text="Try Light Theme"
BackgroundColor="#1976D2"
TextColor="White"
Clicked="OnLightThemeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<!-- Dark Theme -->
<Frame BackgroundColor="#1C1C1E" BorderColor="#3A3A3C" Padding="15" CornerRadius="8">
<VerticalStackLayout Spacing="8">
<HorizontalStackLayout Spacing="8">
<BoxView WidthRequest="24" HeightRequest="24" Color="#2C2C2E" CornerRadius="4" />
<Label Text="Dark Theme" FontAttributes="Bold" TextColor="White" FontSize="16" VerticalOptions="Center" />
</HorizontalStackLayout>
<Label Text="Dark backgrounds with light text. Easy on the eyes in low-light environments."
TextColor="#ABABAB" FontSize="13" />
<Button Text="Try Dark Theme"
BackgroundColor="#0A84FF"
TextColor="White"
Clicked="OnDarkThemeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<!-- System Theme -->
<Frame Padding="15" CornerRadius="8">
<Frame.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FAFAFA" Offset="0.0" />
<GradientStop Color="#1C1C1E" Offset="1.0" />
</LinearGradientBrush>
</Frame.Background>
<VerticalStackLayout Spacing="8">
<HorizontalStackLayout Spacing="8">
<Grid WidthRequest="24" HeightRequest="24">
<BoxView Color="#FFFFFF" CornerRadius="4" />
<BoxView Color="#2C2C2E" CornerRadius="4" Opacity="0.5" />
</Grid>
<Label Text="System Theme" FontAttributes="Bold" TextColor="#212121" FontSize="16" VerticalOptions="Center" />
</HorizontalStackLayout>
<Label Text="Automatically follows your device's light/dark mode setting."
TextColor="#616161" FontSize="13" />
<Button Text="Try System Theme"
BackgroundColor="#34C759"
TextColor="White"
Clicked="OnSystemThemeClicked"
CornerRadius="8" />
</VerticalStackLayout>
</Frame>
<BoxView HeightRequest="20" Color="Transparent" />
<!-- Sample elements to highlight -->
<Label Text="Sample Elements:"
FontSize="18"
FontAttributes="Bold" />
<Frame BackgroundColor="#E8F5E9"
Padding="20"
CornerRadius="12"
spot:Onboarding.StepKey="feature1"
spot:Onboarding.Title="Dashboard Overview"
spot:Onboarding.Description="Your dashboard shows key metrics and recent activity at a glance. The theme affects all tour UI elements."
spot:Onboarding.Order="1">
<VerticalStackLayout Spacing="8">
<Label Text="Dashboard" FontAttributes="Bold" FontSize="16" TextColor="#2E7D32" />
<Label Text="View your stats and analytics" TextColor="#4CAF50" FontSize="13" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#E3F2FD"
Padding="20"
CornerRadius="12"
spot:Onboarding.StepKey="feature2"
spot:Onboarding.Title="Settings Panel"
spot:Onboarding.Description="Customize your experience with various settings. Notice how the callout card matches the selected theme."
spot:Onboarding.Order="2">
<VerticalStackLayout Spacing="8">
<Label Text="Settings" FontAttributes="Bold" FontSize="16" TextColor="#1565C0" />
<Label Text="Personalize your preferences" TextColor="#2196F3" FontSize="13" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#FFF3E0"
Padding="20"
CornerRadius="12"
spot:Onboarding.StepKey="feature3"
spot:Onboarding.Title="Notifications"
spot:Onboarding.Description="Stay updated with real-time notifications. The corner navigator also adapts to the theme."
spot:Onboarding.Order="3">
<VerticalStackLayout Spacing="8">
<Label Text="Notifications" FontAttributes="Bold" FontSize="16" TextColor="#E65100" />
<Label Text="Never miss an update" TextColor="#FF9800" FontSize="13" />
</VerticalStackLayout>
</Frame>
<Frame BackgroundColor="#FCE4EC"
Padding="20"
CornerRadius="12"
spot:Onboarding.StepKey="feature4"
spot:Onboarding.Title="Profile Section"
spot:Onboarding.Description="Manage your profile and account settings here. All UI elements maintain theme consistency."
spot:Onboarding.Order="4">
<VerticalStackLayout Spacing="8">
<Label Text="Profile" FontAttributes="Bold" FontSize="16" TextColor="#AD1457" />
<Label Text="Your account details" TextColor="#E91E63" FontSize="13" />
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ScrollView>
<spot:OnboardingHost x:Name="OnboardingHost"
ShowCornerNavigator="True"
CornerNavigatorPlacement="BottomRight" />
</Grid>
</ContentPage>

View File

@@ -0,0 +1,32 @@
using MarketAlly.MASpotlightTour;
namespace Test.SpotlightTour.Pages;
public partial class ThemesPage : ContentPage
{
public ThemesPage()
{
InitializeComponent();
}
private async void OnLightThemeClicked(object? sender, EventArgs e)
{
OnboardingHost.Theme = TourTheme.Light;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnDarkThemeClicked(object? sender, EventArgs e)
{
OnboardingHost.Theme = TourTheme.Dark;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
private async void OnSystemThemeClicked(object? sender, EventArgs e)
{
OnboardingHost.Theme = TourTheme.System;
OnboardingHost.DisplayMode = TourDisplayMode.SpotlightWithCallout;
await OnboardingHost.StartTourAsync(this.Content);
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,11 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace Test.SpotlightTour
{
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
}

View File

@@ -0,0 +1,16 @@
using Android.App;
using Android.Runtime;
namespace Test.SpotlightTour
{
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@@ -0,0 +1,10 @@
using Foundation;
namespace Test.SpotlightTour
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace Test.SpotlightTour
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@@ -0,0 +1,17 @@
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
using System;
namespace Test.SpotlightTour
{
internal class Program : MauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
static void Main(string[] args)
{
var app = new Program();
app.Run(args);
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="9" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="Test.SpotlightTour.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="Test.SpotlightTour.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:Test.SpotlightTour.WinUI">
</maui:MauiWinUIApplication>

View File

@@ -0,0 +1,25 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Test.SpotlightTour.WinUI
{
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="C30BB1A4-3F1A-43C0-BA33-BCAC20DB2220" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Test.SpotlightTour.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,10 @@
using Foundation;
namespace Test.SpotlightTour
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace Test.SpotlightTour
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</Color>
<Color x:Key="Gray400">#919191</Color>
<Color x:Key="Gray500">#6E6E6E</Color>
<Color x:Key="Gray600">#404040</Color>
<Color x:Key="Gray900">#212121</Color>
<Color x:Key="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>

View File

@@ -0,0 +1,440 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="ListView">
<Setter Property="SeparatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="RefreshControlColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,71 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net9.0-android;net9.0-ios;net9.0-maccatalyst</TargetFrameworks>
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('windows'))">$(TargetFrameworks);net9.0-windows10.0.19041.0</TargetFrameworks>
<!-- Uncomment to also build the tizen app. You will need to install tizen by following this: https://github.com/Samsung/Tizen.NET -->
<!-- <TargetFrameworks>$(TargetFrameworks);net9.0-tizen</TargetFrameworks> -->
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>Test.SpotlightTour</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Display name -->
<ApplicationTitle>Test.SpotlightTour</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.test.spotlighttour</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">21.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'tizen'">6.5</SupportedOSPlatformVersion>
</PropertyGroup>
<ItemGroup>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MarketAlly.MASpotlightTour\MASpotlightTour.Maui.csproj" />
</ItemGroup>
</Project>