Initial commit: IronServices.Maui
Shared MAUI platform code for Iron Services: - Platform-specific implementations for Android, iOS, macOS, Windows - Secure storage integration - Device identification
This commit is contained in:
commit
ab1630b451
|
|
@ -0,0 +1,6 @@
|
|||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.DotSettings.user
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
<?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:controls="clr-namespace:IronServices.Maui.Controls"
|
||||
x:Class="IronServices.Maui.Controls.AppLogView"
|
||||
Title="App Logs">
|
||||
|
||||
<ContentPage.ToolbarItems>
|
||||
<ToolbarItem x:Name="ClearToolbarItem"
|
||||
Text="Clear"
|
||||
Order="Secondary"
|
||||
Clicked="OnClearClicked" />
|
||||
<ToolbarItem x:Name="ShareToolbarItem"
|
||||
Text="Share"
|
||||
Order="Primary"
|
||||
Clicked="OnShareClicked" />
|
||||
</ContentPage.ToolbarItems>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Summary Bar -->
|
||||
<Border Grid.Row="0"
|
||||
Padding="16,12"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray800}}"
|
||||
StrokeThickness="0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<Label x:Name="CountLabel"
|
||||
Text="0 items"
|
||||
FontSize="14"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}"
|
||||
VerticalOptions="Center" />
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Text="Refresh"
|
||||
Clicked="OnRefreshClicked"
|
||||
BackgroundColor="Transparent"
|
||||
TextColor="{StaticResource Primary}"
|
||||
FontSize="14"
|
||||
Padding="8,4" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Log List -->
|
||||
<CollectionView x:Name="LogList"
|
||||
Grid.Row="1"
|
||||
ItemsSource="{Binding}"
|
||||
SelectionMode="Single"
|
||||
SelectionChanged="OnLogSelected">
|
||||
<CollectionView.EmptyView>
|
||||
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Padding="32">
|
||||
<Label Text="No logs captured"
|
||||
FontSize="18"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}"
|
||||
HorizontalOptions="Center" />
|
||||
<Label Text="Exceptions and telemetry events will appear here"
|
||||
FontSize="14"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
Margin="0,8,0,0" />
|
||||
</VerticalStackLayout>
|
||||
</CollectionView.EmptyView>
|
||||
<CollectionView.ItemTemplate>
|
||||
<DataTemplate x:DataType="controls:LogItem">
|
||||
<Border Padding="12"
|
||||
Margin="8,4"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray900}}"
|
||||
Stroke="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray700}}"
|
||||
StrokeShape="RoundRectangle 8">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto" ColumnDefinitions="Auto,*,Auto">
|
||||
<!-- Type Badge -->
|
||||
<Border Grid.Row="0" Grid.Column="0"
|
||||
Padding="6,2"
|
||||
StrokeThickness="0"
|
||||
BackgroundColor="{Binding TypeColor}"
|
||||
StrokeShape="RoundRectangle 4"
|
||||
VerticalOptions="Start">
|
||||
<Label Text="{Binding TypeDisplay}"
|
||||
FontSize="10"
|
||||
TextColor="White"
|
||||
FontAttributes="Bold" />
|
||||
</Border>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<Label Grid.Row="0" Grid.Column="2"
|
||||
Text="{Binding TimestampDisplay}"
|
||||
FontSize="11"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
|
||||
VerticalOptions="Center" />
|
||||
|
||||
<!-- Title -->
|
||||
<Label Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3"
|
||||
Text="{Binding Title}"
|
||||
FontSize="14"
|
||||
FontAttributes="Bold"
|
||||
LineBreakMode="TailTruncation"
|
||||
MaxLines="1"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<!-- Message -->
|
||||
<Label Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3"
|
||||
Text="{Binding Message}"
|
||||
FontSize="12"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}"
|
||||
LineBreakMode="TailTruncation"
|
||||
MaxLines="2"
|
||||
Margin="0,4,0,0" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</CollectionView.ItemTemplate>
|
||||
</CollectionView>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
<Border x:Name="DetailPanel"
|
||||
Grid.Row="2"
|
||||
IsVisible="False"
|
||||
Padding="16"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource Gray100}, Dark={StaticResource Gray800}}"
|
||||
Stroke="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"
|
||||
StrokeShape="Rectangle">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<Label x:Name="DetailTitle"
|
||||
FontSize="16"
|
||||
FontAttributes="Bold"
|
||||
LineBreakMode="TailTruncation" />
|
||||
<Button Grid.Column="1"
|
||||
Text="Close"
|
||||
Clicked="OnCloseDetailClicked"
|
||||
BackgroundColor="Transparent"
|
||||
TextColor="{StaticResource Primary}"
|
||||
FontSize="12"
|
||||
Padding="8,4" />
|
||||
</Grid>
|
||||
|
||||
<!-- Message (full error details) -->
|
||||
<ScrollView Grid.Row="1" MaximumHeightRequest="100" Margin="0,8">
|
||||
<Label x:Name="DetailMessage"
|
||||
FontSize="12"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray700}, Dark={StaticResource Gray300}}" />
|
||||
</ScrollView>
|
||||
|
||||
<!-- Stack Trace -->
|
||||
<ScrollView Grid.Row="2" MaximumHeightRequest="150" Margin="0,8">
|
||||
<Label x:Name="DetailStackTrace"
|
||||
FontFamily="Consolas"
|
||||
FontSize="11"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}" />
|
||||
</ScrollView>
|
||||
|
||||
<Button Grid.Row="3"
|
||||
Text="Copy Full Log"
|
||||
Clicked="OnCopyStackTraceClicked"
|
||||
BackgroundColor="{StaticResource Primary}"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
HeightRequest="40" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</ContentPage>
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using IronTelemetry.Client;
|
||||
|
||||
namespace IronServices.Maui.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A page for displaying captured app logs (exceptions, telemetry events) with share and clear functionality.
|
||||
/// Use within a NavigationPage for proper toolbar display.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// // Navigate to the log view
|
||||
/// await Navigation.PushAsync(new AppLogView
|
||||
/// {
|
||||
/// TelemetryClient = myTelemetryClient
|
||||
/// });
|
||||
///
|
||||
/// // Or with custom title
|
||||
/// await Navigation.PushAsync(new AppLogView
|
||||
/// {
|
||||
/// TelemetryClient = myTelemetryClient,
|
||||
/// Title = "Error Logs"
|
||||
/// });
|
||||
/// </code>
|
||||
/// </example>
|
||||
public partial class AppLogView : ContentPage
|
||||
{
|
||||
private TelemetryClient? _telemetryClient;
|
||||
private readonly ObservableCollection<LogItem> _logItems = new();
|
||||
private LogItem? _selectedItem;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new AppLogView instance.
|
||||
/// </summary>
|
||||
public AppLogView()
|
||||
{
|
||||
InitializeComponent();
|
||||
LogList.BindingContext = _logItems;
|
||||
}
|
||||
|
||||
#region Bindable Properties
|
||||
|
||||
/// <summary>
|
||||
/// The TelemetryClient to pull queued logs from. When set, automatically refreshes the log list.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TelemetryClientProperty = BindableProperty.Create(
|
||||
nameof(TelemetryClient),
|
||||
typeof(TelemetryClient),
|
||||
typeof(AppLogView),
|
||||
null,
|
||||
propertyChanged: OnTelemetryClientChanged);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the TelemetryClient used to retrieve queued log items.
|
||||
/// </summary>
|
||||
public TelemetryClient? TelemetryClient
|
||||
{
|
||||
get => (TelemetryClient?)GetValue(TelemetryClientProperty);
|
||||
set => SetValue(TelemetryClientProperty, value);
|
||||
}
|
||||
|
||||
private static void OnTelemetryClientChanged(BindableObject bindable, object oldValue, object newValue)
|
||||
{
|
||||
var view = (AppLogView)bindable;
|
||||
view._telemetryClient = newValue as TelemetryClient;
|
||||
view.RefreshLogs();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show the share toolbar item. Default: true.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ShowShareButtonProperty = BindableProperty.Create(
|
||||
nameof(ShowShareButton),
|
||||
typeof(bool),
|
||||
typeof(AppLogView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((AppLogView)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 toolbar item. Default: true.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ShowClearButtonProperty = BindableProperty.Create(
|
||||
nameof(ShowClearButton),
|
||||
typeof(bool),
|
||||
typeof(AppLogView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((AppLogView)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);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
/// <summary>
|
||||
/// Raised when logs are cleared via the Clear button.
|
||||
/// </summary>
|
||||
public event EventHandler? LogsCleared;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when logs are shared via the Share button.
|
||||
/// </summary>
|
||||
public event EventHandler? LogsShared;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a log item is selected from the list.
|
||||
/// </summary>
|
||||
public event EventHandler<LogItem>? LogSelected;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the log list is refreshed.
|
||||
/// </summary>
|
||||
public event EventHandler? LogsRefreshed;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Public Methods
|
||||
|
||||
/// <summary>
|
||||
/// Refresh the log list from the telemetry client's local log queue.
|
||||
/// Call this after adding new logs or when the queue may have changed.
|
||||
/// </summary>
|
||||
public void RefreshLogs()
|
||||
{
|
||||
_logItems.Clear();
|
||||
|
||||
if (_telemetryClient != null)
|
||||
{
|
||||
// Get all locally captured log items (not just failed ones)
|
||||
var localItems = _telemetryClient.GetLocalLogItems();
|
||||
|
||||
foreach (var item in localItems.OrderByDescending(i => i.Timestamp))
|
||||
{
|
||||
_logItems.Add(LogItem.FromEnvelopeItem(item));
|
||||
}
|
||||
}
|
||||
|
||||
UpdateCount();
|
||||
LogsRefreshed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a log item manually (for local-only logging without TelemetryClient).
|
||||
/// </summary>
|
||||
/// <param name="type">Log type: "exception", "message", "info", "warning", etc.</param>
|
||||
/// <param name="title">Title or exception type name.</param>
|
||||
/// <param name="message">Log message or exception message.</param>
|
||||
/// <param name="stackTrace">Optional stack trace for exceptions.</param>
|
||||
public void AddLog(string type, string title, string message, string? stackTrace = null)
|
||||
{
|
||||
var item = new LogItem
|
||||
{
|
||||
Type = type,
|
||||
Title = title,
|
||||
Message = message,
|
||||
StackTrace = stackTrace,
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_logItems.Insert(0, item);
|
||||
UpdateCount();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an exception as a log entry.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to log.</param>
|
||||
public void AddException(Exception ex)
|
||||
{
|
||||
AddLog("exception", ex.GetType().Name, ex.Message, ex.StackTrace);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all displayed logs. Does not clear the TelemetryClient's offline queue.
|
||||
/// </summary>
|
||||
public void ClearLogs()
|
||||
{
|
||||
_logItems.Clear();
|
||||
HideDetail();
|
||||
UpdateCount();
|
||||
LogsCleared?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all logs including the TelemetryClient's local log queue.
|
||||
/// </summary>
|
||||
public void ClearAllLogs()
|
||||
{
|
||||
_telemetryClient?.ClearLocalLogItems();
|
||||
_telemetryClient?.OfflineQueue?.Clear();
|
||||
ClearLogs();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export all logs as a formatted string suitable for sharing or saving.
|
||||
/// </summary>
|
||||
/// <returns>Formatted log export string.</returns>
|
||||
public string ExportLogs()
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"=== App Logs Export ===");
|
||||
sb.AppendLine($"Exported: {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"Total items: {_logItems.Count}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var item in _logItems)
|
||||
{
|
||||
sb.AppendLine($"--- [{item.Type.ToUpperInvariant()}] {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC} ---");
|
||||
sb.AppendLine($"Title: {item.Title}");
|
||||
sb.AppendLine($"Message: {item.Message}");
|
||||
|
||||
if (!string.IsNullOrEmpty(item.StackTrace))
|
||||
{
|
||||
sb.AppendLine("Stack Trace:");
|
||||
sb.AppendLine(item.StackTrace);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current log items as a read-only list.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogItem> GetLogs() => _logItems.ToList().AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// Get the count of log items.
|
||||
/// </summary>
|
||||
public int LogCount => _logItems.Count;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private void OnClearClicked(object? sender, EventArgs e)
|
||||
{
|
||||
ClearLogs();
|
||||
}
|
||||
|
||||
private void OnRefreshClicked(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshLogs();
|
||||
}
|
||||
|
||||
private async void OnShareClicked(object? sender, EventArgs e)
|
||||
{
|
||||
if (_logItems.Count == 0)
|
||||
{
|
||||
await DisplayAlert("No Logs", "There are no logs to share.", "OK");
|
||||
return;
|
||||
}
|
||||
|
||||
var content = ExportLogs();
|
||||
|
||||
await Share.Default.RequestAsync(new ShareTextRequest
|
||||
{
|
||||
Title = "App Logs",
|
||||
Text = content
|
||||
});
|
||||
|
||||
LogsShared?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private async void OnLogSelected(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (e.CurrentSelection.FirstOrDefault() is LogItem item)
|
||||
{
|
||||
_selectedItem = item;
|
||||
ShowDetail(item);
|
||||
LogSelected?.Invoke(this, item);
|
||||
|
||||
// Copy log entry to clipboard
|
||||
await CopyLogToClipboardAsync(item);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a log item's details to the clipboard.
|
||||
/// </summary>
|
||||
private async Task CopyLogToClipboardAsync(LogItem item)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("=== Exception Log ===");
|
||||
sb.AppendLine($"Type: {item.Type.ToUpperInvariant()}");
|
||||
sb.AppendLine($"Time: {item.Timestamp:yyyy-MM-dd HH:mm:ss UTC}");
|
||||
sb.AppendLine($"Title: {item.Title}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Message ---");
|
||||
sb.AppendLine(item.Message);
|
||||
|
||||
if (!string.IsNullOrEmpty(item.JourneyId))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Journey ID: {item.JourneyId}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(item.UserId))
|
||||
{
|
||||
sb.AppendLine($"User ID: {item.UserId}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(item.StackTrace))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("--- Stack Trace ---");
|
||||
sb.AppendLine(item.StackTrace);
|
||||
}
|
||||
|
||||
await Clipboard.Default.SetTextAsync(sb.ToString());
|
||||
|
||||
// Show brief toast/feedback
|
||||
await MainThread.InvokeOnMainThreadAsync(async () =>
|
||||
{
|
||||
var originalText = CountLabel.Text;
|
||||
CountLabel.Text = "Copied to clipboard!";
|
||||
CountLabel.TextColor = Colors.Green;
|
||||
await Task.Delay(1500);
|
||||
CountLabel.Text = originalText;
|
||||
CountLabel.TextColor = Application.Current?.RequestedTheme == AppTheme.Dark
|
||||
? Color.FromArgb("#9CA3AF")
|
||||
: Color.FromArgb("#6B7280");
|
||||
});
|
||||
}
|
||||
|
||||
private void OnCloseDetailClicked(object? sender, EventArgs e)
|
||||
{
|
||||
HideDetail();
|
||||
LogList.SelectedItem = null;
|
||||
}
|
||||
|
||||
private async void OnCopyStackTraceClicked(object? sender, EventArgs e)
|
||||
{
|
||||
if (_selectedItem != null)
|
||||
{
|
||||
await CopyLogToClipboardAsync(_selectedItem);
|
||||
|
||||
if (sender is Button button)
|
||||
{
|
||||
var originalText = button.Text;
|
||||
button.Text = "Copied!";
|
||||
await Task.Delay(1000);
|
||||
button.Text = originalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override void OnAppearing()
|
||||
{
|
||||
base.OnAppearing();
|
||||
RefreshLogs();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void UpdateCount()
|
||||
{
|
||||
CountLabel.Text = _logItems.Count == 1 ? "1 item" : $"{_logItems.Count} items";
|
||||
}
|
||||
|
||||
private void ShowDetail(LogItem item)
|
||||
{
|
||||
DetailTitle.Text = item.Title;
|
||||
DetailMessage.Text = item.Message ?? "No message available";
|
||||
DetailStackTrace.Text = item.StackTrace ?? "No stack trace available";
|
||||
DetailPanel.IsVisible = true;
|
||||
}
|
||||
|
||||
private void HideDetail()
|
||||
{
|
||||
DetailPanel.IsVisible = false;
|
||||
_selectedItem = null;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a log item for display in AppLogView.
|
||||
/// </summary>
|
||||
public class LogItem
|
||||
{
|
||||
/// <summary>
|
||||
/// UTC timestamp when the log was captured.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Log type: "exception", "message", "journey_start", "journey_end", "step_start", "step_end", "info", "warning".
|
||||
/// </summary>
|
||||
public string Type { get; set; } = "info";
|
||||
|
||||
/// <summary>
|
||||
/// Title or exception type name.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Log message or exception message.
|
||||
/// </summary>
|
||||
public string Message { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Stack trace for exceptions.
|
||||
/// </summary>
|
||||
public string? StackTrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated journey ID if part of a user journey.
|
||||
/// </summary>
|
||||
public string? JourneyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Associated user ID.
|
||||
/// </summary>
|
||||
public string? UserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Formatted timestamp for display (local time, HH:mm:ss).
|
||||
/// </summary>
|
||||
public string TimestampDisplay => Timestamp.ToLocalTime().ToString("HH:mm:ss");
|
||||
|
||||
/// <summary>
|
||||
/// Short type label for display badge.
|
||||
/// </summary>
|
||||
public string TypeDisplay => Type switch
|
||||
{
|
||||
"exception" => "ERROR",
|
||||
"message" => "MSG",
|
||||
"journey_start" => "START",
|
||||
"journey_end" => "END",
|
||||
"step_start" => "STEP",
|
||||
"step_end" => "STEP",
|
||||
"warning" => "WARN",
|
||||
"info" => "INFO",
|
||||
_ => Type.ToUpperInvariant()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Color for type badge.
|
||||
/// </summary>
|
||||
public Color TypeColor => Type switch
|
||||
{
|
||||
"exception" => Colors.Red,
|
||||
"warning" => Colors.Orange,
|
||||
"message" => Colors.Blue,
|
||||
"info" => Colors.Teal,
|
||||
"journey_start" => Colors.Green,
|
||||
"journey_end" => Colors.Green,
|
||||
"step_start" => Colors.Purple,
|
||||
"step_end" => Colors.Purple,
|
||||
_ => Colors.Gray
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Create a LogItem from a TelemetryClient EnvelopeItem.
|
||||
/// </summary>
|
||||
public static LogItem FromEnvelopeItem(EnvelopeItem item)
|
||||
{
|
||||
return new LogItem
|
||||
{
|
||||
Timestamp = item.Timestamp,
|
||||
Type = item.Type ?? "unknown",
|
||||
Title = item.ExceptionType ?? item.Name ?? item.Type ?? "Log Entry",
|
||||
Message = item.Message ?? "",
|
||||
StackTrace = item.StackTrace,
|
||||
JourneyId = item.JourneyId,
|
||||
UserId = item.UserId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="IronServices.Maui.Controls.LicenseActivationView">
|
||||
|
||||
<Border Padding="24"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray900}}"
|
||||
Stroke="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}"
|
||||
StrokeShape="RoundRectangle 12">
|
||||
<Border.Shadow>
|
||||
<Shadow Brush="Black" Offset="0,2" Radius="8" Opacity="0.15" />
|
||||
</Border.Shadow>
|
||||
<VerticalStackLayout Spacing="16">
|
||||
|
||||
<!-- Icon -->
|
||||
<Label Text="🔑"
|
||||
FontSize="48"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Title -->
|
||||
<Label x:Name="TitleLabel"
|
||||
Text="Activate License"
|
||||
FontSize="24"
|
||||
FontAttributes="Bold"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Subtitle -->
|
||||
<Label x:Name="SubtitleLabel"
|
||||
Text="Enter your license key to activate"
|
||||
FontSize="14"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
|
||||
HorizontalOptions="Center"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<!-- License Key Entry -->
|
||||
<Entry x:Name="LicenseKeyEntry"
|
||||
Placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||
FontFamily="Consolas"
|
||||
FontSize="16"
|
||||
HorizontalTextAlignment="Center"
|
||||
MaxLength="36"
|
||||
Keyboard="Plain"
|
||||
ReturnType="Done" />
|
||||
|
||||
<!-- Status Display -->
|
||||
<Border x:Name="StatusFrame"
|
||||
IsVisible="False"
|
||||
Padding="12"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 8">
|
||||
<HorizontalStackLayout Spacing="8" HorizontalOptions="Center">
|
||||
<Label x:Name="StatusIcon" FontSize="20" />
|
||||
<Label x:Name="StatusLabel" FontSize="14" VerticalOptions="Center" />
|
||||
</HorizontalStackLayout>
|
||||
</Border>
|
||||
|
||||
<!-- Error Message -->
|
||||
<Label x:Name="ErrorLabel"
|
||||
TextColor="Red"
|
||||
FontSize="12"
|
||||
HorizontalOptions="Center"
|
||||
HorizontalTextAlignment="Center"
|
||||
IsVisible="False" />
|
||||
|
||||
<!-- Activate Button -->
|
||||
<Button x:Name="ActivateButton"
|
||||
Text="Activate"
|
||||
Clicked="OnActivateClicked"
|
||||
HeightRequest="48"
|
||||
CornerRadius="8"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<ActivityIndicator x:Name="LoadingIndicator"
|
||||
IsRunning="False"
|
||||
IsVisible="False"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Help Link -->
|
||||
<Label x:Name="HelpLink"
|
||||
Text="Need help finding your license key?"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource Primary}"
|
||||
TextDecorations="Underline"
|
||||
HorizontalOptions="Center">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="OnHelpTapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
</ContentView>
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
using IronLicensing.Client;
|
||||
using IronLicensing.Client.Models;
|
||||
|
||||
namespace IronServices.Maui.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A reusable license activation view for IronLicensing.
|
||||
/// </summary>
|
||||
public partial class LicenseActivationView : ContentView
|
||||
{
|
||||
private ILicenseManager? _licenseManager;
|
||||
|
||||
public LicenseActivationView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
#region Bindable Properties
|
||||
|
||||
/// <summary>
|
||||
/// The ILicenseManager to use for license validation.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LicenseManagerProperty = BindableProperty.Create(
|
||||
nameof(LicenseManager),
|
||||
typeof(ILicenseManager),
|
||||
typeof(LicenseActivationView),
|
||||
null,
|
||||
propertyChanged: (b, o, n) => ((LicenseActivationView)b)._licenseManager = n as ILicenseManager);
|
||||
|
||||
public ILicenseManager? LicenseManager
|
||||
{
|
||||
get => (ILicenseManager?)GetValue(LicenseManagerProperty);
|
||||
set => SetValue(LicenseManagerProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleProperty = BindableProperty.Create(
|
||||
nameof(Title),
|
||||
typeof(string),
|
||||
typeof(LicenseActivationView),
|
||||
"Activate License",
|
||||
propertyChanged: (b, o, n) => ((LicenseActivationView)b).TitleLabel.Text = n as string);
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subtitle text.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(
|
||||
nameof(Subtitle),
|
||||
typeof(string),
|
||||
typeof(LicenseActivationView),
|
||||
"Enter your license key to activate",
|
||||
propertyChanged: (b, o, n) => ((LicenseActivationView)b).SubtitleLabel.Text = n as string);
|
||||
|
||||
public string Subtitle
|
||||
{
|
||||
get => (string)GetValue(SubtitleProperty);
|
||||
set => SetValue(SubtitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-filled license key.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LicenseKeyProperty = BindableProperty.Create(
|
||||
nameof(LicenseKey),
|
||||
typeof(string),
|
||||
typeof(LicenseActivationView),
|
||||
null,
|
||||
BindingMode.TwoWay,
|
||||
propertyChanged: (b, o, n) => ((LicenseActivationView)b).LicenseKeyEntry.Text = n as string);
|
||||
|
||||
public string? LicenseKey
|
||||
{
|
||||
get => (string?)GetValue(LicenseKeyProperty);
|
||||
set => SetValue(LicenseKeyProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show the help link.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ShowHelpLinkProperty = BindableProperty.Create(
|
||||
nameof(ShowHelpLink),
|
||||
typeof(bool),
|
||||
typeof(LicenseActivationView),
|
||||
true,
|
||||
propertyChanged: (b, o, n) => ((LicenseActivationView)b).HelpLink.IsVisible = (bool)n);
|
||||
|
||||
public bool ShowHelpLink
|
||||
{
|
||||
get => (bool)GetValue(ShowHelpLinkProperty);
|
||||
set => SetValue(ShowHelpLinkProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// URL to open when help link is tapped.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty HelpUrlProperty = BindableProperty.Create(
|
||||
nameof(HelpUrl),
|
||||
typeof(string),
|
||||
typeof(LicenseActivationView),
|
||||
"https://ironservices.io/docs/licensing");
|
||||
|
||||
public string HelpUrl
|
||||
{
|
||||
get => (string)GetValue(HelpUrlProperty);
|
||||
set => SetValue(HelpUrlProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
/// <summary>
|
||||
/// Raised when license activation is successful.
|
||||
/// </summary>
|
||||
public event EventHandler<LicenseActivatedEventArgs>? LicenseActivated;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when license activation fails.
|
||||
/// </summary>
|
||||
public event EventHandler<LicenseActivationFailedEventArgs>? ActivationFailed;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when help link is tapped.
|
||||
/// </summary>
|
||||
public event EventHandler? HelpRequested;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private async void OnActivateClicked(object sender, EventArgs e)
|
||||
{
|
||||
if (_licenseManager == null)
|
||||
{
|
||||
ShowError("License manager not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
var licenseKey = LicenseKeyEntry.Text?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(licenseKey))
|
||||
{
|
||||
ShowError("Please enter a license key");
|
||||
return;
|
||||
}
|
||||
|
||||
SetLoading(true);
|
||||
HideError();
|
||||
HideStatus();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _licenseManager.ActivateAsync(licenseKey);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
ShowStatus(true, "License activated successfully!");
|
||||
LicenseActivated?.Invoke(this, new LicenseActivatedEventArgs(result));
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowError(result.ErrorMessage ?? "Invalid license key");
|
||||
ActivationFailed?.Invoke(this, new LicenseActivationFailedEventArgs(result.ErrorMessage ?? "Invalid license key"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowError($"Error: {ex.Message}");
|
||||
ActivationFailed?.Invoke(this, new LicenseActivationFailedEventArgs(ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnHelpTapped(object sender, EventArgs e)
|
||||
{
|
||||
HelpRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
await Browser.OpenAsync(HelpUrl, BrowserLaunchMode.SystemPreferred);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore if can't open browser
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private void SetLoading(bool isLoading)
|
||||
{
|
||||
ActivateButton.IsEnabled = !isLoading;
|
||||
ActivateButton.IsVisible = !isLoading;
|
||||
LoadingIndicator.IsRunning = isLoading;
|
||||
LoadingIndicator.IsVisible = isLoading;
|
||||
LicenseKeyEntry.IsEnabled = !isLoading;
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorLabel.Text = message;
|
||||
ErrorLabel.IsVisible = true;
|
||||
}
|
||||
|
||||
private void HideError()
|
||||
{
|
||||
ErrorLabel.IsVisible = false;
|
||||
}
|
||||
|
||||
private void ShowStatus(bool success, string message)
|
||||
{
|
||||
StatusFrame.IsVisible = true;
|
||||
StatusFrame.BackgroundColor = success
|
||||
? Color.FromArgb("#D1FAE5") // Green background
|
||||
: Color.FromArgb("#FEE2E2"); // Red background
|
||||
StatusIcon.Text = success ? "✓" : "✗";
|
||||
StatusIcon.TextColor = success ? Colors.Green : Colors.Red;
|
||||
StatusLabel.Text = message;
|
||||
StatusLabel.TextColor = success ? Colors.Green : Colors.Red;
|
||||
}
|
||||
|
||||
private void HideStatus()
|
||||
{
|
||||
StatusFrame.IsVisible = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the current license without activating a new one.
|
||||
/// </summary>
|
||||
public async Task<bool> ValidateAsync()
|
||||
{
|
||||
if (_licenseManager == null) return false;
|
||||
|
||||
var licenseKey = LicenseKeyEntry.Text?.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _licenseManager.ValidateAsync(licenseKey);
|
||||
return result.Success;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for successful license activation.
|
||||
/// </summary>
|
||||
public class LicenseActivatedEventArgs : EventArgs
|
||||
{
|
||||
public LicenseResult Result { get; }
|
||||
public LicenseInfo? License => Result.License;
|
||||
|
||||
public LicenseActivatedEventArgs(LicenseResult result)
|
||||
{
|
||||
Result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for failed license activation.
|
||||
/// </summary>
|
||||
public class LicenseActivationFailedEventArgs : EventArgs
|
||||
{
|
||||
public string Error { get; }
|
||||
|
||||
public LicenseActivationFailedEventArgs(string error)
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||
x:Class="IronServices.Maui.Controls.LoginView"
|
||||
x:Name="ThisView">
|
||||
|
||||
<ScrollView>
|
||||
<VerticalStackLayout Spacing="2" Padding="2" VerticalOptions="Center"
|
||||
WidthRequest="{Binding FormMaxWidth, Source={x:Reference ThisView}}"
|
||||
HorizontalOptions="Center">
|
||||
|
||||
<!-- Logo Badge (when LogoText is set) -->
|
||||
<Border BackgroundColor="{Binding LogoBackgroundColor, Source={x:Reference ThisView}, TargetNullValue={StaticResource Primary}}"
|
||||
StrokeThickness="0"
|
||||
StrokeShape="RoundRectangle 16"
|
||||
HeightRequest="80"
|
||||
WidthRequest="80"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding LogoText, Source={x:Reference ThisView}, Converter={StaticResource NullToBoolConverter}}">
|
||||
<Label Text="{Binding LogoText, Source={x:Reference ThisView}}"
|
||||
FontSize="32"
|
||||
FontAttributes="Bold"
|
||||
TextColor="White"
|
||||
HorizontalOptions="Center"
|
||||
VerticalOptions="Center" />
|
||||
</Border>
|
||||
|
||||
<!-- Logo Image (when Logo is set) -->
|
||||
<Image Source="{Binding Logo, Source={x:Reference ThisView}}"
|
||||
HeightRequest="80"
|
||||
Aspect="AspectFit"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding Logo, Source={x:Reference ThisView}, Converter={StaticResource NullToBoolConverter}}" />
|
||||
|
||||
<!-- Title -->
|
||||
<Label Text="{Binding Title, Source={x:Reference ThisView}}"
|
||||
FontSize="28"
|
||||
FontAttributes="Bold"
|
||||
HorizontalOptions="Center"
|
||||
TextColor="{StaticResource Primary}" />
|
||||
|
||||
<!-- Subtitle -->
|
||||
<Label Text="{Binding Subtitle, Source={x:Reference ThisView}}"
|
||||
FontSize="14"
|
||||
TextColor="{StaticResource Gray500}"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Login Form Card -->
|
||||
<Border Stroke="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray700}}"
|
||||
StrokeThickness="1"
|
||||
StrokeShape="RoundRectangle 12"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray800}}"
|
||||
Padding="30"
|
||||
Margin="0,6,0,0">
|
||||
<VerticalStackLayout Spacing="14">
|
||||
|
||||
<!-- Email Field -->
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="Email"
|
||||
FontSize="12"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}" />
|
||||
<Border Stroke="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"
|
||||
StrokeShape="RoundRectangle 8"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray700}}"
|
||||
Padding="12,8">
|
||||
<Entry Text="{Binding Email, Source={x:Reference ThisView}}"
|
||||
Placeholder="you@company.com"
|
||||
Keyboard="Email"
|
||||
ReturnType="Next"
|
||||
IsEnabled="{Binding IsBusy, Source={x:Reference ThisView}, Converter={StaticResource InvertedBoolConverter}}" />
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Password Field -->
|
||||
<VerticalStackLayout Spacing="4">
|
||||
<Label Text="Password"
|
||||
FontSize="12"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray600}, Dark={StaticResource Gray400}}" />
|
||||
<Border Stroke="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"
|
||||
StrokeShape="RoundRectangle 8"
|
||||
BackgroundColor="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray700}}"
|
||||
Padding="12,8">
|
||||
<Entry Text="{Binding Password, Source={x:Reference ThisView}}"
|
||||
Placeholder="Enter your password"
|
||||
IsPassword="True"
|
||||
ReturnType="Done"
|
||||
IsEnabled="{Binding IsBusy, Source={x:Reference ThisView}, Converter={StaticResource InvertedBoolConverter}}" />
|
||||
</Border>
|
||||
</VerticalStackLayout>
|
||||
|
||||
<!-- Error Message -->
|
||||
<Label Text="{Binding ErrorMessage, Source={x:Reference ThisView}}"
|
||||
TextColor="Red"
|
||||
FontSize="12"
|
||||
IsVisible="{Binding ErrorMessage, Source={x:Reference ThisView}, Converter={StaticResource StringToBoolConverter}}"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Login Button -->
|
||||
<Button Text="{Binding IsBusy, Source={x:Reference ThisView}, Converter={StaticResource BoolToTextConverter}, ConverterParameter='Signing in...|Sign In'}"
|
||||
Clicked="OnLoginClicked"
|
||||
IsEnabled="{Binding IsBusy, Source={x:Reference ThisView}, Converter={StaticResource InvertedBoolConverter}}"
|
||||
BackgroundColor="{StaticResource Primary}"
|
||||
TextColor="White"
|
||||
CornerRadius="8"
|
||||
HeightRequest="48"
|
||||
FontAttributes="Bold"
|
||||
Margin="0,8,0,0" />
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<ActivityIndicator IsRunning="{Binding IsBusy, Source={x:Reference ThisView}}"
|
||||
IsVisible="{Binding IsBusy, Source={x:Reference ThisView}}"
|
||||
Color="{StaticResource Primary}"
|
||||
HorizontalOptions="Center" />
|
||||
|
||||
<!-- Register Link -->
|
||||
<HorizontalStackLayout HorizontalOptions="Center"
|
||||
Spacing="4"
|
||||
IsVisible="{Binding ShowRegisterLink, Source={x:Reference ThisView}}">
|
||||
<Label Text="Don't have an account?"
|
||||
FontSize="12"
|
||||
TextColor="{AppThemeBinding Light={StaticResource Gray500}, Dark={StaticResource Gray400}}"
|
||||
VerticalOptions="Center" />
|
||||
<Label Text="Sign Up"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource Primary}"
|
||||
TextDecorations="Underline"
|
||||
VerticalOptions="Center">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="OnRegisterTapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
</HorizontalStackLayout>
|
||||
|
||||
<!-- Forgot Password Link -->
|
||||
<Label Text="Forgot Password?"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource Primary}"
|
||||
TextDecorations="Underline"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding ShowForgotPasswordLink, Source={x:Reference ThisView}}">
|
||||
<Label.GestureRecognizers>
|
||||
<TapGestureRecognizer Tapped="OnForgotPasswordTapped" />
|
||||
</Label.GestureRecognizers>
|
||||
</Label>
|
||||
|
||||
</VerticalStackLayout>
|
||||
</Border>
|
||||
|
||||
<!-- Footer -->
|
||||
<Label Text="{Binding FooterText, Source={x:Reference ThisView}}"
|
||||
FontSize="12"
|
||||
TextColor="{StaticResource Gray400}"
|
||||
HorizontalOptions="Center"
|
||||
IsVisible="{Binding FooterText, Source={x:Reference ThisView}, Converter={StaticResource NullToBoolConverter}}"
|
||||
Margin="0,24,0,0" />
|
||||
|
||||
</VerticalStackLayout>
|
||||
</ScrollView>
|
||||
|
||||
</ContentView>
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
using System.Windows.Input;
|
||||
using IronServices.Client;
|
||||
|
||||
namespace IronServices.Maui.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// A reusable login view for IronServices authentication.
|
||||
/// Supports both MVVM command bindings and event-based usage.
|
||||
/// </summary>
|
||||
public partial class LoginView : ContentView
|
||||
{
|
||||
private IronServicesClient? _client;
|
||||
|
||||
public LoginView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
#region Bindable Properties - Configuration
|
||||
|
||||
/// <summary>
|
||||
/// The IronServicesClient to use for authentication (event-based mode).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ClientProperty = BindableProperty.Create(
|
||||
nameof(Client),
|
||||
typeof(IronServicesClient),
|
||||
typeof(LoginView),
|
||||
null,
|
||||
propertyChanged: (b, o, n) => ((LoginView)b)._client = n as IronServicesClient);
|
||||
|
||||
public IronServicesClient? Client
|
||||
{
|
||||
get => (IronServicesClient?)GetValue(ClientProperty);
|
||||
set => SetValue(ClientProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The title text displayed above the login form.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty TitleProperty = BindableProperty.Create(
|
||||
nameof(Title),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
"Sign In");
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The subtitle text displayed below the title.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty SubtitleProperty = BindableProperty.Create(
|
||||
nameof(Subtitle),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
"Enter your credentials to continue");
|
||||
|
||||
public string Subtitle
|
||||
{
|
||||
get => (string)GetValue(SubtitleProperty);
|
||||
set => SetValue(SubtitleProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The logo image source.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LogoProperty = BindableProperty.Create(
|
||||
nameof(Logo),
|
||||
typeof(ImageSource),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public ImageSource? Logo
|
||||
{
|
||||
get => (ImageSource?)GetValue(LogoProperty);
|
||||
set => SetValue(LogoProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Text to display in the logo badge (e.g., "IL" for IronLicensing).
|
||||
/// Used when Logo is not set.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LogoTextProperty = BindableProperty.Create(
|
||||
nameof(LogoText),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public string? LogoText
|
||||
{
|
||||
get => (string?)GetValue(LogoTextProperty);
|
||||
set => SetValue(LogoTextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background color for the logo badge.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LogoBackgroundColorProperty = BindableProperty.Create(
|
||||
nameof(LogoBackgroundColor),
|
||||
typeof(Color),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public Color? LogoBackgroundColor
|
||||
{
|
||||
get => (Color?)GetValue(LogoBackgroundColorProperty);
|
||||
set => SetValue(LogoBackgroundColorProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Footer text displayed below the form.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FooterTextProperty = BindableProperty.Create(
|
||||
nameof(FooterText),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public string? FooterText
|
||||
{
|
||||
get => (string?)GetValue(FooterTextProperty);
|
||||
set => SetValue(FooterTextProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum width of the form. Default is -1 (no limit).
|
||||
/// </summary>
|
||||
public static readonly BindableProperty FormMaxWidthProperty = BindableProperty.Create(
|
||||
nameof(FormMaxWidth),
|
||||
typeof(double),
|
||||
typeof(LoginView),
|
||||
-1d);
|
||||
|
||||
public double FormMaxWidth
|
||||
{
|
||||
get => (double)GetValue(FormMaxWidthProperty);
|
||||
set => SetValue(FormMaxWidthProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show the register link.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ShowRegisterLinkProperty = BindableProperty.Create(
|
||||
nameof(ShowRegisterLink),
|
||||
typeof(bool),
|
||||
typeof(LoginView),
|
||||
false);
|
||||
|
||||
public bool ShowRegisterLink
|
||||
{
|
||||
get => (bool)GetValue(ShowRegisterLinkProperty);
|
||||
set => SetValue(ShowRegisterLinkProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether to show the forgot password link.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ShowForgotPasswordLinkProperty = BindableProperty.Create(
|
||||
nameof(ShowForgotPasswordLink),
|
||||
typeof(bool),
|
||||
typeof(LoginView),
|
||||
false);
|
||||
|
||||
public bool ShowForgotPasswordLink
|
||||
{
|
||||
get => (bool)GetValue(ShowForgotPasswordLinkProperty);
|
||||
set => SetValue(ShowForgotPasswordLinkProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bindable Properties - MVVM Data
|
||||
|
||||
/// <summary>
|
||||
/// The email address entered by the user.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty EmailProperty = BindableProperty.Create(
|
||||
nameof(Email),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
string.Empty,
|
||||
BindingMode.TwoWay);
|
||||
|
||||
public string Email
|
||||
{
|
||||
get => (string)GetValue(EmailProperty);
|
||||
set => SetValue(EmailProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The password entered by the user.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty PasswordProperty = BindableProperty.Create(
|
||||
nameof(Password),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
string.Empty,
|
||||
BindingMode.TwoWay);
|
||||
|
||||
public string Password
|
||||
{
|
||||
get => (string)GetValue(PasswordProperty);
|
||||
set => SetValue(PasswordProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error message to display.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ErrorMessageProperty = BindableProperty.Create(
|
||||
nameof(ErrorMessage),
|
||||
typeof(string),
|
||||
typeof(LoginView),
|
||||
string.Empty);
|
||||
|
||||
public string ErrorMessage
|
||||
{
|
||||
get => (string)GetValue(ErrorMessageProperty);
|
||||
set => SetValue(ErrorMessageProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a login operation is in progress.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty IsBusyProperty = BindableProperty.Create(
|
||||
nameof(IsBusy),
|
||||
typeof(bool),
|
||||
typeof(LoginView),
|
||||
false);
|
||||
|
||||
public bool IsBusy
|
||||
{
|
||||
get => (bool)GetValue(IsBusyProperty);
|
||||
set => SetValue(IsBusyProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bindable Properties - Commands
|
||||
|
||||
/// <summary>
|
||||
/// Command to execute when the login button is pressed.
|
||||
/// If set, takes precedence over event-based login.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty LoginCommandProperty = BindableProperty.Create(
|
||||
nameof(LoginCommand),
|
||||
typeof(ICommand),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public ICommand? LoginCommand
|
||||
{
|
||||
get => (ICommand?)GetValue(LoginCommandProperty);
|
||||
set => SetValue(LoginCommandProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to execute when the register link is tapped.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty RegisterCommandProperty = BindableProperty.Create(
|
||||
nameof(RegisterCommand),
|
||||
typeof(ICommand),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public ICommand? RegisterCommand
|
||||
{
|
||||
get => (ICommand?)GetValue(RegisterCommandProperty);
|
||||
set => SetValue(RegisterCommandProperty, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Command to execute when the forgot password link is tapped.
|
||||
/// </summary>
|
||||
public static readonly BindableProperty ForgotPasswordCommandProperty = BindableProperty.Create(
|
||||
nameof(ForgotPasswordCommand),
|
||||
typeof(ICommand),
|
||||
typeof(LoginView),
|
||||
null);
|
||||
|
||||
public ICommand? ForgotPasswordCommand
|
||||
{
|
||||
get => (ICommand?)GetValue(ForgotPasswordCommandProperty);
|
||||
set => SetValue(ForgotPasswordCommandProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
|
||||
/// <summary>
|
||||
/// Raised when login is successful (event-based mode only).
|
||||
/// </summary>
|
||||
public event EventHandler<LoginSuccessEventArgs>? LoginSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when login fails (event-based mode only).
|
||||
/// </summary>
|
||||
public event EventHandler<LoginFailedEventArgs>? LoginFailed;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the register link is tapped (if RegisterCommand is not set).
|
||||
/// </summary>
|
||||
public event EventHandler? RegisterRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the forgot password link is tapped (if ForgotPasswordCommand is not set).
|
||||
/// </summary>
|
||||
public event EventHandler? ForgotPasswordRequested;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers
|
||||
|
||||
private async void OnLoginClicked(object sender, EventArgs e)
|
||||
{
|
||||
// If a command is bound, use it
|
||||
if (LoginCommand != null)
|
||||
{
|
||||
if (LoginCommand.CanExecute(null))
|
||||
{
|
||||
LoginCommand.Execute(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use event-based login with Client
|
||||
if (_client == null)
|
||||
{
|
||||
ErrorMessage = "Client not configured";
|
||||
return;
|
||||
}
|
||||
|
||||
var email = Email?.Trim();
|
||||
var password = Password;
|
||||
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
ErrorMessage = "Please enter your email";
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
ErrorMessage = "Please enter your password";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
ErrorMessage = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _client.LoginAsync(email, password);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
LoginSuccess?.Invoke(this, new LoginSuccessEventArgs(result));
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = result.Error ?? "Login failed";
|
||||
LoginFailed?.Invoke(this, new LoginFailedEventArgs(result.Error ?? "Login failed"));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"Error: {ex.Message}";
|
||||
LoginFailed?.Invoke(this, new LoginFailedEventArgs(ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRegisterTapped(object sender, EventArgs e)
|
||||
{
|
||||
if (RegisterCommand != null && RegisterCommand.CanExecute(null))
|
||||
{
|
||||
RegisterCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
RegisterRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnForgotPasswordTapped(object sender, EventArgs e)
|
||||
{
|
||||
if (ForgotPasswordCommand != null && ForgotPasswordCommand.CanExecute(null))
|
||||
{
|
||||
ForgotPasswordCommand.Execute(null);
|
||||
}
|
||||
else
|
||||
{
|
||||
ForgotPasswordRequested?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for successful login.
|
||||
/// </summary>
|
||||
public class LoginSuccessEventArgs : EventArgs
|
||||
{
|
||||
public LoginResult Result { get; }
|
||||
public string? UserId => Result.UserId;
|
||||
public string? Email => Result.Email;
|
||||
public string? DisplayName => Result.DisplayName;
|
||||
|
||||
public LoginSuccessEventArgs(LoginResult result)
|
||||
{
|
||||
Result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for failed login.
|
||||
/// </summary>
|
||||
public class LoginFailedEventArgs : EventArgs
|
||||
{
|
||||
public string Error { get; }
|
||||
|
||||
public LoginFailedEventArgs(string error)
|
||||
{
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
using IronServices.Client;
|
||||
using IronServices.Maui.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace IronServices.Maui;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering IronServices with MAUI dependency injection.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds IronServicesClient with secure token storage for MAUI apps using default URLs.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddIronServices(this IServiceCollection services)
|
||||
{
|
||||
return services.AddIronServices(_ => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds IronServicesClient with secure token storage and custom configuration.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="configure">Configuration action for the client options.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddIronServices(this IServiceCollection services, Action<IronServicesClientOptions> configure)
|
||||
{
|
||||
var options = new IronServicesClientOptions();
|
||||
configure(options);
|
||||
|
||||
services.AddSingleton<ITokenStorage, MauiSecureTokenStorage>();
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var tokenStorage = sp.GetRequiredService<ITokenStorage>();
|
||||
return new IronServicesClient(options, tokenStorage);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<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>
|
||||
<MauiEnableXamlCHotReload>true</MauiEnableXamlCHotReload>
|
||||
<!-- Suppress XC0022/XC0025: Bindings with Source={x:Reference} to self cannot use compiled bindings -->
|
||||
<NoWarn>$(NoWarn);XC0022;XC0025</NoWarn>
|
||||
|
||||
<RootNamespace>IronServices.Maui</RootNamespace>
|
||||
<AssemblyName>IronServices.Maui</AssemblyName>
|
||||
<Description>Control library library for IronServices API (IronLicensing, IronTelemetry and IronNotify)</Description>
|
||||
<Authors>David H Friedel Jr</Authors>
|
||||
<Company>MarketAlly</Company>
|
||||
<PackageId>IronServices.Maui</PackageId>
|
||||
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
|
||||
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\IronServices.Client\IronServices.Client.csproj" />
|
||||
<ProjectReference Include="..\IronLicensing.Client\IronLicensing.Client.csproj" />
|
||||
<ProjectReference Include="..\IronTelemetry.Client\IronTelemetry.Client.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 IronServices
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace IronServices.Maui
|
||||
{
|
||||
// All the code in this file is only included on Android.
|
||||
public class PlatformClass1
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace IronServices.Maui
|
||||
{
|
||||
// All the code in this file is only included on Mac Catalyst.
|
||||
public class PlatformClass1
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
using System;
|
||||
|
||||
namespace IronServices.Maui
|
||||
{
|
||||
// All the code in this file is only included on Tizen.
|
||||
public class PlatformClass1
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace IronServices.Maui
|
||||
{
|
||||
// All the code in this file is only included on Windows.
|
||||
public class PlatformClass1
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace IronServices.Maui
|
||||
{
|
||||
// All the code in this file is only included on iOS.
|
||||
public class PlatformClass1
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
# IronServices.Maui
|
||||
|
||||
MAUI controls and services for IronServices (IronLicensing, IronNotify, IronTelemetry).
|
||||
|
||||
## Installation
|
||||
|
||||
```xml
|
||||
<PackageReference Include="IronServices.Maui" Version="1.0.0" />
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Register Services
|
||||
|
||||
```csharp
|
||||
// MauiProgram.cs
|
||||
builder.Services.AddIronServices();
|
||||
|
||||
// Or with custom URLs
|
||||
builder.Services.AddIronServices(options =>
|
||||
{
|
||||
options.LicensingUrl = "https://ironlicensing.com";
|
||||
options.NotifyUrl = "https://ironnotify.com";
|
||||
options.TelemetryUrl = "https://irontelemetry.com";
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Use Controls
|
||||
|
||||
```xaml
|
||||
<ContentPage xmlns:iron="clr-namespace:IronServices.Maui.Controls;assembly=IronServices.Maui">
|
||||
<iron:LoginView Client="{Binding IronClient}" LoginSuccess="OnLoginSuccess" />
|
||||
</ContentPage>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controls
|
||||
|
||||
### LoginView
|
||||
|
||||
A drop-in login form for IronServices authentication.
|
||||
|
||||
**Type:** `ContentView` (embeddable)
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `Client` | `IronServicesClient` | `null` | **Required.** The client instance for authentication. |
|
||||
| `Title` | `string` | `"Sign In"` | Header text above the form. |
|
||||
| `Subtitle` | `string` | `"Enter your credentials..."` | Subheader text. |
|
||||
| `Logo` | `ImageSource` | `null` | Optional logo image. |
|
||||
| `ShowRegisterLink` | `bool` | `true` | Show "Sign Up" link. |
|
||||
| `ShowForgotPasswordLink` | `bool` | `true` | Show "Forgot Password?" link. |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Args | Description |
|
||||
|-------|------|-------------|
|
||||
| `LoginSuccess` | `LoginSuccessEventArgs` | Fired on successful login. Contains `UserId`, `Email`, `DisplayName`. |
|
||||
| `LoginFailed` | `LoginFailedEventArgs` | Fired on failed login. Contains `Error` message. |
|
||||
| `RegisterRequested` | `EventArgs` | User tapped "Sign Up" link. |
|
||||
| `ForgotPasswordRequested` | `EventArgs` | User tapped "Forgot Password?" link. |
|
||||
|
||||
#### Example
|
||||
|
||||
```csharp
|
||||
// XAML
|
||||
<iron:LoginView x:Name="LoginView"
|
||||
Client="{Binding IronClient}"
|
||||
Title="Welcome Back"
|
||||
Logo="logo.png"
|
||||
ShowRegisterLink="True"
|
||||
LoginSuccess="OnLoginSuccess"
|
||||
RegisterRequested="OnRegisterRequested" />
|
||||
|
||||
// Code-behind
|
||||
private async void OnLoginSuccess(object sender, LoginSuccessEventArgs e)
|
||||
{
|
||||
await DisplayAlert("Success", $"Welcome, {e.DisplayName}!", "OK");
|
||||
await Shell.Current.GoToAsync("//main");
|
||||
}
|
||||
|
||||
private async void OnRegisterRequested(object sender, EventArgs e)
|
||||
{
|
||||
await Shell.Current.GoToAsync("//register");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### LicenseActivationView
|
||||
|
||||
A license key entry and activation form.
|
||||
|
||||
**Type:** `ContentView` (embeddable)
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `LicenseManager` | `ILicenseManager` | `null` | **Required.** The license manager instance. |
|
||||
| `Title` | `string` | `"Activate License"` | Header text. |
|
||||
| `Subtitle` | `string` | `"Enter your license key..."` | Subheader text. |
|
||||
| `LicenseKey` | `string` | `null` | Pre-filled license key (two-way binding). |
|
||||
| `ShowHelpLink` | `bool` | `true` | Show help link below form. |
|
||||
| `HelpUrl` | `string` | `"https://..."` | URL opened when help tapped. |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Args | Description |
|
||||
|-------|------|-------------|
|
||||
| `LicenseActivated` | `LicenseActivatedEventArgs` | Activation succeeded. Contains `License` info. |
|
||||
| `ActivationFailed` | `LicenseActivationFailedEventArgs` | Activation failed. Contains `Error` message. |
|
||||
| `HelpRequested` | `EventArgs` | User tapped help link. |
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `ValidateAsync()` | `Task<bool>` | Validate the entered license key without activating. |
|
||||
|
||||
#### Example
|
||||
|
||||
```csharp
|
||||
// XAML
|
||||
<iron:LicenseActivationView x:Name="ActivationView"
|
||||
LicenseManager="{Binding LicenseManager}"
|
||||
LicenseActivated="OnLicenseActivated" />
|
||||
|
||||
// Code-behind
|
||||
private async void OnLicenseActivated(object sender, LicenseActivatedEventArgs e)
|
||||
{
|
||||
var license = e.License;
|
||||
await DisplayAlert("Activated",
|
||||
$"License: {license.Tier}\nExpires: {license.ExpiresAt:d}", "OK");
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### AppLogView
|
||||
|
||||
A full-page log viewer for exceptions and telemetry events. Shows queued items from `TelemetryClient.OfflineQueue`.
|
||||
|
||||
**Type:** `ContentPage` (standalone page, use with NavigationPage)
|
||||
|
||||
#### Properties
|
||||
|
||||
| Property | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `TelemetryClient` | `TelemetryClient` | `null` | Client to pull queued logs from. Auto-refreshes on set. |
|
||||
| `Title` | `string` | `"App Logs"` | Page title (shown in navigation bar). |
|
||||
| `ShowShareButton` | `bool` | `true` | Enable Share toolbar button. |
|
||||
| `ShowClearButton` | `bool` | `true` | Enable Clear toolbar button. |
|
||||
|
||||
#### Events
|
||||
|
||||
| Event | Args | Description |
|
||||
|-------|------|-------------|
|
||||
| `LogsCleared` | `EventArgs` | User cleared the log list. |
|
||||
| `LogsShared` | `EventArgs` | User shared the logs. |
|
||||
| `LogSelected` | `LogItem` | User selected a log entry. |
|
||||
| `LogsRefreshed` | `EventArgs` | Log list was refreshed. |
|
||||
|
||||
#### Methods
|
||||
|
||||
| Method | Returns | Description |
|
||||
|--------|---------|-------------|
|
||||
| `RefreshLogs()` | `void` | Reload logs from TelemetryClient queue. |
|
||||
| `AddLog(type, title, message, stackTrace?)` | `void` | Add a manual log entry. |
|
||||
| `AddException(Exception)` | `void` | Add an exception as a log entry. |
|
||||
| `ClearLogs()` | `void` | Clear displayed logs (not the queue). |
|
||||
| `ClearAllLogs()` | `void` | Clear displayed logs AND the offline queue. |
|
||||
| `ExportLogs()` | `string` | Export all logs as formatted text. |
|
||||
| `GetLogs()` | `IReadOnlyList<LogItem>` | Get current log items. |
|
||||
| `LogCount` | `int` | Number of log items. |
|
||||
|
||||
#### LogItem Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `Timestamp` | `DateTime` | UTC timestamp. |
|
||||
| `Type` | `string` | `"exception"`, `"message"`, `"info"`, `"warning"`, etc. |
|
||||
| `Title` | `string` | Exception type name or message title. |
|
||||
| `Message` | `string` | Log message. |
|
||||
| `StackTrace` | `string?` | Stack trace for exceptions. |
|
||||
| `JourneyId` | `string?` | Associated user journey ID. |
|
||||
| `UserId` | `string?` | Associated user ID. |
|
||||
|
||||
#### Example
|
||||
|
||||
```csharp
|
||||
// Navigate to log view
|
||||
await Navigation.PushAsync(new AppLogView
|
||||
{
|
||||
TelemetryClient = _telemetryClient,
|
||||
Title = "Error Logs"
|
||||
});
|
||||
|
||||
// With event handling
|
||||
var logView = new AppLogView { TelemetryClient = _telemetryClient };
|
||||
logView.LogsCleared += (s, e) => Debug.WriteLine("Logs cleared");
|
||||
logView.LogSelected += (s, item) => Debug.WriteLine($"Selected: {item.Title}");
|
||||
await Navigation.PushAsync(logView);
|
||||
|
||||
// Manual log entries (without TelemetryClient)
|
||||
var logView = new AppLogView();
|
||||
logView.AddException(ex);
|
||||
logView.AddLog("warning", "Network Issue", "Connection timeout after 30s");
|
||||
```
|
||||
|
||||
#### Toolbar
|
||||
|
||||
- **Share** (Primary): Exports logs as text and opens system share sheet.
|
||||
- **Clear** (Secondary/overflow menu): Clears displayed logs.
|
||||
- **Refresh** button in summary bar: Reloads from queue.
|
||||
|
||||
---
|
||||
|
||||
## Services
|
||||
|
||||
### MauiSecureTokenStorage
|
||||
|
||||
Secure token storage using MAUI `SecureStorage`. Automatically registered when using `AddIronServices()`.
|
||||
|
||||
```csharp
|
||||
// Automatically used by IronServicesClient
|
||||
builder.Services.AddIronServices();
|
||||
|
||||
// Or manually
|
||||
services.AddSingleton<ITokenStorage, MauiSecureTokenStorage>();
|
||||
```
|
||||
|
||||
### ServiceCollectionExtensions
|
||||
|
||||
```csharp
|
||||
// Default URLs
|
||||
services.AddIronServices();
|
||||
|
||||
// Custom URLs
|
||||
services.AddIronServices(options =>
|
||||
{
|
||||
options.LicensingUrl = "https://licensing.myapp.com";
|
||||
options.NotifyUrl = "https://notify.myapp.com";
|
||||
options.TelemetryUrl = "https://telemetry.myapp.com";
|
||||
});
|
||||
```
|
||||
|
||||
Registers:
|
||||
- `MauiSecureTokenStorage` as `ITokenStorage`
|
||||
- `IronServicesClient` as singleton
|
||||
|
||||
---
|
||||
|
||||
## Complete Example
|
||||
|
||||
```csharp
|
||||
// MauiProgram.cs
|
||||
public static MauiApp CreateMauiApp()
|
||||
{
|
||||
var builder = MauiApp.CreateBuilder();
|
||||
builder.UseMauiApp<App>();
|
||||
|
||||
// Register IronServices
|
||||
builder.Services.AddIronServices();
|
||||
|
||||
// Register TelemetryClient for logging
|
||||
builder.Services.AddSingleton(sp => new TelemetryClient(new TelemetryOptions
|
||||
{
|
||||
Dsn = "your-dsn-here",
|
||||
EnableOfflineQueue = true
|
||||
}));
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
// LoginPage.xaml
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||
xmlns:iron="clr-namespace:IronServices.Maui.Controls;assembly=IronServices.Maui">
|
||||
<ScrollView>
|
||||
<iron:LoginView x:Name="LoginControl"
|
||||
Client="{Binding IronClient}"
|
||||
Logo="app_logo.png"
|
||||
Title="Welcome"
|
||||
LoginSuccess="OnLoginSuccess" />
|
||||
</ScrollView>
|
||||
</ContentPage>
|
||||
|
||||
// LoginPage.xaml.cs
|
||||
public partial class LoginPage : ContentPage
|
||||
{
|
||||
public LoginPage(IronServicesClient client)
|
||||
{
|
||||
InitializeComponent();
|
||||
LoginControl.Client = client;
|
||||
}
|
||||
|
||||
private async void OnLoginSuccess(object sender, LoginSuccessEventArgs e)
|
||||
{
|
||||
await Shell.Current.GoToAsync("//main");
|
||||
}
|
||||
}
|
||||
|
||||
// SettingsPage.xaml.cs - Navigate to logs
|
||||
private async void OnViewLogsClicked(object sender, EventArgs e)
|
||||
{
|
||||
var telemetry = Handler.MauiContext.Services.GetService<TelemetryClient>();
|
||||
await Navigation.PushAsync(new AppLogView
|
||||
{
|
||||
TelemetryClient = telemetry
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Styling
|
||||
|
||||
All controls use MAUI resource dictionaries. Override these colors in your `App.xaml`:
|
||||
|
||||
```xaml
|
||||
<Color x:Key="Primary">#512BD4</Color>
|
||||
<Color x:Key="Secondary">#DFD8F7</Color>
|
||||
<Color x:Key="Gray100">#E1E1E1</Color>
|
||||
<Color x:Key="Gray200">#C8C8C8</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="White">#FFFFFF</Color>
|
||||
```
|
||||
|
||||
Controls support light/dark themes via `AppThemeBinding`.
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
| Platform | Minimum Version |
|
||||
|----------|-----------------|
|
||||
| Android | API 21 (5.0) |
|
||||
| iOS | 14.0 |
|
||||
| macOS | 11.0 |
|
||||
| Windows | 10.0.17763.0 |
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `IronServices.Client` - Core API client
|
||||
- `IronLicensing.Client` - License validation
|
||||
- `IronTelemetry.Client` - Telemetry and logging
|
||||
- `Microsoft.Maui.Controls` - MAUI framework
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Proprietary. See LICENSE file.
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
using IronServices.Client;
|
||||
|
||||
namespace IronServices.Maui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// MAUI implementation of ITokenStorage using SecureStorage.
|
||||
/// Securely stores authentication tokens on the device.
|
||||
/// </summary>
|
||||
public class MauiSecureTokenStorage : ITokenStorage
|
||||
{
|
||||
private const string SessionTokenKey = "ironservices_session_token";
|
||||
private const string TokenExpiryKey = "ironservices_token_expiry";
|
||||
|
||||
public async Task SaveTokenAsync(string token, DateTime? expiresAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
await SecureStorage.Default.SetAsync(SessionTokenKey, token);
|
||||
if (expiresAt.HasValue)
|
||||
{
|
||||
await SecureStorage.Default.SetAsync(TokenExpiryKey, expiresAt.Value.ToString("O"));
|
||||
}
|
||||
else
|
||||
{
|
||||
SecureStorage.Default.Remove(TokenExpiryKey);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// SecureStorage may not be available on all platforms
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(string? Token, DateTime? ExpiresAt)> GetTokenAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = await SecureStorage.Default.GetAsync(SessionTokenKey);
|
||||
DateTime? expiresAt = null;
|
||||
|
||||
var expiryStr = await SecureStorage.Default.GetAsync(TokenExpiryKey);
|
||||
if (DateTime.TryParse(expiryStr, out var expiry))
|
||||
{
|
||||
expiresAt = expiry;
|
||||
}
|
||||
|
||||
return (token, expiresAt);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ClearTokenAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
SecureStorage.Default.Remove(SessionTokenKey);
|
||||
SecureStorage.Default.Remove(TokenExpiryKey);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue