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:
David Friedel 2025-12-25 09:15:31 +00:00
commit ab1630b451
17 changed files with 2213 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
bin/
obj/
*.user
*.suo
.vs/
*.DotSettings.user

164
Controls/AppLogView.xaml Normal file
View File

@ -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>

496
Controls/AppLogView.xaml.cs Normal file
View File

@ -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
};
}
}

View File

@ -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="&#x1F511;"
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>

View File

@ -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;
}
}

160
Controls/LoginView.xaml Executable file
View File

@ -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>

433
Controls/LoginView.xaml.cs Normal file
View File

@ -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;
}
}

View File

@ -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;
}
}

42
IronServices.Maui.csproj Executable file
View File

@ -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>

21
LICENSE Normal file
View File

@ -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.

View File

@ -0,0 +1,7 @@
namespace IronServices.Maui
{
// All the code in this file is only included on Android.
public class PlatformClass1
{
}
}

View File

@ -0,0 +1,7 @@
namespace IronServices.Maui
{
// All the code in this file is only included on Mac Catalyst.
public class PlatformClass1
{
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace IronServices.Maui
{
// All the code in this file is only included on Tizen.
public class PlatformClass1
{
}
}

View File

@ -0,0 +1,7 @@
namespace IronServices.Maui
{
// All the code in this file is only included on Windows.
public class PlatformClass1
{
}
}

View File

@ -0,0 +1,7 @@
namespace IronServices.Maui
{
// All the code in this file is only included on iOS.
public class PlatformClass1
{
}
}

362
README.md Normal file
View File

@ -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.

View 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;
}
}