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