Files
maspotlighttour/MarketAlly.MASpotlightTour/CalloutCard.cs

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
}