ironservices-maui/Controls/UserJourneyView.xaml.cs

902 lines
27 KiB
C#

using System.Collections.ObjectModel;
using System.Text;
using IronTelemetry.Client;
using Microsoft.Maui.Controls.Shapes;
namespace IronServices.Maui.Controls;
/// <summary>
/// A page for displaying user journeys captured by IronTelemetry with multiple view modes.
/// Supports Timeline, Tree, and Flow visualization of journey data.
/// </summary>
/// <example>
/// <code>
/// // Basic usage
/// var journeyView = new UserJourneyView
/// {
/// TelemetryClient = IronTelemetry.Client,
/// EnableLiveUpdates = true
/// };
/// await Navigation.PushAsync(journeyView);
///
/// // With filtering
/// var journeyView = new UserJourneyView
/// {
/// TelemetryClient = myClient,
/// ViewMode = JourneyViewMode.Tree,
/// UserIdFilter = currentUser.Id
/// };
/// </code>
/// </example>
public partial class UserJourneyView : ContentPage
{
private TelemetryClient? _telemetryClient;
private readonly ObservableCollection<JourneyItem> _journeys = new();
private JourneyItem? _selectedJourney;
private StepItem? _selectedStep;
private JourneyViewMode _viewMode = JourneyViewMode.Timeline;
private bool _enableLiveUpdates;
private Timer? _liveUpdateTimer;
private int _lastKnownItemCount;
/// <summary>
/// Creates a new UserJourneyView instance.
/// </summary>
public UserJourneyView()
{
InitializeComponent();
JourneyList.ItemsSource = _journeys;
}
#region Bindable Properties
/// <summary>
/// The TelemetryClient to pull journey data from.
/// </summary>
public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create(
nameof(TelemetryClient),
typeof(TelemetryClient),
typeof(UserJourneyView),
null,
propertyChanged: OnTelemetryClientChanged);
/// <summary>
/// Gets or sets the TelemetryClient used to retrieve journey data.
/// </summary>
public TelemetryClient? TelemetryClient
{
get => (TelemetryClient?)GetValue(TelemetryClientProperty);
set => SetValue(TelemetryClientProperty, value);
}
private static void OnTelemetryClientChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._telemetryClient = newValue as TelemetryClient;
view.RefreshJourneys();
view.UpdateLiveUpdatesSubscription();
}
/// <summary>
/// Current view mode (Timeline, Tree, or Flow).
/// </summary>
public static readonly BindableProperty ViewModeProperty = BindableProperty.Create(
nameof(ViewMode),
typeof(JourneyViewMode),
typeof(UserJourneyView),
JourneyViewMode.Timeline,
propertyChanged: OnViewModeChanged);
/// <summary>
/// Gets or sets the current view mode.
/// </summary>
public JourneyViewMode ViewMode
{
get => (JourneyViewMode)GetValue(ViewModeProperty);
set => SetValue(ViewModeProperty, value);
}
private static void OnViewModeChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._viewMode = (JourneyViewMode)newValue;
view.UpdateViewMode();
}
/// <summary>
/// Whether to automatically refresh when new items are added.
/// </summary>
public static readonly BindableProperty EnableLiveUpdatesProperty = BindableProperty.Create(
nameof(EnableLiveUpdates),
typeof(bool),
typeof(UserJourneyView),
false,
propertyChanged: OnEnableLiveUpdatesChanged);
/// <summary>
/// Gets or sets whether to automatically refresh the journey list.
/// </summary>
public bool EnableLiveUpdates
{
get => (bool)GetValue(EnableLiveUpdatesProperty);
set => SetValue(EnableLiveUpdatesProperty, value);
}
private static void OnEnableLiveUpdatesChanged(BindableObject bindable, object oldValue, object newValue)
{
var view = (UserJourneyView)bindable;
view._enableLiveUpdates = (bool)newValue;
view.UpdateLiveUpdatesSubscription();
}
/// <summary>
/// Whether to show the share button. Default: true.
/// </summary>
public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create(
nameof(ShowShareButton),
typeof(bool),
typeof(UserJourneyView),
true,
propertyChanged: (b, o, n) => ((UserJourneyView)b).ShareToolbarItem.IsEnabled = (bool)n);
/// <summary>
/// Gets or sets whether the Share toolbar button is visible.
/// </summary>
public bool ShowShareButton
{
get => (bool)GetValue(ShowShareButtonProperty);
set => SetValue(ShowShareButtonProperty, value);
}
/// <summary>
/// Whether to show the clear button. Default: true.
/// </summary>
public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create(
nameof(ShowClearButton),
typeof(bool),
typeof(UserJourneyView),
true,
propertyChanged: (b, o, n) => ((UserJourneyView)b).ClearToolbarItem.IsEnabled = (bool)n);
/// <summary>
/// Gets or sets whether the Clear toolbar button is visible.
/// </summary>
public bool ShowClearButton
{
get => (bool)GetValue(ShowClearButtonProperty);
set => SetValue(ShowClearButtonProperty, value);
}
/// <summary>
/// Filter to show only journeys matching this user ID.
/// </summary>
public static readonly BindableProperty UserIdFilterProperty = BindableProperty.Create(
nameof(UserIdFilter),
typeof(string),
typeof(UserJourneyView),
null,
propertyChanged: (b, o, n) => ((UserJourneyView)b).RefreshJourneys());
/// <summary>
/// Gets or sets a user ID to filter journeys by.
/// </summary>
public string? UserIdFilter
{
get => (string?)GetValue(UserIdFilterProperty);
set => SetValue(UserIdFilterProperty, value);
}
#endregion
#region Events
/// <summary>
/// Raised when a journey is selected from the list.
/// </summary>
public event EventHandler<JourneyItem>? JourneySelected;
/// <summary>
/// Raised when a step is selected from a journey.
/// </summary>
public event EventHandler<StepItem>? StepSelected;
/// <summary>
/// Raised when the journey list is refreshed.
/// </summary>
public event EventHandler? JourneysRefreshed;
/// <summary>
/// Raised when journeys are cleared.
/// </summary>
public event EventHandler? JourneysCleared;
/// <summary>
/// Raised when journeys are shared/exported.
/// </summary>
public event EventHandler? JourneysShared;
#endregion
#region Public Methods
/// <summary>
/// Refresh the journey list from the TelemetryClient's local log queue.
/// </summary>
public void RefreshJourneys()
{
_journeys.Clear();
if (_telemetryClient == null) return;
var localItems = _telemetryClient.GetLocalLogItems();
var journeys = JourneyReconstructor.ReconstructJourneys(localItems);
// Apply user filter if set
if (!string.IsNullOrEmpty(UserIdFilter))
{
journeys = journeys.Where(j => j.UserId == UserIdFilter).ToList();
}
// Sort by start time descending
foreach (var journey in journeys.OrderByDescending(j => j.StartTime))
{
_journeys.Add(journey);
}
UpdateCount();
UpdateViewForCurrentMode();
JourneysRefreshed?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Clear all displayed journeys.
/// </summary>
public void ClearJourneys()
{
_journeys.Clear();
HideDetailPanel();
UpdateCount();
JourneysCleared?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Clear all journeys including the TelemetryClient's local queue.
/// </summary>
public void ClearAllJourneys()
{
_telemetryClient?.ClearLocalLogItems();
ClearJourneys();
}
/// <summary>
/// Export all journeys as a formatted string.
/// </summary>
public string ExportJourneys()
{
var sb = new StringBuilder();
sb.AppendLine("=== User Journeys Export ===");
sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Total journeys: {_journeys.Count}");
sb.AppendLine();
foreach (var journey in _journeys)
{
ExportJourney(sb, journey);
}
return sb.ToString();
}
/// <summary>
/// Get read-only list of current journeys.
/// </summary>
public IReadOnlyList<JourneyItem> GetJourneys() => _journeys.ToList().AsReadOnly();
/// <summary>
/// Get the count of journeys.
/// </summary>
public int JourneyCount => _journeys.Count;
#endregion
#region Event Handlers
private void OnRefreshClicked(object? sender, EventArgs e)
{
RefreshJourneys();
}
private void OnClearClicked(object? sender, EventArgs e)
{
ClearJourneys();
}
private async void OnShareClicked(object? sender, EventArgs e)
{
if (_journeys.Count == 0)
{
await DisplayAlert("No Journeys", "There are no journeys to share.", "OK");
return;
}
var content = ExportJourneys();
await Share.Default.RequestAsync(new ShareTextRequest
{
Title = "User Journeys",
Text = content
});
JourneysShared?.Invoke(this, EventArgs.Empty);
}
private void OnTimelineSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Timeline;
}
private void OnTreeSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Tree;
}
private void OnFlowSelected(object? sender, EventArgs e)
{
ViewMode = JourneyViewMode.Flow;
}
private void OnJourneySelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is JourneyItem journey)
{
_selectedJourney = journey;
ShowDetailPanel(journey);
JourneySelected?.Invoke(this, journey);
}
}
private void OnStepSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is StepItem step)
{
_selectedStep = step;
StepSelected?.Invoke(this, step);
}
}
private async void OnExceptionSelected(object? sender, SelectionChangedEventArgs e)
{
if (e.CurrentSelection.FirstOrDefault() is ExceptionItem exception)
{
// Show exception details in an alert or copy to clipboard
var message = $"{exception.ExceptionType}: {exception.Message}";
if (exception.HasStackTrace)
{
message += $"\n\nStack Trace:\n{exception.StackTrace}";
}
await Clipboard.Default.SetTextAsync(message);
await DisplayAlert("Exception Copied", "Exception details copied to clipboard.", "OK");
}
}
private void OnCloseDetailClicked(object? sender, EventArgs e)
{
HideDetailPanel();
JourneyList.SelectedItem = null;
}
private async void OnCopyClicked(object? sender, EventArgs e)
{
if (_selectedJourney == null) return;
var sb = new StringBuilder();
ExportJourney(sb, _selectedJourney);
await Clipboard.Default.SetTextAsync(sb.ToString());
// Show brief feedback
var originalText = CopyButton.Text;
CopyButton.Text = "Copied!";
await Task.Delay(1500);
CopyButton.Text = originalText;
}
#endregion
#region Lifecycle
protected override void OnAppearing()
{
base.OnAppearing();
RefreshJourneys();
UpdateLiveUpdatesSubscription();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_liveUpdateTimer?.Dispose();
_liveUpdateTimer = null;
}
#endregion
#region View Mode Management
private void UpdateViewMode()
{
// Update button styles
TimelineBtn.BackgroundColor = _viewMode == JourneyViewMode.Timeline
? Color.FromArgb("#512BD4") : Colors.Transparent;
TimelineBtn.TextColor = _viewMode == JourneyViewMode.Timeline
? Colors.White : Color.FromArgb("#512BD4");
TreeBtn.BackgroundColor = _viewMode == JourneyViewMode.Tree
? Color.FromArgb("#512BD4") : Colors.Transparent;
TreeBtn.TextColor = _viewMode == JourneyViewMode.Tree
? Colors.White : Color.FromArgb("#512BD4");
FlowBtn.BackgroundColor = _viewMode == JourneyViewMode.Flow
? Color.FromArgb("#512BD4") : Colors.Transparent;
FlowBtn.TextColor = _viewMode == JourneyViewMode.Flow
? Colors.White : Color.FromArgb("#512BD4");
// Show/hide containers
JourneyList.IsVisible = _viewMode == JourneyViewMode.Timeline;
TreeViewContainer.IsVisible = _viewMode == JourneyViewMode.Tree;
FlowViewContainer.IsVisible = _viewMode == JourneyViewMode.Flow;
// Build view content
UpdateViewForCurrentMode();
}
private void UpdateViewForCurrentMode()
{
switch (_viewMode)
{
case JourneyViewMode.Tree:
BuildTreeView();
break;
case JourneyViewMode.Flow:
BuildFlowView();
break;
}
}
private void BuildTreeView()
{
TreeViewContent.Children.Clear();
foreach (var journey in _journeys)
{
var journeyNode = CreateTreeNode(journey);
TreeViewContent.Children.Add(journeyNode);
}
}
private View CreateTreeNode(JourneyItem journey)
{
var container = new VerticalStackLayout { Spacing = 2 };
// Journey header
var header = new Border
{
Padding = new Thickness(12, 8),
BackgroundColor = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#1F2937") : Colors.White,
Stroke = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#374151") : Color.FromArgb("#E5E7EB"),
StrokeShape = new RoundRectangle { CornerRadius = 8 }
};
var headerContent = new Grid
{
ColumnDefinitions = new ColumnDefinitionCollection
{
new ColumnDefinition(GridLength.Auto),
new ColumnDefinition(GridLength.Star),
new ColumnDefinition(GridLength.Auto)
}
};
// Expand/collapse indicator
var expandLabel = new Label
{
Text = journey.IsExpanded ? "-" : "+",
FontSize = 14,
FontAttributes = FontAttributes.Bold,
VerticalOptions = LayoutOptions.Center,
Margin = new Thickness(0, 0, 8, 0)
};
headerContent.Add(expandLabel, 0);
// Journey name and status
var nameStack = new HorizontalStackLayout { Spacing = 8 };
nameStack.Add(new Border
{
Padding = new Thickness(6, 2),
BackgroundColor = journey.StatusColor,
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 4 },
Content = new Label { Text = journey.StatusIcon, FontSize = 10, TextColor = Colors.White }
});
nameStack.Add(new Label { Text = journey.Name, FontSize = 14, FontAttributes = FontAttributes.Bold });
headerContent.Add(nameStack, 1);
// Duration
headerContent.Add(new Label
{
Text = journey.DurationDisplay,
FontSize = 12,
TextColor = Colors.Gray,
VerticalOptions = LayoutOptions.Center
}, 2);
header.Content = headerContent;
// Handle tap to expand/collapse
var tapGesture = new TapGestureRecognizer();
tapGesture.Tapped += (s, e) =>
{
journey.IsExpanded = !journey.IsExpanded;
expandLabel.Text = journey.IsExpanded ? "-" : "+";
BuildTreeView(); // Rebuild to show/hide children
};
header.GestureRecognizers.Add(tapGesture);
container.Add(header);
// Child steps (if expanded)
if (journey.IsExpanded && journey.Steps.Count > 0)
{
var stepsContainer = new VerticalStackLayout
{
Margin = new Thickness(20, 4, 0, 0),
Spacing = 2
};
foreach (var step in journey.Steps)
{
var stepNode = CreateStepTreeNode(step, 1);
stepsContainer.Add(stepNode);
}
container.Add(stepsContainer);
}
return container;
}
private View CreateStepTreeNode(StepItem step, int level)
{
var container = new VerticalStackLayout { Spacing = 2 };
var stepBorder = new Border
{
Padding = new Thickness(8, 4),
BackgroundColor = Application.Current?.RequestedTheme == AppTheme.Dark
? Color.FromArgb("#111827") : Color.FromArgb("#F9FAFB"),
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 4 }
};
var stepContent = new Grid
{
ColumnDefinitions = new ColumnDefinitionCollection
{
new ColumnDefinition(GridLength.Auto),
new ColumnDefinition(GridLength.Star),
new ColumnDefinition(GridLength.Auto)
}
};
// Status indicator
stepContent.Add(new Border
{
Padding = new Thickness(4, 2),
BackgroundColor = step.StatusColor,
StrokeThickness = 0,
StrokeShape = new RoundRectangle { CornerRadius = 2 },
Content = new Label { Text = step.StatusIcon, FontSize = 9, TextColor = Colors.White },
Margin = new Thickness(0, 0, 8, 0)
}, 0);
// Step name
stepContent.Add(new Label
{
Text = step.Name,
FontSize = 12,
VerticalOptions = LayoutOptions.Center
}, 1);
// Duration
stepContent.Add(new Label
{
Text = step.DurationDisplay,
FontSize = 10,
TextColor = Colors.Gray,
VerticalOptions = LayoutOptions.Center
}, 2);
stepBorder.Content = stepContent;
container.Add(stepBorder);
// Child steps
if (step.ChildSteps.Count > 0)
{
var childContainer = new VerticalStackLayout
{
Margin = new Thickness(16, 2, 0, 0),
Spacing = 2
};
foreach (var child in step.ChildSteps)
{
childContainer.Add(CreateStepTreeNode(child, level + 1));
}
container.Add(childContainer);
}
return container;
}
private void BuildFlowView()
{
// Flow view uses GraphicsView - implement custom drawable
// For now, show a placeholder message
FlowGraphicsView.Drawable = new FlowViewDrawable(_journeys.ToList());
FlowGraphicsView.Invalidate();
}
#endregion
#region Detail Panel
private void ShowDetailPanel(JourneyItem journey)
{
DetailTitle.Text = journey.Name;
DetailStatus.Text = journey.StatusDisplay;
DetailStatus.TextColor = journey.StatusColor;
DetailDuration.Text = journey.DurationDisplay;
if (!string.IsNullOrEmpty(journey.UserId))
{
DetailUser.Text = journey.UserId;
DetailUserContainer.IsVisible = true;
}
else
{
DetailUserContainer.IsVisible = false;
}
// Populate steps
var flattenedSteps = journey.GetFlattenedSteps();
StepsList.ItemsSource = flattenedSteps;
StepsSection.IsVisible = flattenedSteps.Count > 0;
// Populate breadcrumbs
BreadcrumbsList.ItemsSource = journey.Breadcrumbs;
BreadcrumbsSection.IsVisible = journey.Breadcrumbs.Count > 0;
// Populate exceptions
ExceptionsList.ItemsSource = journey.Exceptions;
ExceptionsSection.IsVisible = journey.Exceptions.Count > 0;
// Populate metadata
if (journey.Metadata.Count > 0)
{
MetadataLabel.Text = string.Join("\n", journey.Metadata.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
MetadataSection.IsVisible = true;
}
else
{
MetadataSection.IsVisible = false;
}
DetailPanel.IsVisible = true;
}
private void HideDetailPanel()
{
DetailPanel.IsVisible = false;
_selectedJourney = null;
_selectedStep = null;
}
#endregion
#region Live Updates
private void UpdateLiveUpdatesSubscription()
{
_liveUpdateTimer?.Dispose();
_liveUpdateTimer = null;
if (_enableLiveUpdates && _telemetryClient != null)
{
_lastKnownItemCount = _telemetryClient.GetLocalLogItems().Count;
_liveUpdateTimer = new Timer(CheckForNewItems, null, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(2));
}
}
private void CheckForNewItems(object? state)
{
if (_telemetryClient == null) return;
var currentCount = _telemetryClient.GetLocalLogItems().Count;
if (currentCount != _lastKnownItemCount)
{
_lastKnownItemCount = currentCount;
MainThread.BeginInvokeOnMainThread(RefreshJourneys);
}
}
#endregion
#region Helper Methods
private void UpdateCount()
{
CountLabel.Text = _journeys.Count == 1 ? "1 journey" : $"{_journeys.Count} journeys";
}
private void ExportJourney(StringBuilder sb, JourneyItem journey)
{
sb.AppendLine($"=== Journey: {journey.Name} ===");
sb.AppendLine($"ID: {journey.JourneyId}");
sb.AppendLine($"Status: {journey.StatusDisplay}");
sb.AppendLine($"Started: {journey.StartTime:yyyy-MM-dd HH:mm:ss UTC}");
if (journey.EndTime.HasValue)
sb.AppendLine($"Ended: {journey.EndTime:yyyy-MM-dd HH:mm:ss UTC}");
sb.AppendLine($"Duration: {journey.DurationDisplay}");
if (!string.IsNullOrEmpty(journey.UserId))
sb.AppendLine($"User: {journey.UserId} ({journey.UserEmail})");
sb.AppendLine();
if (journey.Steps.Count > 0)
{
sb.AppendLine("Steps:");
foreach (var step in journey.GetFlattenedSteps())
{
ExportStep(sb, step);
}
sb.AppendLine();
}
if (journey.Exceptions.Count > 0)
{
sb.AppendLine("Exceptions:");
foreach (var ex in journey.Exceptions)
{
sb.AppendLine($" [{ex.TimestampDisplay}] {ex.ExceptionType}: {ex.Message}");
if (!string.IsNullOrEmpty(ex.StackTrace))
{
foreach (var line in ex.StackTrace.Split('\n').Take(5))
{
sb.AppendLine($" {line.Trim()}");
}
sb.AppendLine(" ...");
}
}
sb.AppendLine();
}
if (journey.Breadcrumbs.Count > 0)
{
sb.AppendLine("Breadcrumbs:");
foreach (var bc in journey.Breadcrumbs)
{
sb.AppendLine($" [{bc.TimestampDisplay}] [{bc.Level}] {bc.Category}: {bc.Message}");
}
sb.AppendLine();
}
if (journey.Metadata.Count > 0)
{
sb.AppendLine("Metadata:");
foreach (var kvp in journey.Metadata)
{
sb.AppendLine($" {kvp.Key}: {kvp.Value}");
}
sb.AppendLine();
}
sb.AppendLine();
}
private void ExportStep(StringBuilder sb, StepItem step)
{
var indent = new string(' ', (step.NestingLevel + 1) * 2);
var statusIcon = step.Status switch
{
StepDisplayStatus.Completed => "[OK]",
StepDisplayStatus.Failed => "[FAIL]",
StepDisplayStatus.Skipped => "[SKIP]",
_ => "[...]"
};
sb.AppendLine($"{indent}{statusIcon} {step.Name} ({step.DurationDisplay})");
if (!string.IsNullOrEmpty(step.FailureReason))
sb.AppendLine($"{indent} Reason: {step.FailureReason}");
}
#endregion
}
#region Flow View Drawable
/// <summary>
/// Custom drawable for the Flow view visualization.
/// </summary>
public class FlowViewDrawable : IDrawable
{
private readonly List<JourneyItem> _journeys;
private const float JourneyHeight = 80;
private const float StepWidth = 120;
private const float StepHeight = 40;
private const float Padding = 20;
private const float StepGap = 20;
public FlowViewDrawable(List<JourneyItem> journeys)
{
_journeys = journeys;
}
public void Draw(ICanvas canvas, RectF dirtyRect)
{
canvas.FillColor = Colors.Transparent;
canvas.FillRectangle(dirtyRect);
float y = Padding;
foreach (var journey in _journeys)
{
DrawJourney(canvas, journey, Padding, y);
y += JourneyHeight + Padding;
}
}
private void DrawJourney(ICanvas canvas, JourneyItem journey, float x, float y)
{
// Draw journey header
canvas.FillColor = journey.StatusColor;
canvas.FillRoundedRectangle(x, y, StepWidth, StepHeight, 8);
canvas.FontColor = Colors.White;
canvas.FontSize = 12;
canvas.DrawString(journey.Name, x + 8, y + 8, StepWidth - 16, StepHeight - 16, HorizontalAlignment.Left, VerticalAlignment.Center);
// Draw steps
float stepX = x + StepWidth + StepGap;
var flatSteps = journey.GetFlattenedSteps().Where(s => s.NestingLevel == 0).ToList();
for (int i = 0; i < flatSteps.Count; i++)
{
var step = flatSteps[i];
// Draw connector line
canvas.StrokeColor = Colors.Gray;
canvas.StrokeSize = 2;
canvas.DrawLine(stepX - StepGap, y + StepHeight / 2, stepX, y + StepHeight / 2);
// Draw step box
canvas.FillColor = step.StatusColor.WithAlpha(0.2f);
canvas.FillRoundedRectangle(stepX, y, StepWidth, StepHeight, 4);
canvas.StrokeColor = step.StatusColor;
canvas.StrokeSize = 2;
canvas.DrawRoundedRectangle(stepX, y, StepWidth, StepHeight, 4);
canvas.FontColor = Colors.Black;
canvas.FontSize = 10;
canvas.DrawString(step.Name, stepX + 4, y + 4, StepWidth - 8, StepHeight - 8, HorizontalAlignment.Left, VerticalAlignment.Center);
stepX += StepWidth + StepGap;
}
}
}
#endregion