564 lines
20 KiB
C#
564 lines
20 KiB
C#
using Microsoft.Maui.Controls.Shapes;
|
|
using MarketAlly.SpotlightTour.Maui.Animations;
|
|
using MarketAlly.SpotlightTour.Maui.Localization;
|
|
|
|
namespace MarketAlly.SpotlightTour.Maui;
|
|
|
|
/// <summary>
|
|
/// A 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() : this(TourConfiguration.Default) { }
|
|
|
|
public CalloutCard(TourConfiguration config)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(config);
|
|
|
|
BackgroundColor = Colors.White;
|
|
StrokeShape = new RoundRectangle { CornerRadius = (float)config.CalloutCornerRadius };
|
|
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 = TourStrings.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 = TourStrings.Next,
|
|
BackgroundColor = Color.FromArgb(config.PrimaryAccentColor),
|
|
TextColor = Colors.White,
|
|
CornerRadius = 8,
|
|
Padding = new Thickness(16, 8)
|
|
};
|
|
_nextButton.Clicked += (_, _) => NextClicked?.Invoke(this, EventArgs.Empty);
|
|
|
|
_closeButton = new Button
|
|
{
|
|
Text = TourStrings.Done,
|
|
BackgroundColor = Color.FromArgb(config.SuccessColor),
|
|
TextColor = Colors.White,
|
|
CornerRadius = 8,
|
|
Padding = new Thickness(16, 8),
|
|
IsVisible = false
|
|
};
|
|
_closeButton.Clicked += (_, _) => CloseClicked?.Invoke(this, EventArgs.Empty);
|
|
|
|
// Subscribe to culture changes
|
|
TourStrings.CultureChanged += OnCultureChanged;
|
|
|
|
_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)
|
|
{
|
|
// Ensure UI updates happen on the main thread
|
|
if (!MainThread.IsMainThread)
|
|
{
|
|
MainThread.BeginInvokeOnMainThread(() => UpdateForStep(title, description, isFirst, isLast));
|
|
return;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Ensure UI updates happen on the main thread
|
|
if (!MainThread.IsMainThread)
|
|
{
|
|
MainThread.BeginInvokeOnMainThread(() => PositionRelativeToSpotlight(spotlightRect, containerSize, placement, margin));
|
|
return;
|
|
}
|
|
|
|
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)
|
|
{
|
|
return SpotlightGeometry.DetermineAutoPlacement(spotlightRect, containerSize, cardHeight, margin);
|
|
}
|
|
|
|
private static double ClampVertical(double y, double height, double containerHeight, double margin)
|
|
{
|
|
return SpotlightGeometry.ClampVertical(y, height, containerHeight, margin);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
// Ensure UI updates happen on the main thread
|
|
if (!MainThread.IsMainThread)
|
|
{
|
|
MainThread.BeginInvokeOnMainThread(() => PositionInCorner(corner, containerSize, margin));
|
|
return;
|
|
}
|
|
|
|
// Guard against invalid container size - defer positioning
|
|
if (containerSize.Width <= 0 || containerSize.Height <= 0)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[CalloutCard] PositionInCorner: Invalid container size {containerSize}, deferring");
|
|
// Store the corner for later positioning when we have valid dimensions
|
|
_pendingCorner = corner;
|
|
_pendingMargin = margin;
|
|
return;
|
|
}
|
|
|
|
var cardWidth = Math.Min(containerSize.Width * 0.85, 400);
|
|
WidthRequest = cardWidth;
|
|
|
|
var measured = Measure(cardWidth, double.PositiveInfinity);
|
|
var cardHeight = Math.Max(measured.Height, 100); // Ensure minimum 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;
|
|
|
|
System.Diagnostics.Debug.WriteLine($"[CalloutCard] PositionInCorner: {corner}, container={containerSize}, pos=({x},{y})");
|
|
}
|
|
|
|
// For deferred corner positioning
|
|
private CalloutCorner? _pendingCorner;
|
|
private double _pendingMargin = 16;
|
|
|
|
/// <summary>
|
|
/// Applies any pending corner position when container size becomes available.
|
|
/// Call this from OnboardingHost when layout changes.
|
|
/// </summary>
|
|
public void ApplyPendingCornerPosition(Size containerSize)
|
|
{
|
|
if (_pendingCorner.HasValue && containerSize.Width > 0 && containerSize.Height > 0)
|
|
{
|
|
var corner = _pendingCorner.Value;
|
|
var margin = _pendingMargin;
|
|
_pendingCorner = null;
|
|
PositionInCorner(corner, containerSize, margin);
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
return SpotlightGeometry.DetermineBestCorner(spotlightRect, containerSize, cardWidth, cardHeight, margin);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates button text when culture changes.
|
|
/// </summary>
|
|
private void OnCultureChanged(object? sender, EventArgs e)
|
|
{
|
|
UpdateLocalizedText();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates all localized text in the callout card.
|
|
/// </summary>
|
|
public void UpdateLocalizedText()
|
|
{
|
|
_previousButton.Text = TourStrings.Previous;
|
|
_nextButton.Text = TourStrings.Next;
|
|
_closeButton.Text = TourStrings.Done;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies RTL or LTR flow direction to the callout card layout.
|
|
/// </summary>
|
|
/// <param name="isRtl">True for right-to-left layout.</param>
|
|
public void ApplyFlowDirection(bool isRtl)
|
|
{
|
|
// Ensure UI updates happen on the main thread
|
|
if (!MainThread.IsMainThread)
|
|
{
|
|
MainThread.BeginInvokeOnMainThread(() => ApplyFlowDirection(isRtl));
|
|
return;
|
|
}
|
|
|
|
// In RTL, we swap the button positions:
|
|
// LTR: [Previous (col 0)] [Next (col 1)] [Close (col 2)]
|
|
// RTL: [Close (col 0)] [Next (col 1)] [Previous (col 2)]
|
|
_buttonContainer.Children.Clear();
|
|
|
|
if (isRtl)
|
|
{
|
|
_buttonContainer.Add(_closeButton, 0);
|
|
_buttonContainer.Add(_nextButton, 1);
|
|
_buttonContainer.Add(_previousButton, 2);
|
|
}
|
|
else
|
|
{
|
|
_buttonContainer.Add(_previousButton, 0);
|
|
_buttonContainer.Add(_nextButton, 1);
|
|
_buttonContainer.Add(_closeButton, 2);
|
|
}
|
|
|
|
// Update text alignment for labels
|
|
_titleLabel.HorizontalTextAlignment = isRtl ? TextAlignment.End : TextAlignment.Start;
|
|
_descriptionLabel.HorizontalTextAlignment = isRtl ? TextAlignment.End : TextAlignment.Start;
|
|
}
|
|
|
|
#region Animations
|
|
|
|
/// <summary>
|
|
/// Shows the callout card with an entrance animation.
|
|
/// </summary>
|
|
/// <param name="config">Animation configuration to use.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public async Task ShowWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
|
|
{
|
|
config ??= AnimationConfiguration.Default;
|
|
await AnimationHelper.PlayEntranceAsync(this, config.CalloutEntrance, config, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides the callout card with an exit animation.
|
|
/// </summary>
|
|
/// <param name="config">Animation configuration to use.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public async Task HideWithAnimationAsync(AnimationConfiguration? config = null, CancellationToken cancellationToken = default)
|
|
{
|
|
config ??= AnimationConfiguration.Default;
|
|
await AnimationHelper.PlayExitAsync(this, config.CalloutExit, config, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Animates transition to new step content.
|
|
/// </summary>
|
|
/// <param name="config">Animation configuration to use.</param>
|
|
/// <param name="isForward">Whether transitioning forward (next) or backward (previous).</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
public async Task AnimateStepTransitionAsync(AnimationConfiguration? config = null, bool isForward = true, CancellationToken cancellationToken = default)
|
|
{
|
|
config ??= AnimationConfiguration.Default;
|
|
await AnimationHelper.AnimateStepTransitionAsync(this, config.StepTransition, isForward, config, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Plays an attention-grabbing animation.
|
|
/// </summary>
|
|
public async Task PulseAttentionAsync()
|
|
{
|
|
await AnimationHelper.HeartbeatAsync(this).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resets all animation properties to default.
|
|
/// </summary>
|
|
public void ResetAnimationState()
|
|
{
|
|
AnimationHelper.ResetElement(this);
|
|
}
|
|
|
|
#endregion
|
|
}
|