This commit is contained in:
David H. Friedel Jr. 2025-12-10 21:59:12 -05:00
parent 9a3780ab40
commit 9c075174bd
69 changed files with 11965 additions and 0 deletions

103
.gitignore vendored Normal file
View File

@ -0,0 +1,103 @@
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio
.vs/
*.suo
*.user
*.userosscache
*.sln.docstates
*.rsuser
*.userprefs
# Visual Studio Code
.vscode/
# Rider
.idea/
# NuGet
*.nupkg
*.snupkg
project.lock.json
project.fragment.lock.json
artifacts/
# MSBuild
*.csproj.user
*.vbproj.user
# Windows
[Tt]humbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# MAUI / Xamarin
*.ipa
*.dSYM.zip
*.dSYM
*.apk
*.aab
# Test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
*.trx
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# Resharper
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JetBrains Rider
*.sln.iml
# TeamCity
.TeamCity*
# DotCover
*.dotCover
# Publish profiles
*.pubxml
*.publishproj
# Backup files
*~
*.bak
*.swp
# Local app settings
appsettings.*.json
!appsettings.json
!appsettings.Development.json
# Secrets
*.pfx
*.snk
secrets.json

1107
API_Reference.md Normal file

File diff suppressed because it is too large Load Diff

445
README.md Normal file
View File

@ -0,0 +1,445 @@
# MarketAlly.Replicate.Maui
[![NuGet Version](https://img.shields.io/nuget/v/MarketAlly.Replicate.Maui.svg?style=flat)](https://www.nuget.org/packages/MarketAlly.Replicate.Maui/)
[![NuGet Downloads](https://img.shields.io/nuget/dt/MarketAlly.Replicate.Maui.svg)](https://www.nuget.org/packages/MarketAlly.Replicate.Maui/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![.NET](https://img.shields.io/badge/.NET-9.0-512BD4)](https://dotnet.microsoft.com/download)
[![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20Android%20%7C%20Windows%20%7C%20macOS-lightgray)](https://dotnet.microsoft.com/apps/maui)
A production-ready .NET library for [Replicate](https://replicate.com) AI API integration. Generate AI images and videos using popular models like Stable Diffusion XL, HiDream, Google Veo 3, Kling, Luma Ray, and more.
## Features
- **Dual-target support**: Use with .NET MAUI apps (iOS, Android, Windows, macOS) or plain .NET 9 (console, ASP.NET Core, Blazor)
- **Pre-configured model presets**: Ready-to-use configurations for popular AI models
- **Ready-to-use MAUI controls**: Drop-in UI components for image/video transformation
- **Prediction tracking**: Real-time status updates and history management
- **BYOK support**: Bring Your Own Key for multi-tenant applications
- **Localization**: Built-in support for 8 languages with custom translation capability
- **Webhook integration**: Support for async processing with webhooks
## Supported Models
### Image Models
| Model | Description |
|-------|-------------|
| **HiDream E1.1** | Fast high-quality image generation/editing |
| **Stable Diffusion XL** | Popular open-source image generation |
| **Ideogram V3 Turbo** | Best for images with realistic, legible text |
| **Recraft V3 SVG** | High-quality SVG images, logos, and icons |
### Video Models
| Model | Description |
|-------|-------------|
| **Google Veo 3 Fast** | State-of-the-art video generation from Google |
| **Kling 2.5 Turbo Pro** | Latest high-quality video generation |
| **Kling 1.6 Pro** | Professional video generation |
| **Seedance 1 Pro** | High-quality video with image-to-video support |
| **Luma Ray Flash 2** | Fast video generation with camera control |
| **Wan 2.1** | Image-to-video with 480p/720p support |
| **MiniMax Video 01** | Text and image to video generation |
## Installation
```bash
dotnet add package MarketAlly.Replicate.Maui
```
Or via Package Manager:
```powershell
Install-Package MarketAlly.Replicate.Maui
```
## Quick Start
### Option 1: .NET MAUI Application (Full Experience)
**1. Configure in MauiProgram.cs:**
```csharp
using MarketAlly.Replicate.Maui;
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseReplicateMaui(settings =>
{
settings.ApiToken = "your-replicate-api-token";
});
return builder.Build();
}
```
**2. Add the control to your XAML:**
```xml
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:replicate="clr-namespace:MarketAlly.Replicate.Maui.Controls;assembly=MarketAlly.Replicate.Maui">
<replicate:ReplicateTransformerView
x:Name="TransformerView"
LayoutMode="SideBySide"
TransformationCompleted="OnTransformationCompleted" />
</ContentPage>
```
**3. Initialize and handle events:**
```csharp
using MarketAlly.Replicate.Maui;
using MarketAlly.Replicate.Maui.Controls;
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
// Use a preset model
TransformerView.SetImagePreset(ModelPresets.HiDreamE1);
}
private void OnTransformationCompleted(object sender, TransformationCompletedEventArgs e)
{
// e.Result.Output contains the URL to the generated image/video
Console.WriteLine($"Result: {e.Result.Output}");
}
}
```
### Option 2: API-Only Usage (Console, ASP.NET Core, Blazor)
```csharp
using MarketAlly.Replicate.Maui;
using Microsoft.Extensions.Options;
// Create transformer directly
var settings = Options.Create(new ReplicateSettings
{
ApiToken = "your-replicate-api-token"
});
using var httpClient = new HttpClient();
var transformer = new ReplicateTransformer(httpClient, settings);
// Use a model preset
var preset = ModelPresets.HiDreamE1;
var imageBytes = File.ReadAllBytes("input.jpg");
var result = await transformer.RunPresetAsync(
preset,
imageBytes,
customPrompt: "anime style portrait, studio ghibli inspired"
);
Console.WriteLine($"Status: {result.Status}");
Console.WriteLine($"Output: {result.Output}");
```
### Option 3: Dependency Injection (ASP.NET Core)
```csharp
// In Program.cs or Startup.cs
services.Configure<ReplicateSettings>(options =>
{
options.ApiToken = Configuration["Replicate:ApiToken"];
});
services.AddHttpClient<IReplicateTransformer, ReplicateTransformer>();
// In your controller or service
public class ImageController : ControllerBase
{
private readonly IReplicateTransformer _transformer;
public ImageController(IReplicateTransformer transformer)
{
_transformer = transformer;
}
[HttpPost("generate")]
public async Task<IActionResult> Generate([FromBody] GenerateRequest request)
{
var result = await _transformer.RunPresetAsync(
ModelPresets.StableDiffusionXL,
Convert.FromBase64String(request.ImageBase64),
request.Prompt
);
return Ok(new { result.Output, result.Status });
}
}
```
## Using Model Presets
Model presets provide pre-configured settings for popular models:
```csharp
// Image Models
var hidream = ModelPresets.HiDreamE1;
var sdxl = ModelPresets.StableDiffusionXL;
var ideogram = ModelPresets.IdeogramV3Turbo;
var recraft = ModelPresets.RecraftV3Svg;
// Video Models
var veo = ModelPresets.GoogleVeo3Fast;
var kling = ModelPresets.Kling25TurboPro;
var seedance = ModelPresets.SeedancePro;
var luma = ModelPresets.LumaRayFlash;
// Get all presets
var allPresets = ModelPresets.All;
var imageModels = ModelPresets.ImageModels;
var videoModels = ModelPresets.VideoModels;
// Find by name
var preset = ModelPresets.FindByName("HiDream E1.1");
```
### Custom Parameters
Override default preset parameters:
```csharp
var customParams = new Dictionary<string, object>
{
{ "guidance_scale", 7.5 },
{ "num_inference_steps", 30 },
{ "seed", 42 }
};
var result = await transformer.RunPresetAsync(
ModelPresets.StableDiffusionXL,
imageBytes,
customPrompt: "cyberpunk cityscape",
customParameters: customParams
);
```
## MAUI Control Features
### Layout Modes
```csharp
// Image only - no buttons (for custom UI)
TransformerView.LayoutMode = TransformerLayoutMode.ImageOnly;
// Buttons below the image (default)
TransformerView.LayoutMode = TransformerLayoutMode.ButtonsBelow;
// Overlay buttons on the image
TransformerView.LayoutMode = TransformerLayoutMode.ButtonsOverlay;
// Side-by-side source and result
TransformerView.LayoutMode = TransformerLayoutMode.SideBySide;
```
### Overlay Button Position
```csharp
// Position overlay buttons at top or bottom
TransformerView.OverlayButtonPosition = OverlayButtonPosition.Top;
TransformerView.OverlayButtonPosition = OverlayButtonPosition.Bottom; // default
```
### Localization
Built-in support for 8 languages:
```csharp
// Enable localization
TransformerView.UseLocalization = true;
// Set language
ReplicateStrings.CurrentCulture = "es"; // Spanish
// Custom translations
ReplicateStrings.RegisterTranslations("es", new Dictionary<string, string>
{
[ReplicateStrings.Keys.Transform] = "Convertir",
[ReplicateStrings.Keys.GenerateVideo] = "Crear vídeo"
});
```
Supported languages: English (en), Spanish (es), French (fr), German (de), Chinese (zh), Japanese (ja), Portuguese (pt), Italian (it)
### Button Configuration
Customize button appearance:
```csharp
// Configure individual buttons
TransformerView.ButtonConfigs.OverlaySelect.Text = "Choose Image";
TransformerView.ButtonConfigs.OverlaySelect.IconText = "📷";
TransformerView.ButtonConfigs.OverlayTransform.BackgroundColor = Colors.Purple;
// Set display mode
TransformerView.ButtonConfigs.DefaultDisplayMode = ButtonDisplayMode.Icon; // Icon only
TransformerView.ButtonConfigs.DefaultDisplayMode = ButtonDisplayMode.Label; // Text only
TransformerView.ButtonConfigs.DefaultDisplayMode = ButtonDisplayMode.Both; // Both (default)
```
## Prediction Tracking
Track prediction status in real-time:
```csharp
// Enable tracking (enabled by default)
var tracker = new PredictionTracker(transformer);
// Subscribe to events
tracker.PredictionStatusChanged += (s, e) =>
{
Console.WriteLine($"Prediction {e.Prediction.Id}: {e.PreviousStatus} -> {e.NewStatus}");
};
tracker.PredictionCompleted += (s, e) =>
{
if (e.Succeeded)
Console.WriteLine($"Completed: {e.Prediction.Output}");
else
Console.WriteLine($"Failed: {e.Prediction.Error}");
};
// Access history
var history = tracker.History;
var pending = tracker.PendingPredictions;
```
## BYOK (Bring Your Own Key)
Support multi-tenant scenarios where users provide their own API keys:
```csharp
// Inject the factory
public class MyService
{
private readonly IReplicateTransformerFactory _factory;
public MyService(IReplicateTransformerFactory factory)
{
_factory = factory;
}
public async Task<string> GenerateForUser(string userApiKey, byte[] image)
{
// Create transformer with user's API key
var transformer = _factory.CreateWithToken(userApiKey);
var result = await transformer.RunPresetAsync(
ModelPresets.HiDreamE1,
image
);
return result.Output;
}
}
```
## Webhook Support
Use webhooks for long-running predictions:
```csharp
var options = new PredictionOptions
{
WebhookUrl = "https://your-server.com/webhook/replicate",
WebhookEventsFilter = new[] { "completed" },
WebhookOnly = true // Don't poll, just return immediately
};
var result = await transformer.RunPresetAsync(
ModelPresets.GoogleVeo3Fast,
imageBytes,
options: options
);
// result.Id can be used to track the prediction
// Your webhook will receive the completion notification
```
## Events Reference
### ReplicateTransformerView Events
| Event | Description |
|-------|-------------|
| `ImageSelected` | Fired when user selects an image |
| `TransformationStarted` | Fired when transformation begins |
| `TransformationCompleted` | Fired when transformation succeeds |
| `TransformationError` | Fired when transformation fails |
| `PredictionTracked` | Fired when prediction status changes |
| `PredictionCompleted` | Fired when prediction completes |
| `FileSaved` | Fired when result is saved to file |
| `FileSaveError` | Fired when file save fails |
| `StateChanged` | Fired when control state changes |
| `ImageTapped` | Fired when image is tapped |
### IReplicateTransformer Events
| Event | Description |
|-------|-------------|
| `PredictionCreated` | Fired immediately when prediction is created (before polling) |
## Error Handling
```csharp
try
{
var result = await transformer.RunPresetAsync(preset, imageBytes);
if (result.IsFailed)
{
Console.WriteLine($"Prediction failed: {result.Error}");
}
else if (result.IsCanceled)
{
Console.WriteLine("Prediction was canceled");
}
else if (result.IsSucceeded)
{
Console.WriteLine($"Success: {result.Output}");
}
}
catch (ReplicateApiException ex)
{
Console.WriteLine($"API Error {ex.StatusCode}: {ex.Message}");
Console.WriteLine($"Response: {ex.ResponseBody}");
}
catch (ReplicateTransformationException ex)
{
Console.WriteLine($"Transformation Error: {ex.Message}");
}
```
## Requirements
- .NET 9.0 or later
- For MAUI targets: iOS 15.0+, Android 24+, Windows 10.0.17763+, macOS 15.0+
- Replicate API token ([Get one here](https://replicate.com/account/api-tokens))
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Links
- [GitHub Repository](https://github.com/MarketAlly/Replicate.Maui)
- [NuGet Package](https://www.nuget.org/packages/MarketAlly.Replicate.Maui/)
- [API Reference](https://github.com/MarketAlly/Replicate.Maui/blob/main/API_Reference.md)
- [Replicate Documentation](https://replicate.com/docs)
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Support
For issues and feature requests, please use the [GitHub Issues](https://github.com/MarketAlly/Replicate.Maui/issues) page.
---
Made with care by [MarketAlly](https://github.com/MarketAlly)

4
Replicate.Maui.slnx Normal file
View File

@ -0,0 +1,4 @@
<Solution>
<Project Path="Replicate.Maui/Replicate.Maui.csproj" />
<Project Path="Test.Replicate/Test.Replicate.csproj" />
</Solution>

View File

@ -0,0 +1,517 @@
namespace MarketAlly.Replicate.Maui.Controls;
/// <summary>
/// Display mode for transformer buttons.
/// </summary>
public enum ButtonDisplayMode
{
/// <summary>
/// Show only the text label.
/// </summary>
Label,
/// <summary>
/// Show only the icon (ImageSource or IconText).
/// </summary>
Icon,
/// <summary>
/// Show both icon and label.
/// </summary>
Both
}
/// <summary>
/// Position for overlay buttons in the ImageOverlay layout mode.
/// </summary>
public enum OverlayButtonPosition
{
/// <summary>
/// Buttons appear at the top of the image.
/// </summary>
Top,
/// <summary>
/// Buttons appear at the bottom of the image (default).
/// </summary>
Bottom
}
/// <summary>
/// Configuration for a transformer button's appearance.
/// </summary>
public class ButtonConfig : BindableObject
{
/// <summary>
/// Raised when any configuration property changes.
/// </summary>
public event EventHandler? ConfigurationChanged;
public static readonly BindableProperty TextProperty =
BindableProperty.Create(nameof(Text), typeof(string), typeof(ButtonConfig), string.Empty,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty IconTextProperty =
BindableProperty.Create(nameof(IconText), typeof(string), typeof(ButtonConfig), string.Empty,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty ImageSourceProperty =
BindableProperty.Create(nameof(ImageSource), typeof(ImageSource), typeof(ButtonConfig), null,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty DisplayModeProperty =
BindableProperty.Create(nameof(DisplayMode), typeof(ButtonDisplayMode?), typeof(ButtonConfig), null,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty BackgroundColorProperty =
BindableProperty.Create(nameof(BackgroundColor), typeof(Color), typeof(ButtonConfig), null,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty TextColorProperty =
BindableProperty.Create(nameof(TextColor), typeof(Color), typeof(ButtonConfig), null,
propertyChanged: OnConfigPropertyChanged);
public static readonly BindableProperty IsVisibleProperty =
BindableProperty.Create(nameof(IsVisible), typeof(bool), typeof(ButtonConfig), true,
propertyChanged: OnConfigPropertyChanged);
private static void OnConfigPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ButtonConfig config)
{
config.ConfigurationChanged?.Invoke(config, EventArgs.Empty);
}
}
/// <summary>
/// The text label for the button.
/// </summary>
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
/// <summary>
/// Unicode/emoji icon text (e.g., "📸", "🖼").
/// Used when ImageSource is null and DisplayMode includes icon.
/// </summary>
public string IconText
{
get => (string)GetValue(IconTextProperty);
set => SetValue(IconTextProperty, value);
}
/// <summary>
/// Image source for the button icon.
/// Takes precedence over IconText when set.
/// </summary>
public ImageSource? ImageSource
{
get => (ImageSource?)GetValue(ImageSourceProperty);
set => SetValue(ImageSourceProperty, value);
}
/// <summary>
/// How to display the button content. If null, uses the parent's default display mode.
/// </summary>
public ButtonDisplayMode? DisplayMode
{
get => (ButtonDisplayMode?)GetValue(DisplayModeProperty);
set => SetValue(DisplayModeProperty, value);
}
/// <summary>
/// Background color for the button.
/// </summary>
public Color? BackgroundColor
{
get => (Color?)GetValue(BackgroundColorProperty);
set => SetValue(BackgroundColorProperty, value);
}
/// <summary>
/// Text color for the button.
/// </summary>
public Color TextColor
{
get => (Color)GetValue(TextColorProperty);
set => SetValue(TextColorProperty, value);
}
/// <summary>
/// Whether the button is visible.
/// </summary>
public bool IsVisible
{
get => (bool)GetValue(IsVisibleProperty);
set => SetValue(IsVisibleProperty, value);
}
/// <summary>
/// Gets the display text based on DisplayMode.
/// </summary>
/// <param name="defaultMode">The default display mode to use if DisplayMode is null.</param>
public string GetDisplayText(ButtonDisplayMode defaultMode = ButtonDisplayMode.Both)
{
var effectiveMode = DisplayMode ?? defaultMode;
return effectiveMode switch
{
ButtonDisplayMode.Label => Text,
ButtonDisplayMode.Icon => ImageSource == null ? IconText : string.Empty,
ButtonDisplayMode.Both => ImageSource == null ? $"{IconText} {Text}".Trim() : Text,
_ => Text
};
}
/// <summary>
/// Gets whether to show the image based on effective display mode.
/// </summary>
/// <param name="defaultMode">The default display mode to use if DisplayMode is null.</param>
public bool ShouldShowImage(ButtonDisplayMode defaultMode = ButtonDisplayMode.Both)
{
var effectiveMode = DisplayMode ?? defaultMode;
return ImageSource != null && effectiveMode != ButtonDisplayMode.Label;
}
/// <summary>
/// Gets the effective image source based on display mode.
/// </summary>
/// <param name="defaultMode">The default display mode to use if DisplayMode is null.</param>
public ImageSource? GetEffectiveImageSource(ButtonDisplayMode defaultMode = ButtonDisplayMode.Both)
{
return ShouldShowImage(defaultMode) ? ImageSource : null;
}
/// <summary>
/// Apply this configuration to a MAUI Button.
/// </summary>
/// <param name="button">The button to configure.</param>
/// <param name="defaultMode">The default display mode to use if DisplayMode is null.</param>
public void ApplyTo(Button button, ButtonDisplayMode defaultMode = ButtonDisplayMode.Both)
{
button.Text = GetDisplayText(defaultMode);
button.ImageSource = GetEffectiveImageSource(defaultMode);
if (BackgroundColor != null)
button.BackgroundColor = BackgroundColor;
if (TextColor != null)
button.TextColor = TextColor;
button.IsVisible = IsVisible;
}
/// <summary>
/// Create a new ButtonConfig with the specified values.
/// </summary>
public static ButtonConfig Create(string text, string iconText, ImageSource? imageSource = null, ButtonDisplayMode? displayMode = null)
{
return new ButtonConfig
{
Text = text,
IconText = iconText,
ImageSource = imageSource,
DisplayMode = displayMode
};
}
/// <summary>
/// Create a clone of this configuration.
/// </summary>
public ButtonConfig Clone()
{
return new ButtonConfig
{
Text = Text,
IconText = IconText,
ImageSource = ImageSource,
DisplayMode = DisplayMode,
BackgroundColor = BackgroundColor,
TextColor = TextColor,
IsVisible = IsVisible
};
}
}
/// <summary>
/// Predefined button configurations for common transformer buttons.
/// </summary>
public static class DefaultButtonConfigs
{
// Source buttons
public static ButtonConfig CapturePhoto => new()
{
Text = "Take",
IconText = "📸"
};
public static ButtonConfig PickImage => new()
{
Text = "Pick",
IconText = "🖼"
};
public static ButtonConfig Select => new()
{
Text = "Select",
IconText = "🖼"
};
public static ButtonConfig CaptureVideo => new()
{
Text = "Record",
IconText = "🎬"
};
public static ButtonConfig PickVideo => new()
{
Text = "Pick Video",
IconText = "🎥"
};
// Transform buttons
public static ButtonConfig TransformImage => new()
{
Text = "Transform",
IconText = "✨"
};
public static ButtonConfig TransformAnime => new()
{
Text = "Transform to Anime",
IconText = "✨"
};
public static ButtonConfig GenerateVideo => new()
{
Text = "Generate Video",
IconText = "🎬"
};
// Result buttons
public static ButtonConfig Clear => new()
{
Text = "Clear",
IconText = "🗑"
};
public static ButtonConfig Redo => new()
{
Text = "Redo",
IconText = "↺"
};
public static ButtonConfig Reset => new()
{
Text = "Reset",
IconText = "↺"
};
public static ButtonConfig BackToImage => new()
{
Text = "Image",
IconText = "🖼"
};
}
/// <summary>
/// Container for all transformer button configurations.
/// Allows grouping and customizing button appearance in a single bindable object.
/// </summary>
public class TransformerButtonConfigs : BindableObject
{
/// <summary>
/// Raised when any button configuration changes.
/// </summary>
public event EventHandler? ConfigurationChanged;
#region Bindable Properties
public static readonly BindableProperty DefaultDisplayModeProperty =
BindableProperty.Create(nameof(DefaultDisplayMode), typeof(ButtonDisplayMode), typeof(TransformerButtonConfigs),
ButtonDisplayMode.Both, propertyChanged: OnPropertyChanged);
// Overlay buttons
public static readonly BindableProperty OverlaySelectProperty =
BindableProperty.Create(nameof(OverlaySelect), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
public static readonly BindableProperty OverlayTransformProperty =
BindableProperty.Create(nameof(OverlayTransform), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
// SideBySide buttons
public static readonly BindableProperty SideBySideCaptureProperty =
BindableProperty.Create(nameof(SideBySideCapture), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
public static readonly BindableProperty SideBySidePickProperty =
BindableProperty.Create(nameof(SideBySidePick), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
public static readonly BindableProperty SideBySideTransformProperty =
BindableProperty.Create(nameof(SideBySideTransform), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
public static readonly BindableProperty SideBySideClearProperty =
BindableProperty.Create(nameof(SideBySideClear), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
public static readonly BindableProperty SideBySideRedoProperty =
BindableProperty.Create(nameof(SideBySideRedo), typeof(ButtonConfig), typeof(TransformerButtonConfigs),
null, propertyChanged: OnButtonConfigChanged);
#endregion
#region Property Accessors
/// <summary>
/// Default display mode for all buttons. Individual buttons can override this.
/// </summary>
public ButtonDisplayMode DefaultDisplayMode
{
get => (ButtonDisplayMode)GetValue(DefaultDisplayModeProperty);
set => SetValue(DefaultDisplayModeProperty, value);
}
/// <summary>
/// Configuration for the overlay select button.
/// </summary>
public ButtonConfig OverlaySelect
{
get => (ButtonConfig?)GetValue(OverlaySelectProperty) ?? CreateDefaultOverlaySelect();
set => SetValue(OverlaySelectProperty, value);
}
/// <summary>
/// Configuration for the overlay transform button.
/// </summary>
public ButtonConfig OverlayTransform
{
get => (ButtonConfig?)GetValue(OverlayTransformProperty) ?? CreateDefaultOverlayTransform();
set => SetValue(OverlayTransformProperty, value);
}
/// <summary>
/// Configuration for the side-by-side capture button.
/// </summary>
public ButtonConfig SideBySideCapture
{
get => (ButtonConfig?)GetValue(SideBySideCaptureProperty) ?? CreateDefaultSideBySideCapture();
set => SetValue(SideBySideCaptureProperty, value);
}
/// <summary>
/// Configuration for the side-by-side pick button.
/// </summary>
public ButtonConfig SideBySidePick
{
get => (ButtonConfig?)GetValue(SideBySidePickProperty) ?? CreateDefaultSideBySidePick();
set => SetValue(SideBySidePickProperty, value);
}
/// <summary>
/// Configuration for the side-by-side transform button.
/// </summary>
public ButtonConfig SideBySideTransform
{
get => (ButtonConfig?)GetValue(SideBySideTransformProperty) ?? CreateDefaultSideBySideTransform();
set => SetValue(SideBySideTransformProperty, value);
}
/// <summary>
/// Configuration for the side-by-side clear button.
/// </summary>
public ButtonConfig SideBySideClear
{
get => (ButtonConfig?)GetValue(SideBySideClearProperty) ?? CreateDefaultSideBySideClear();
set => SetValue(SideBySideClearProperty, value);
}
/// <summary>
/// Configuration for the side-by-side redo button.
/// </summary>
public ButtonConfig SideBySideRedo
{
get => (ButtonConfig?)GetValue(SideBySideRedoProperty) ?? CreateDefaultSideBySideRedo();
set => SetValue(SideBySideRedoProperty, value);
}
#endregion
#region Default Factory Methods
private static ButtonConfig CreateDefaultOverlaySelect() => new() { Text = "Select", IconText = "🖼" };
private static ButtonConfig CreateDefaultOverlayTransform() => new() { Text = "Transform", IconText = "✨" };
private static ButtonConfig CreateDefaultSideBySideCapture() => new() { Text = "Take", IconText = "📸" };
private static ButtonConfig CreateDefaultSideBySidePick() => new() { Text = "Pick", IconText = "🖼" };
private static ButtonConfig CreateDefaultSideBySideTransform() => new() { Text = "Transform", IconText = "✨" };
private static ButtonConfig CreateDefaultSideBySideClear() => new() { Text = "Clear", IconText = "🗑" };
private static ButtonConfig CreateDefaultSideBySideRedo() => new() { Text = "Redo", IconText = "↺" };
#endregion
#region Change Handlers
private static void OnPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is TransformerButtonConfigs configs)
{
configs.ConfigurationChanged?.Invoke(configs, EventArgs.Empty);
}
}
private static void OnButtonConfigChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is TransformerButtonConfigs configs)
{
// Unsubscribe from old config
if (oldValue is ButtonConfig oldConfig)
{
oldConfig.ConfigurationChanged -= configs.OnChildConfigurationChanged;
}
// Subscribe to new config
if (newValue is ButtonConfig newConfig)
{
newConfig.ConfigurationChanged += configs.OnChildConfigurationChanged;
}
configs.ConfigurationChanged?.Invoke(configs, EventArgs.Empty);
}
}
private void OnChildConfigurationChanged(object? sender, EventArgs e)
{
ConfigurationChanged?.Invoke(this, EventArgs.Empty);
}
#endregion
/// <summary>
/// Apply a button configuration to a button control.
/// </summary>
public void ApplyConfig(ButtonConfig config, Button button)
{
config.ApplyTo(button, DefaultDisplayMode);
}
/// <summary>
/// Create a default TransformerButtonConfigs instance with all defaults applied.
/// </summary>
public static TransformerButtonConfigs CreateDefault()
{
return new TransformerButtonConfigs
{
DefaultDisplayMode = ButtonDisplayMode.Both,
OverlaySelect = CreateDefaultOverlaySelect(),
OverlayTransform = CreateDefaultOverlayTransform(),
SideBySideCapture = CreateDefaultSideBySideCapture(),
SideBySidePick = CreateDefaultSideBySidePick(),
SideBySideTransform = CreateDefaultSideBySideTransform(),
SideBySideClear = CreateDefaultSideBySideClear(),
SideBySideRedo = CreateDefaultSideBySideRedo()
};
}
}

View File

@ -0,0 +1,10 @@
<?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="MarketAlly.Replicate.Maui.Controls.ConfigurableButton">
<Button x:Name="InnerButton"
Clicked="OnButtonClicked"
ContentLayout="Left,6"/>
</ContentView>

View File

@ -0,0 +1,230 @@
namespace MarketAlly.Replicate.Maui.Controls;
public partial class ConfigurableButton : ContentView
{
public static readonly BindableProperty TextProperty =
BindableProperty.Create(nameof(Text), typeof(string), typeof(ConfigurableButton), string.Empty,
propertyChanged: OnAppearancePropertyChanged);
public static readonly BindableProperty IconTextProperty =
BindableProperty.Create(nameof(IconText), typeof(string), typeof(ConfigurableButton), string.Empty,
propertyChanged: OnAppearancePropertyChanged);
public static readonly BindableProperty IconSourceProperty =
BindableProperty.Create(nameof(IconSource), typeof(ImageSource), typeof(ConfigurableButton), null,
propertyChanged: OnAppearancePropertyChanged);
public static readonly BindableProperty DisplayModeProperty =
BindableProperty.Create(nameof(DisplayMode), typeof(ButtonDisplayMode), typeof(ConfigurableButton), ButtonDisplayMode.Both,
propertyChanged: OnAppearancePropertyChanged);
public static readonly BindableProperty ButtonBackgroundColorProperty =
BindableProperty.Create(nameof(ButtonBackgroundColor), typeof(Color), typeof(ConfigurableButton), null,
propertyChanged: OnStylePropertyChanged);
public static readonly BindableProperty ButtonTextColorProperty =
BindableProperty.Create(nameof(ButtonTextColor), typeof(Color), typeof(ConfigurableButton), Colors.White,
propertyChanged: OnStylePropertyChanged);
public static readonly BindableProperty CornerRadiusProperty =
BindableProperty.Create(nameof(CornerRadius), typeof(int), typeof(ConfigurableButton), 0,
propertyChanged: OnStylePropertyChanged);
public static readonly BindableProperty ButtonHeightRequestProperty =
BindableProperty.Create(nameof(ButtonHeightRequest), typeof(double), typeof(ConfigurableButton), 44.0,
propertyChanged: OnStylePropertyChanged);
public static readonly BindableProperty FontSizeProperty =
BindableProperty.Create(nameof(FontSize), typeof(double), typeof(ConfigurableButton), 14.0,
propertyChanged: OnStylePropertyChanged);
public static readonly BindableProperty CommandProperty =
BindableProperty.Create(nameof(Command), typeof(System.Windows.Input.ICommand), typeof(ConfigurableButton), null,
propertyChanged: OnCommandPropertyChanged);
public static readonly BindableProperty CommandParameterProperty =
BindableProperty.Create(nameof(CommandParameter), typeof(object), typeof(ConfigurableButton), null,
propertyChanged: OnCommandPropertyChanged);
/// <summary>
/// The text label for the button.
/// </summary>
public string Text
{
get => (string)GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
/// <summary>
/// Unicode/emoji icon text (e.g., "📸", "🖼").
/// Used when IconSource is null and DisplayMode includes icon.
/// </summary>
public string IconText
{
get => (string)GetValue(IconTextProperty);
set => SetValue(IconTextProperty, value);
}
/// <summary>
/// Image source for the button icon.
/// Takes precedence over IconText when set.
/// </summary>
public ImageSource? IconSource
{
get => (ImageSource?)GetValue(IconSourceProperty);
set => SetValue(IconSourceProperty, value);
}
/// <summary>
/// How to display the button content (Label, Icon, or Both).
/// </summary>
public ButtonDisplayMode DisplayMode
{
get => (ButtonDisplayMode)GetValue(DisplayModeProperty);
set => SetValue(DisplayModeProperty, value);
}
/// <summary>
/// Background color for the button.
/// </summary>
public Color? ButtonBackgroundColor
{
get => (Color?)GetValue(ButtonBackgroundColorProperty);
set => SetValue(ButtonBackgroundColorProperty, value);
}
/// <summary>
/// Text color for the button.
/// </summary>
public Color ButtonTextColor
{
get => (Color)GetValue(ButtonTextColorProperty);
set => SetValue(ButtonTextColorProperty, value);
}
/// <summary>
/// Corner radius for the button.
/// </summary>
public int CornerRadius
{
get => (int)GetValue(CornerRadiusProperty);
set => SetValue(CornerRadiusProperty, value);
}
/// <summary>
/// Height request for the button.
/// </summary>
public double ButtonHeightRequest
{
get => (double)GetValue(ButtonHeightRequestProperty);
set => SetValue(ButtonHeightRequestProperty, value);
}
/// <summary>
/// Font size for button text.
/// </summary>
public double FontSize
{
get => (double)GetValue(FontSizeProperty);
set => SetValue(FontSizeProperty, value);
}
/// <summary>
/// Command to execute when button is clicked.
/// </summary>
public System.Windows.Input.ICommand? Command
{
get => (System.Windows.Input.ICommand?)GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
/// <summary>
/// Parameter to pass to the command.
/// </summary>
public object? CommandParameter
{
get => GetValue(CommandParameterProperty);
set => SetValue(CommandParameterProperty, value);
}
/// <summary>
/// Event raised when the button is clicked.
/// </summary>
public event EventHandler? Clicked;
public ConfigurableButton()
{
InitializeComponent();
UpdateButtonAppearance();
UpdateButtonStyle();
}
private static void OnAppearancePropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ConfigurableButton button)
button.UpdateButtonAppearance();
}
private static void OnStylePropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ConfigurableButton button)
button.UpdateButtonStyle();
}
private static void OnCommandPropertyChanged(BindableObject bindable, object oldValue, object newValue)
{
if (bindable is ConfigurableButton button)
{
button.InnerButton.Command = button.Command;
button.InnerButton.CommandParameter = button.CommandParameter;
}
}
private void UpdateButtonAppearance()
{
string displayText = DisplayMode switch
{
ButtonDisplayMode.Label => Text,
ButtonDisplayMode.Icon => IconSource == null ? IconText : string.Empty,
ButtonDisplayMode.Both => IconSource == null ? $"{IconText} {Text}".Trim() : Text,
_ => Text
};
InnerButton.Text = displayText;
// Set image source if using Icon or Both mode with an ImageSource
if (IconSource != null && DisplayMode != ButtonDisplayMode.Label)
{
InnerButton.ImageSource = IconSource;
}
else
{
InnerButton.ImageSource = null;
}
}
private void UpdateButtonStyle()
{
if (ButtonBackgroundColor != null)
InnerButton.BackgroundColor = ButtonBackgroundColor;
InnerButton.TextColor = ButtonTextColor;
InnerButton.CornerRadius = CornerRadius;
InnerButton.HeightRequest = ButtonHeightRequest;
InnerButton.FontSize = FontSize;
}
private void OnButtonClicked(object? sender, EventArgs e)
{
Clicked?.Invoke(this, e);
}
/// <summary>
/// Enable or disable the button.
/// </summary>
public new bool IsEnabled
{
get => InnerButton.IsEnabled;
set => InnerButton.IsEnabled = value;
}
}

View File

@ -0,0 +1,444 @@
<?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"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
x:Class="MarketAlly.Replicate.Maui.Controls.ReplicateTransformerView">
<Grid x:Name="RootGrid" RowDefinitions="*,Auto,Auto" Padding="0" RowSpacing="8">
<!-- ============================================== -->
<!-- SINGLE IMAGE LAYOUT (Default, Overlay, Below) -->
<!-- ============================================== -->
<Grid x:Name="SingleImageContainer" Grid.Row="0">
<Border x:Name="MainImageBorder"
StrokeShape="RoundRectangle 12"
Stroke="{AppThemeBinding Light=#E0E0E0, Dark=#404040}"
StrokeThickness="2"
Background="{AppThemeBinding Light=#F5F5F5, Dark=#1A1A1A}"
MinimumHeightRequest="200">
<Grid>
<!-- Placeholder when no image -->
<VerticalStackLayout x:Name="PlaceholderView"
VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="8">
<Label Text="&#x1F4F7;"
FontSize="48"
HorizontalOptions="Center"/>
<Label x:Name="PlaceholderLabel"
Text="Select or capture an image"
FontSize="14"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- Source Image -->
<Image x:Name="SourceImage"
Aspect="AspectFit"
IsVisible="False"/>
<!-- Result Image -->
<Image x:Name="ResultImage"
Aspect="AspectFit"
IsVisible="False"/>
<!-- Video Player (for video results) -->
<toolkit:MediaElement x:Name="VideoPlayer"
Aspect="AspectFit"
ShouldAutoPlay="True"
ShouldLoopPlayback="True"
IsVisible="False"/>
<!-- Play Button Overlay (shows on result image when video is available) -->
<Border x:Name="PlayButtonOverlay"
IsVisible="False"
Background="Transparent"
StrokeThickness="0">
<Button x:Name="PlayVideoButton"
Text="▶"
FontSize="48"
BackgroundColor="#88000000"
TextColor="White"
CornerRadius="40"
HeightRequest="80"
WidthRequest="80"
HorizontalOptions="Center"
VerticalOptions="Center"
Clicked="OnPlayVideoClicked"/>
</Border>
<!-- Processing Overlay -->
<Border x:Name="ProcessingOverlay"
IsVisible="False"
Background="#80000000"
StrokeThickness="0">
<VerticalStackLayout VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="16">
<ActivityIndicator IsRunning="True"
Color="White"
HeightRequest="48"
WidthRequest="48"/>
<Label x:Name="ProcessingLabel"
Text="Transforming..."
TextColor="White"
FontSize="16"
HorizontalOptions="Center"/>
<Label x:Name="ProcessingSubLabel"
Text="This may take a moment"
TextColor="#CCCCCC"
FontSize="12"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Border>
<!-- Error Overlay -->
<Border x:Name="ErrorOverlay"
IsVisible="False"
Background="#80000000"
StrokeThickness="0">
<VerticalStackLayout VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="12"
Padding="20">
<Label Text="&#x26A0;"
FontSize="36"
HorizontalOptions="Center"/>
<Label x:Name="ErrorLabel"
Text="An error occurred"
TextColor="White"
FontSize="14"
HorizontalOptions="Center"
HorizontalTextAlignment="Center"/>
<Button x:Name="DismissErrorButton"
Text="Dismiss"
BackgroundColor="#666666"
TextColor="White"
CornerRadius="8"
HeightRequest="36"
Clicked="OnDismissErrorClicked"/>
</VerticalStackLayout>
</Border>
<!-- OVERLAY BUTTONS (flush at bottom of image) -->
<Grid x:Name="OverlayButtonsContainer"
IsVisible="False"
VerticalOptions="End"
ColumnDefinitions="*,*"
ColumnSpacing="0">
<Button x:Name="OverlayPickButton"
Grid.Column="0"
Text="&#x1F5BC; Select"
FontSize="14"
BackgroundColor="#CC007AFF"
TextColor="White"
CornerRadius="0"
HeightRequest="48"
Clicked="OnPickImageClicked"/>
<Button x:Name="OverlayTransformButton"
Grid.Column="1"
Text="&#x2728; Transform"
FontSize="14"
BackgroundColor="#CCFF2D55"
TextColor="White"
CornerRadius="0"
HeightRequest="48"
Clicked="OnOverlayTransformClicked"/>
</Grid>
</Grid>
</Border>
</Grid>
<!-- ============================================== -->
<!-- SIDE BY SIDE LAYOUT -->
<!-- ============================================== -->
<Grid x:Name="SideBySideContainer"
Grid.Row="0"
IsVisible="False"
ColumnDefinitions="*,*"
ColumnSpacing="8">
<!-- Source Image Side -->
<Border Grid.Column="0"
StrokeShape="RoundRectangle 12"
Stroke="{AppThemeBinding Light=#E0E0E0, Dark=#404040}"
StrokeThickness="2"
Background="{AppThemeBinding Light=#F5F5F5, Dark=#1A1A1A}"
MinimumHeightRequest="200">
<Grid>
<!-- Placeholder for source -->
<VerticalStackLayout x:Name="SideBySidePlaceholder"
VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="8">
<Label Text="&#x1F4F7;"
FontSize="36"
HorizontalOptions="Center"/>
<Label x:Name="SideBySidePlaceholderLabel"
Text="Select image"
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- Source Image -->
<Image x:Name="SideBySideSourceImage"
Aspect="AspectFit"
IsVisible="False"/>
<!-- Label -->
<Label Text="SOURCE"
FontSize="10"
FontAttributes="Bold"
TextColor="{AppThemeBinding Light=#999999, Dark=#666666}"
VerticalOptions="Start"
HorizontalOptions="Start"
Margin="8,4"/>
<!-- Source Side Buttons (Take/Pick) -->
<Grid x:Name="SideBySideSourceButtons"
VerticalOptions="End"
ColumnDefinitions="*,*"
ColumnSpacing="0">
<Button x:Name="SideBySideCaptureButton"
Grid.Column="0"
Text="&#x1F4F8; Take"
FontSize="12"
BackgroundColor="#CC007AFF"
TextColor="White"
CornerRadius="0"
HeightRequest="40"
Clicked="OnCapturePhotoClicked"/>
<Button x:Name="SideBySidePickButton"
Grid.Column="1"
Text="&#x1F5BC; Pick"
FontSize="12"
BackgroundColor="#CC34C759"
TextColor="White"
CornerRadius="0"
HeightRequest="40"
Clicked="OnPickImageClicked"/>
</Grid>
</Grid>
</Border>
<!-- Result Image Side -->
<Border Grid.Column="1"
StrokeShape="RoundRectangle 12"
Stroke="{AppThemeBinding Light=#E0E0E0, Dark=#404040}"
StrokeThickness="2"
Background="{AppThemeBinding Light=#F5F5F5, Dark=#1A1A1A}"
MinimumHeightRequest="200">
<Grid>
<!-- Placeholder for result -->
<VerticalStackLayout x:Name="SideBySideResultPlaceholder"
VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="8">
<Label Text="&#x2728;"
FontSize="36"
HorizontalOptions="Center"/>
<Label Text="Result"
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- Result Image -->
<Image x:Name="SideBySideResultImage"
Aspect="AspectFit"
IsVisible="False"/>
<!-- Video Player (for video results in SideBySide) -->
<toolkit:MediaElement x:Name="SideBySideVideoPlayer"
Aspect="AspectFit"
ShouldAutoPlay="True"
ShouldLoopPlayback="True"
IsVisible="False"/>
<!-- Play Button Overlay for SideBySide -->
<Border x:Name="SideBySidePlayButtonOverlay"
IsVisible="False"
Background="Transparent"
StrokeThickness="0">
<Button x:Name="SideBySidePlayVideoButton"
Text="▶"
FontSize="32"
BackgroundColor="#88000000"
TextColor="White"
CornerRadius="30"
HeightRequest="60"
WidthRequest="60"
HorizontalOptions="Center"
VerticalOptions="Center"
Clicked="OnSideBySidePlayVideoClicked"/>
</Border>
<!-- Processing indicator for result side -->
<Border x:Name="SideBySideProcessingOverlay"
IsVisible="False"
Background="#80000000"
StrokeThickness="0">
<VerticalStackLayout VerticalOptions="Center"
HorizontalOptions="Center"
Spacing="8">
<ActivityIndicator IsRunning="True"
Color="White"
HeightRequest="32"
WidthRequest="32"/>
<Label x:Name="SideBySideProcessingLabel"
Text="Processing..."
TextColor="White"
FontSize="12"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Border>
<!-- Label -->
<Label Text="RESULT"
FontSize="10"
FontAttributes="Bold"
TextColor="{AppThemeBinding Light=#999999, Dark=#666666}"
VerticalOptions="Start"
HorizontalOptions="Start"
Margin="8,4"/>
<!-- Result Side Buttons (Clear/Redo) -->
<Grid x:Name="SideBySideResultButtons"
VerticalOptions="End"
ColumnDefinitions="*,*"
ColumnSpacing="0"
IsVisible="False">
<Button x:Name="SideBySideClearButton"
Grid.Column="0"
Text="&#x1F5D1; Clear"
FontSize="12"
BackgroundColor="#CC8E8E93"
TextColor="White"
CornerRadius="0"
HeightRequest="40"
Clicked="OnSideBySideClearClicked"/>
<Button x:Name="SideBySideRedoButton"
Grid.Column="1"
Text="&#x21BA; Redo"
FontSize="12"
BackgroundColor="#CCFF9500"
TextColor="White"
CornerRadius="0"
HeightRequest="40"
Clicked="OnSideBySideRedoClicked"/>
</Grid>
</Grid>
</Border>
</Grid>
<!-- ============================================== -->
<!-- SIDE BY SIDE TRANSFORM BUTTON -->
<!-- ============================================== -->
<Button x:Name="SideBySideTransformButton"
Grid.Row="1"
Text="✨ Transform"
FontSize="16"
BackgroundColor="{AppThemeBinding Light=#FF2D55, Dark=#FF375F}"
TextColor="White"
CornerRadius="8"
HeightRequest="48"
IsVisible="False"
Clicked="OnSideBySideTransformClicked"/>
<!-- ============================================== -->
<!-- BUTTONS BELOW (Source Buttons Row) -->
<!-- ============================================== -->
<Grid Grid.Row="1"
x:Name="SourceButtonsGrid"
ColumnDefinitions="*,*,*,*"
ColumnSpacing="8">
<Button x:Name="CapturePhotoButton"
Grid.Column="0"
Text="&#x1F4F8;"
FontSize="20"
ToolTipProperties.Text="Take Photo"
BackgroundColor="{AppThemeBinding Light=#007AFF, Dark=#0A84FF}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
Clicked="OnCapturePhotoClicked"/>
<Button x:Name="PickImageButton"
Grid.Column="1"
Text="&#x1F5BC;"
FontSize="20"
ToolTipProperties.Text="Pick Image"
BackgroundColor="{AppThemeBinding Light=#34C759, Dark=#30D158}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
Clicked="OnPickImageClicked"/>
<Button x:Name="CaptureVideoButton"
Grid.Column="2"
Text="&#x1F3AC;"
FontSize="20"
ToolTipProperties.Text="Record Video"
BackgroundColor="{AppThemeBinding Light=#FF9500, Dark=#FF9F0A}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
IsVisible="False"
Clicked="OnCaptureVideoClicked"/>
<Button x:Name="PickVideoButton"
Grid.Column="3"
Text="&#x1F3A5;"
FontSize="20"
ToolTipProperties.Text="Pick Video"
BackgroundColor="{AppThemeBinding Light=#AF52DE, Dark=#BF5AF2}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
IsVisible="False"
Clicked="OnPickVideoClicked"/>
</Grid>
<!-- Transform Buttons Row -->
<Grid Grid.Row="2"
ColumnDefinitions="*,*,Auto"
ColumnSpacing="8"
x:Name="TransformButtonsGrid"
IsVisible="False">
<Button x:Name="TransformAnimeButton"
Grid.Column="0"
Text="Transform to Anime"
FontSize="14"
BackgroundColor="{AppThemeBinding Light=#FF2D55, Dark=#FF375F}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
Clicked="OnTransformAnimeClicked"/>
<Button x:Name="TransformVideoButton"
Grid.Column="1"
Text="Generate Video"
FontSize="14"
BackgroundColor="{AppThemeBinding Light=#5856D6, Dark=#5E5CE6}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
Clicked="OnTransformVideoClicked"/>
<Button x:Name="ResetButton"
Grid.Column="2"
Text="&#x21BA;"
FontSize="20"
ToolTipProperties.Text="Reset"
BackgroundColor="{AppThemeBinding Light=#8E8E93, Dark=#636366}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
WidthRequest="44"
Clicked="OnResetClicked"/>
</Grid>
</Grid>
</ContentView>

File diff suppressed because it is too large Load Diff

177
Replicate.Maui/EventArgs.cs Normal file
View File

@ -0,0 +1,177 @@
namespace MarketAlly.Replicate.Maui;
/// <summary>
/// Event args for when a transformation starts.
/// </summary>
public class TransformationStartedEventArgs : EventArgs
{
/// <summary>
/// The type of transformation being started.
/// </summary>
public TransformationType Type { get; }
public TransformationStartedEventArgs(TransformationType type) => Type = type;
}
/// <summary>
/// Event args for when a transformation completes successfully.
/// </summary>
public class TransformationCompletedEventArgs : EventArgs
{
/// <summary>
/// The type of transformation that completed.
/// </summary>
public TransformationType Type { get; }
/// <summary>
/// The URL of the result output.
/// </summary>
public string? ResultUrl { get; }
/// <summary>
/// The full prediction result.
/// </summary>
public PredictionResult Result { get; }
public TransformationCompletedEventArgs(TransformationType type, PredictionResult result)
{
Type = type;
Result = result;
ResultUrl = result.Output;
}
}
/// <summary>
/// Event args for when a transformation fails.
/// </summary>
public class TransformationErrorEventArgs : EventArgs
{
/// <summary>
/// The type of transformation that failed.
/// </summary>
public TransformationType Type { get; }
/// <summary>
/// The exception that occurred.
/// </summary>
public Exception Error { get; }
public TransformationErrorEventArgs(TransformationType type, Exception error)
{
Type = type;
Error = error;
}
}
/// <summary>
/// Event args for when an image is selected.
/// </summary>
public class ImageSelectedEventArgs : EventArgs
{
/// <summary>
/// The selected image bytes.
/// </summary>
public byte[] ImageBytes { get; }
public ImageSelectedEventArgs(byte[] imageBytes) => ImageBytes = imageBytes;
}
/// <summary>
/// Event args for when an image is tapped.
/// </summary>
public class ImageTappedEventArgs : EventArgs
{
/// <summary>
/// Whether this is the source image (true) or result image (false).
/// </summary>
public bool IsSourceImage { get; }
/// <summary>
/// The URL of the result, if applicable.
/// </summary>
public string? ResultUrl { get; }
/// <summary>
/// The image bytes, if available.
/// </summary>
public byte[]? ImageBytes { get; }
public ImageTappedEventArgs(bool isSourceImage, string? resultUrl = null, byte[]? imageBytes = null)
{
IsSourceImage = isSourceImage;
ResultUrl = resultUrl;
ImageBytes = imageBytes;
}
}
/// <summary>
/// Event args for when a file is saved successfully.
/// </summary>
public class FileSavedEventArgs : EventArgs
{
/// <summary>
/// The full path to the saved file.
/// </summary>
public string FilePath { get; }
/// <summary>
/// The type of file saved (Image or Video).
/// </summary>
public TransformationType Type { get; }
/// <summary>
/// The size of the saved file in bytes.
/// </summary>
public long FileSizeBytes { get; }
public FileSavedEventArgs(string filePath, TransformationType type, long fileSizeBytes)
{
FilePath = filePath;
Type = type;
FileSizeBytes = fileSizeBytes;
}
}
/// <summary>
/// Event args for when a file save fails.
/// </summary>
public class FileSaveErrorEventArgs : EventArgs
{
/// <summary>
/// The type of file that failed to save.
/// </summary>
public TransformationType Type { get; }
/// <summary>
/// The exception that occurred.
/// </summary>
public Exception Error { get; }
public FileSaveErrorEventArgs(TransformationType type, Exception error)
{
Type = type;
Error = error;
}
}
/// <summary>
/// Event args for when the transformer state changes.
/// </summary>
public class StateChangedEventArgs : EventArgs
{
/// <summary>
/// The previous state of the control.
/// </summary>
public TransformerState PreviousState { get; }
/// <summary>
/// The new/current state of the control.
/// </summary>
public TransformerState NewState { get; }
public StateChangedEventArgs(TransformerState previousState, TransformerState newState)
{
PreviousState = previousState;
NewState = newState;
}
}

View File

@ -0,0 +1,23 @@
using System.Net;
namespace MarketAlly.Replicate.Maui
{
public class ReplicateApiException : Exception
{
public HttpStatusCode StatusCode { get; }
public string ResponseBody { get; }
public ReplicateApiException(string message, HttpStatusCode statusCode, string responseBody)
: base(message)
{
StatusCode = statusCode;
ResponseBody = responseBody;
}
}
public class ReplicateTransformationException : Exception
{
public ReplicateTransformationException(string message) : base(message) { }
public ReplicateTransformationException(string message, Exception innerException) : base(message, innerException) { }
}
}

View File

@ -0,0 +1,426 @@
namespace MarketAlly.Replicate.Maui
{
/// <summary>
/// Type of transformation being performed.
/// </summary>
public enum TransformationType
{
Image,
Video
}
/// <summary>
/// Status of a prediction in the Replicate API.
/// </summary>
public enum PredictionStatus
{
/// <summary>
/// Prediction is starting up.
/// </summary>
Starting,
/// <summary>
/// Prediction is actively processing.
/// </summary>
Processing,
/// <summary>
/// Prediction completed successfully.
/// </summary>
Succeeded,
/// <summary>
/// Prediction failed with an error.
/// </summary>
Failed,
/// <summary>
/// Prediction was canceled by the user.
/// </summary>
Canceled,
/// <summary>
/// Unknown or unrecognized status.
/// </summary>
Unknown
}
/// <summary>
/// Helper extensions for PredictionStatus.
/// </summary>
public static class PredictionStatusExtensions
{
/// <summary>
/// Convert a status string from the API to a PredictionStatus enum.
/// </summary>
public static PredictionStatus ToPredictionStatus(this string? status)
{
return status?.ToLowerInvariant() switch
{
"starting" => PredictionStatus.Starting,
"processing" => PredictionStatus.Processing,
"succeeded" => PredictionStatus.Succeeded,
"failed" => PredictionStatus.Failed,
"canceled" => PredictionStatus.Canceled,
_ => PredictionStatus.Unknown
};
}
/// <summary>
/// Convert a PredictionStatus enum to the API string value.
/// </summary>
public static string ToApiString(this PredictionStatus status)
{
return status switch
{
PredictionStatus.Starting => "starting",
PredictionStatus.Processing => "processing",
PredictionStatus.Succeeded => "succeeded",
PredictionStatus.Failed => "failed",
PredictionStatus.Canceled => "canceled",
_ => "unknown"
};
}
/// <summary>
/// Whether this status represents a completed prediction (succeeded, failed, or canceled).
/// </summary>
public static bool IsCompleted(this PredictionStatus status)
{
return status is PredictionStatus.Succeeded or PredictionStatus.Failed or PredictionStatus.Canceled;
}
/// <summary>
/// Whether this status represents a pending prediction (starting or processing).
/// </summary>
public static bool IsPending(this PredictionStatus status)
{
return status is PredictionStatus.Starting or PredictionStatus.Processing;
}
}
/// <summary>
/// Event args for when a prediction is created (before polling starts).
/// </summary>
public class PredictionCreatedEventArgs : EventArgs
{
/// <summary>
/// The initial prediction result with ID and starting status.
/// </summary>
public PredictionResult Prediction { get; }
public PredictionCreatedEventArgs(PredictionResult prediction)
{
Prediction = prediction;
}
}
/// <summary>
/// Interface for Replicate API image and video transformations.
/// </summary>
public interface IReplicateTransformer
{
/// <summary>
/// Raised when a prediction is created (before polling starts).
/// Use this to track predictions immediately when they're submitted.
/// </summary>
event EventHandler<PredictionCreatedEventArgs>? PredictionCreated;
/// <summary>
/// Transform an image to anime style using raw bytes.
/// </summary>
/// <param name="imageBytes">The image bytes to transform.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToAnimeAsync(
byte[] imageBytes,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Transform an image to anime style using base64 encoded string.
/// </summary>
/// <param name="base64Image">Base64 encoded image (with or without data URI prefix).</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToAnimeFromBase64Async(
string base64Image,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Transform an image to anime style from a URL.
/// </summary>
/// <param name="imageUrl">URL of the image to transform.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToAnimeFromUrlAsync(
string imageUrl,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Generate a video from an image using raw bytes.
/// </summary>
/// <param name="imageBytes">The image bytes to animate.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToVideoAsync(
byte[] imageBytes,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Generate a video from a base64 encoded image.
/// </summary>
/// <param name="base64Image">Base64 encoded image (with or without data URI prefix).</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToVideoFromBase64Async(
string base64Image,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Generate a video from an image URL.
/// </summary>
/// <param name="imageUrl">URL of the image to animate.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Prediction result with output URL and metadata.</returns>
Task<PredictionResult> TransformToVideoFromUrlAsync(
string imageUrl,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Cancel a running prediction.
/// </summary>
/// <param name="predictionId">The prediction ID to cancel.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task CancelPredictionAsync(string predictionId, CancellationToken cancellationToken = default);
/// <summary>
/// Get the status of a prediction.
/// </summary>
/// <param name="predictionId">The prediction ID to check.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Current prediction status and result if completed.</returns>
Task<PredictionResult> GetPredictionAsync(string predictionId, CancellationToken cancellationToken = default);
/// <summary>
/// Run a prediction using a model preset with image bytes.
/// </summary>
/// <param name="preset">The model preset to use.</param>
/// <param name="imageBytes">The image bytes.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="customParameters">Optional parameters to override preset defaults.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictionResult> RunPresetAsync(
ModelPreset preset,
byte[] imageBytes,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Run a prediction using a model preset with base64 image.
/// </summary>
/// <param name="preset">The model preset to use.</param>
/// <param name="base64Image">Base64 encoded image (with or without data URI prefix).</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="customParameters">Optional parameters to override preset defaults.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictionResult> RunPresetFromBase64Async(
ModelPreset preset,
string base64Image,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Run a prediction using a model preset with image URL.
/// </summary>
/// <param name="preset">The model preset to use.</param>
/// <param name="imageUrl">URL of the image.</param>
/// <param name="customPrompt">Optional custom prompt override.</param>
/// <param name="customParameters">Optional parameters to override preset defaults.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictionResult> RunPresetFromUrlAsync(
ModelPreset preset,
string imageUrl,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Run a text-only prediction using a model preset (no image input).
/// </summary>
/// <param name="preset">The model preset to use.</param>
/// <param name="prompt">The prompt for generation.</param>
/// <param name="customParameters">Optional parameters to override preset defaults.</param>
/// <param name="options">Optional prediction options (webhook, sync mode, etc.).</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<PredictionResult> RunPresetTextOnlyAsync(
ModelPreset preset,
string prompt,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Options for creating a prediction.
/// </summary>
public class PredictionOptions
{
/// <summary>
/// Webhook URL to receive prediction updates.
/// </summary>
public string? WebhookUrl { get; set; }
/// <summary>
/// Filter which events trigger webhook calls.
/// Options: "start", "output", "logs", "completed"
/// </summary>
public string[]? WebhookEventsFilter { get; set; }
/// <summary>
/// Use sync mode - wait for result up to specified seconds (1-60).
/// If null, uses async mode with polling.
/// </summary>
public int? SyncModeWaitSeconds { get; set; }
/// <summary>
/// If true and using sync mode, don't poll - just return the initial response.
/// Useful when using webhooks.
/// </summary>
public bool WebhookOnly { get; set; }
}
/// <summary>
/// Result of a prediction.
/// </summary>
public class PredictionResult
{
/// <summary>
/// Unique prediction identifier.
/// </summary>
public string Id { get; set; } = string.Empty;
/// <summary>
/// Current status as string: starting, processing, succeeded, failed, canceled.
/// Consider using StatusEnum for type-safe comparisons.
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Current status as a strongly-typed enum.
/// </summary>
public PredictionStatus StatusEnum => Status.ToPredictionStatus();
/// <summary>
/// Output URL(s) if succeeded.
/// </summary>
public string? Output { get; set; }
/// <summary>
/// All output URLs if multiple outputs.
/// </summary>
public string[]? Outputs { get; set; }
/// <summary>
/// Error message if failed.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// Prediction metrics (predict_time, total_time).
/// </summary>
public PredictionMetrics? Metrics { get; set; }
/// <summary>
/// When the prediction was created.
/// </summary>
public DateTimeOffset? CreatedAt { get; set; }
/// <summary>
/// When the prediction started processing.
/// </summary>
public DateTimeOffset? StartedAt { get; set; }
/// <summary>
/// When the prediction completed.
/// </summary>
public DateTimeOffset? CompletedAt { get; set; }
/// <summary>
/// URL to cancel the prediction.
/// </summary>
public string? CancelUrl { get; set; }
/// <summary>
/// Whether the prediction completed (succeeded, failed, or canceled).
/// </summary>
public bool IsCompleted => StatusEnum.IsCompleted();
/// <summary>
/// Whether the prediction succeeded.
/// </summary>
public bool IsSucceeded => StatusEnum == PredictionStatus.Succeeded;
/// <summary>
/// Whether the prediction is pending (starting or processing).
/// </summary>
public bool IsPending => StatusEnum.IsPending();
/// <summary>
/// Whether the prediction failed.
/// </summary>
public bool IsFailed => StatusEnum == PredictionStatus.Failed;
/// <summary>
/// Whether the prediction was canceled.
/// </summary>
public bool IsCanceled => StatusEnum == PredictionStatus.Canceled;
}
/// <summary>
/// Prediction performance metrics.
/// </summary>
public class PredictionMetrics
{
/// <summary>
/// Time spent running the model (seconds).
/// </summary>
public double? PredictTime { get; set; }
/// <summary>
/// Total time including queue wait (seconds).
/// </summary>
public double? TotalTime { get; set; }
}
}

View File

@ -0,0 +1,608 @@
using System.Collections.Concurrent;
using System.Globalization;
namespace MarketAlly.Replicate.Maui.Localization;
/// <summary>
/// Provides localized strings for the Replicate.Maui controls.
/// Supports English, Spanish, French, German, Chinese, Japanese, Portuguese, and Italian.
/// Custom translations can be added or overridden using RegisterTranslations.
/// </summary>
public static class ReplicateStrings
{
private static readonly ConcurrentDictionary<string, Dictionary<string, string>> _translations = new();
private static string _currentCulture = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
/// <summary>
/// String keys used throughout the control.
/// </summary>
public static class Keys
{
// Button labels
public const string Take = nameof(Take);
public const string Pick = nameof(Pick);
public const string Select = nameof(Select);
public const string Transform = nameof(Transform);
public const string Clear = nameof(Clear);
public const string Redo = nameof(Redo);
public const string Reset = nameof(Reset);
public const string Cancel = nameof(Cancel);
public const string Image = nameof(Image);
public const string GenerateVideo = nameof(GenerateVideo);
public const string Record = nameof(Record);
public const string PickVideo = nameof(PickVideo);
// Status messages
public const string Transforming = nameof(Transforming);
public const string GeneratingVideo = nameof(GeneratingVideo);
public const string TransformingImage = nameof(TransformingImage);
public const string Processing = nameof(Processing);
public const string PleaseWait = nameof(PleaseWait);
public const string MayTakeAMoment = nameof(MayTakeAMoment);
public const string MayTakeSeveralMinutes = nameof(MayTakeSeveralMinutes);
public const string TransformationComplete = nameof(TransformationComplete);
public const string VideoGenerationComplete = nameof(VideoGenerationComplete);
public const string TransformationCancelled = nameof(TransformationCancelled);
// Placeholders
public const string TapToSelectImage = nameof(TapToSelectImage);
public const string SourceImage = nameof(SourceImage);
public const string Result = nameof(Result);
public const string NoImageSelected = nameof(NoImageSelected);
// Errors
public const string Error = nameof(Error);
public const string Dismiss = nameof(Dismiss);
public const string TransformationFailed = nameof(TransformationFailed);
public const string NoTransformerInitialized = nameof(NoTransformerInitialized);
// Prediction status
public const string Starting = nameof(Starting);
public const string Succeeded = nameof(Succeeded);
public const string Failed = nameof(Failed);
public const string Canceled = nameof(Canceled);
public const string Unknown = nameof(Unknown);
public const string Pending = nameof(Pending);
}
static ReplicateStrings()
{
InitializeDefaultTranslations();
}
/// <summary>
/// Gets or sets the current culture for localization.
/// Defaults to the system's current UI culture.
/// </summary>
public static string CurrentCulture
{
get => _currentCulture;
set => _currentCulture = value?.ToLowerInvariant() ?? "en";
}
/// <summary>
/// Set the current culture from a CultureInfo.
/// </summary>
public static void SetCulture(CultureInfo culture)
{
CurrentCulture = culture.TwoLetterISOLanguageName;
}
/// <summary>
/// Set the current culture to match the system's current UI culture.
/// </summary>
public static void UseSystemCulture()
{
CurrentCulture = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
}
/// <summary>
/// Get a localized string by key.
/// Falls back to English if the key is not found in the current culture.
/// </summary>
/// <param name="key">The string key.</param>
/// <returns>The localized string, or the key itself if not found.</returns>
public static string Get(string key)
{
// Try current culture
if (_translations.TryGetValue(_currentCulture, out var currentStrings) &&
currentStrings.TryGetValue(key, out var value))
{
return value;
}
// Fall back to English
if (_translations.TryGetValue("en", out var englishStrings) &&
englishStrings.TryGetValue(key, out var englishValue))
{
return englishValue;
}
// Return key as last resort
return key;
}
/// <summary>
/// Get a localized string by key for a specific culture.
/// </summary>
/// <param name="key">The string key.</param>
/// <param name="culture">The culture code (e.g., "en", "es", "fr").</param>
/// <returns>The localized string, or falls back to English, or the key itself.</returns>
public static string Get(string key, string culture)
{
culture = culture?.ToLowerInvariant() ?? "en";
if (_translations.TryGetValue(culture, out var strings) &&
strings.TryGetValue(key, out var value))
{
return value;
}
// Fall back to English
if (_translations.TryGetValue("en", out var englishStrings) &&
englishStrings.TryGetValue(key, out var englishValue))
{
return englishValue;
}
return key;
}
/// <summary>
/// Register or override translations for a specific culture.
/// </summary>
/// <param name="culture">The culture code (e.g., "en", "es", "fr").</param>
/// <param name="translations">Dictionary of key-value translations.</param>
/// <example>
/// <code>
/// // Add custom Spanish translations
/// ReplicateStrings.RegisterTranslations("es", new Dictionary&lt;string, string&gt;
/// {
/// [ReplicateStrings.Keys.Transform] = "Convertir",
/// [ReplicateStrings.Keys.GenerateVideo] = "Crear vídeo"
/// });
/// </code>
/// </example>
public static void RegisterTranslations(string culture, Dictionary<string, string> translations)
{
culture = culture?.ToLowerInvariant() ?? "en";
_translations.AddOrUpdate(
culture,
translations,
(key, existing) =>
{
foreach (var kvp in translations)
{
existing[kvp.Key] = kvp.Value;
}
return existing;
});
}
/// <summary>
/// Register or override a single translation for a specific culture.
/// </summary>
/// <param name="culture">The culture code.</param>
/// <param name="key">The string key.</param>
/// <param name="value">The translated value.</param>
public static void RegisterTranslation(string culture, string key, string value)
{
culture = culture?.ToLowerInvariant() ?? "en";
_translations.AddOrUpdate(
culture,
new Dictionary<string, string> { [key] = value },
(k, existing) =>
{
existing[key] = value;
return existing;
});
}
/// <summary>
/// Check if a culture has any translations registered.
/// </summary>
public static bool HasCulture(string culture)
{
return _translations.ContainsKey(culture?.ToLowerInvariant() ?? "en");
}
/// <summary>
/// Get all supported culture codes.
/// </summary>
public static IEnumerable<string> SupportedCultures => _translations.Keys;
/// <summary>
/// Clear all custom translations and restore defaults.
/// </summary>
public static void ResetToDefaults()
{
_translations.Clear();
InitializeDefaultTranslations();
}
private static void InitializeDefaultTranslations()
{
// English (en)
RegisterTranslations("en", new Dictionary<string, string>
{
// Button labels
[Keys.Take] = "Take",
[Keys.Pick] = "Pick",
[Keys.Select] = "Select",
[Keys.Transform] = "Transform",
[Keys.Clear] = "Clear",
[Keys.Redo] = "Redo",
[Keys.Reset] = "Reset",
[Keys.Cancel] = "Cancel",
[Keys.Image] = "Image",
[Keys.GenerateVideo] = "Generate Video",
[Keys.Record] = "Record",
[Keys.PickVideo] = "Pick Video",
// Status messages
[Keys.Transforming] = "Transforming...",
[Keys.GeneratingVideo] = "Generating video...",
[Keys.TransformingImage] = "Transforming image...",
[Keys.Processing] = "Processing",
[Keys.PleaseWait] = "Please wait",
[Keys.MayTakeAMoment] = "This may take a moment",
[Keys.MayTakeSeveralMinutes] = "This may take several minutes",
[Keys.TransformationComplete] = "Transformation complete",
[Keys.VideoGenerationComplete] = "Video generation complete",
[Keys.TransformationCancelled] = "Transformation cancelled",
// Placeholders
[Keys.TapToSelectImage] = "Tap to select image",
[Keys.SourceImage] = "Source",
[Keys.Result] = "Result",
[Keys.NoImageSelected] = "No image selected",
// Errors
[Keys.Error] = "Error",
[Keys.Dismiss] = "Dismiss",
[Keys.TransformationFailed] = "Transformation failed",
[Keys.NoTransformerInitialized] = "No transformer initialized",
// Prediction status
[Keys.Starting] = "Starting",
[Keys.Succeeded] = "Succeeded",
[Keys.Failed] = "Failed",
[Keys.Canceled] = "Canceled",
[Keys.Unknown] = "Unknown",
[Keys.Pending] = "Pending"
});
// Spanish (es)
RegisterTranslations("es", new Dictionary<string, string>
{
[Keys.Take] = "Tomar",
[Keys.Pick] = "Elegir",
[Keys.Select] = "Seleccionar",
[Keys.Transform] = "Transformar",
[Keys.Clear] = "Borrar",
[Keys.Redo] = "Rehacer",
[Keys.Reset] = "Reiniciar",
[Keys.Cancel] = "Cancelar",
[Keys.Image] = "Imagen",
[Keys.GenerateVideo] = "Generar vídeo",
[Keys.Record] = "Grabar",
[Keys.PickVideo] = "Elegir vídeo",
[Keys.Transforming] = "Transformando...",
[Keys.GeneratingVideo] = "Generando vídeo...",
[Keys.TransformingImage] = "Transformando imagen...",
[Keys.Processing] = "Procesando",
[Keys.PleaseWait] = "Por favor espere",
[Keys.MayTakeAMoment] = "Esto puede tardar un momento",
[Keys.MayTakeSeveralMinutes] = "Esto puede tardar varios minutos",
[Keys.TransformationComplete] = "Transformación completada",
[Keys.VideoGenerationComplete] = "Generación de vídeo completada",
[Keys.TransformationCancelled] = "Transformación cancelada",
[Keys.TapToSelectImage] = "Toque para seleccionar imagen",
[Keys.SourceImage] = "Origen",
[Keys.Result] = "Resultado",
[Keys.NoImageSelected] = "Ninguna imagen seleccionada",
[Keys.Error] = "Error",
[Keys.Dismiss] = "Cerrar",
[Keys.TransformationFailed] = "La transformación falló",
[Keys.NoTransformerInitialized] = "Transformador no inicializado",
[Keys.Starting] = "Iniciando",
[Keys.Succeeded] = "Exitoso",
[Keys.Failed] = "Fallido",
[Keys.Canceled] = "Cancelado",
[Keys.Unknown] = "Desconocido",
[Keys.Pending] = "Pendiente"
});
// French (fr)
RegisterTranslations("fr", new Dictionary<string, string>
{
[Keys.Take] = "Prendre",
[Keys.Pick] = "Choisir",
[Keys.Select] = "Sélectionner",
[Keys.Transform] = "Transformer",
[Keys.Clear] = "Effacer",
[Keys.Redo] = "Refaire",
[Keys.Reset] = "Réinitialiser",
[Keys.Cancel] = "Annuler",
[Keys.Image] = "Image",
[Keys.GenerateVideo] = "Générer vidéo",
[Keys.Record] = "Enregistrer",
[Keys.PickVideo] = "Choisir vidéo",
[Keys.Transforming] = "Transformation...",
[Keys.GeneratingVideo] = "Génération de vidéo...",
[Keys.TransformingImage] = "Transformation de l'image...",
[Keys.Processing] = "Traitement",
[Keys.PleaseWait] = "Veuillez patienter",
[Keys.MayTakeAMoment] = "Cela peut prendre un moment",
[Keys.MayTakeSeveralMinutes] = "Cela peut prendre plusieurs minutes",
[Keys.TransformationComplete] = "Transformation terminée",
[Keys.VideoGenerationComplete] = "Génération vidéo terminée",
[Keys.TransformationCancelled] = "Transformation annulée",
[Keys.TapToSelectImage] = "Appuyez pour sélectionner une image",
[Keys.SourceImage] = "Source",
[Keys.Result] = "Résultat",
[Keys.NoImageSelected] = "Aucune image sélectionnée",
[Keys.Error] = "Erreur",
[Keys.Dismiss] = "Fermer",
[Keys.TransformationFailed] = "La transformation a échoué",
[Keys.NoTransformerInitialized] = "Transformateur non initialisé",
[Keys.Starting] = "Démarrage",
[Keys.Succeeded] = "Réussi",
[Keys.Failed] = "Échoué",
[Keys.Canceled] = "Annulé",
[Keys.Unknown] = "Inconnu",
[Keys.Pending] = "En attente"
});
// German (de)
RegisterTranslations("de", new Dictionary<string, string>
{
[Keys.Take] = "Aufnehmen",
[Keys.Pick] = "Auswählen",
[Keys.Select] = "Wählen",
[Keys.Transform] = "Transformieren",
[Keys.Clear] = "Löschen",
[Keys.Redo] = "Wiederholen",
[Keys.Reset] = "Zurücksetzen",
[Keys.Cancel] = "Abbrechen",
[Keys.Image] = "Bild",
[Keys.GenerateVideo] = "Video erstellen",
[Keys.Record] = "Aufzeichnen",
[Keys.PickVideo] = "Video auswählen",
[Keys.Transforming] = "Transformiere...",
[Keys.GeneratingVideo] = "Video wird erstellt...",
[Keys.TransformingImage] = "Bild wird transformiert...",
[Keys.Processing] = "Verarbeitung",
[Keys.PleaseWait] = "Bitte warten",
[Keys.MayTakeAMoment] = "Dies kann einen Moment dauern",
[Keys.MayTakeSeveralMinutes] = "Dies kann mehrere Minuten dauern",
[Keys.TransformationComplete] = "Transformation abgeschlossen",
[Keys.VideoGenerationComplete] = "Videogenerierung abgeschlossen",
[Keys.TransformationCancelled] = "Transformation abgebrochen",
[Keys.TapToSelectImage] = "Tippen um Bild auszuwählen",
[Keys.SourceImage] = "Quelle",
[Keys.Result] = "Ergebnis",
[Keys.NoImageSelected] = "Kein Bild ausgewählt",
[Keys.Error] = "Fehler",
[Keys.Dismiss] = "Schließen",
[Keys.TransformationFailed] = "Transformation fehlgeschlagen",
[Keys.NoTransformerInitialized] = "Transformer nicht initialisiert",
[Keys.Starting] = "Startet",
[Keys.Succeeded] = "Erfolgreich",
[Keys.Failed] = "Fehlgeschlagen",
[Keys.Canceled] = "Abgebrochen",
[Keys.Unknown] = "Unbekannt",
[Keys.Pending] = "Ausstehend"
});
// Chinese Simplified (zh)
RegisterTranslations("zh", new Dictionary<string, string>
{
[Keys.Take] = "拍照",
[Keys.Pick] = "选择",
[Keys.Select] = "选择",
[Keys.Transform] = "转换",
[Keys.Clear] = "清除",
[Keys.Redo] = "重做",
[Keys.Reset] = "重置",
[Keys.Cancel] = "取消",
[Keys.Image] = "图片",
[Keys.GenerateVideo] = "生成视频",
[Keys.Record] = "录制",
[Keys.PickVideo] = "选择视频",
[Keys.Transforming] = "正在转换...",
[Keys.GeneratingVideo] = "正在生成视频...",
[Keys.TransformingImage] = "正在转换图片...",
[Keys.Processing] = "处理中",
[Keys.PleaseWait] = "请稍候",
[Keys.MayTakeAMoment] = "这可能需要一点时间",
[Keys.MayTakeSeveralMinutes] = "这可能需要几分钟",
[Keys.TransformationComplete] = "转换完成",
[Keys.VideoGenerationComplete] = "视频生成完成",
[Keys.TransformationCancelled] = "转换已取消",
[Keys.TapToSelectImage] = "点击选择图片",
[Keys.SourceImage] = "原图",
[Keys.Result] = "结果",
[Keys.NoImageSelected] = "未选择图片",
[Keys.Error] = "错误",
[Keys.Dismiss] = "关闭",
[Keys.TransformationFailed] = "转换失败",
[Keys.NoTransformerInitialized] = "转换器未初始化",
[Keys.Starting] = "启动中",
[Keys.Succeeded] = "成功",
[Keys.Failed] = "失败",
[Keys.Canceled] = "已取消",
[Keys.Unknown] = "未知",
[Keys.Pending] = "等待中"
});
// Japanese (ja)
RegisterTranslations("ja", new Dictionary<string, string>
{
[Keys.Take] = "撮影",
[Keys.Pick] = "選択",
[Keys.Select] = "選択",
[Keys.Transform] = "変換",
[Keys.Clear] = "クリア",
[Keys.Redo] = "やり直し",
[Keys.Reset] = "リセット",
[Keys.Cancel] = "キャンセル",
[Keys.Image] = "画像",
[Keys.GenerateVideo] = "動画生成",
[Keys.Record] = "録画",
[Keys.PickVideo] = "動画を選択",
[Keys.Transforming] = "変換中...",
[Keys.GeneratingVideo] = "動画を生成中...",
[Keys.TransformingImage] = "画像を変換中...",
[Keys.Processing] = "処理中",
[Keys.PleaseWait] = "お待ちください",
[Keys.MayTakeAMoment] = "しばらくお待ちください",
[Keys.MayTakeSeveralMinutes] = "数分かかる場合があります",
[Keys.TransformationComplete] = "変換完了",
[Keys.VideoGenerationComplete] = "動画生成完了",
[Keys.TransformationCancelled] = "変換がキャンセルされました",
[Keys.TapToSelectImage] = "タップして画像を選択",
[Keys.SourceImage] = "元画像",
[Keys.Result] = "結果",
[Keys.NoImageSelected] = "画像が選択されていません",
[Keys.Error] = "エラー",
[Keys.Dismiss] = "閉じる",
[Keys.TransformationFailed] = "変換に失敗しました",
[Keys.NoTransformerInitialized] = "トランスフォーマーが初期化されていません",
[Keys.Starting] = "開始中",
[Keys.Succeeded] = "成功",
[Keys.Failed] = "失敗",
[Keys.Canceled] = "キャンセル済み",
[Keys.Unknown] = "不明",
[Keys.Pending] = "保留中"
});
// Portuguese (pt)
RegisterTranslations("pt", new Dictionary<string, string>
{
[Keys.Take] = "Tirar",
[Keys.Pick] = "Escolher",
[Keys.Select] = "Selecionar",
[Keys.Transform] = "Transformar",
[Keys.Clear] = "Limpar",
[Keys.Redo] = "Refazer",
[Keys.Reset] = "Redefinir",
[Keys.Cancel] = "Cancelar",
[Keys.Image] = "Imagem",
[Keys.GenerateVideo] = "Gerar vídeo",
[Keys.Record] = "Gravar",
[Keys.PickVideo] = "Escolher vídeo",
[Keys.Transforming] = "Transformando...",
[Keys.GeneratingVideo] = "Gerando vídeo...",
[Keys.TransformingImage] = "Transformando imagem...",
[Keys.Processing] = "Processando",
[Keys.PleaseWait] = "Por favor aguarde",
[Keys.MayTakeAMoment] = "Isso pode levar um momento",
[Keys.MayTakeSeveralMinutes] = "Isso pode levar vários minutos",
[Keys.TransformationComplete] = "Transformação concluída",
[Keys.VideoGenerationComplete] = "Geração de vídeo concluída",
[Keys.TransformationCancelled] = "Transformação cancelada",
[Keys.TapToSelectImage] = "Toque para selecionar imagem",
[Keys.SourceImage] = "Origem",
[Keys.Result] = "Resultado",
[Keys.NoImageSelected] = "Nenhuma imagem selecionada",
[Keys.Error] = "Erro",
[Keys.Dismiss] = "Fechar",
[Keys.TransformationFailed] = "A transformação falhou",
[Keys.NoTransformerInitialized] = "Transformador não inicializado",
[Keys.Starting] = "Iniciando",
[Keys.Succeeded] = "Sucesso",
[Keys.Failed] = "Falhou",
[Keys.Canceled] = "Cancelado",
[Keys.Unknown] = "Desconhecido",
[Keys.Pending] = "Pendente"
});
// Italian (it)
RegisterTranslations("it", new Dictionary<string, string>
{
[Keys.Take] = "Scatta",
[Keys.Pick] = "Scegli",
[Keys.Select] = "Seleziona",
[Keys.Transform] = "Trasforma",
[Keys.Clear] = "Cancella",
[Keys.Redo] = "Ripeti",
[Keys.Reset] = "Reimposta",
[Keys.Cancel] = "Annulla",
[Keys.Image] = "Immagine",
[Keys.GenerateVideo] = "Genera video",
[Keys.Record] = "Registra",
[Keys.PickVideo] = "Scegli video",
[Keys.Transforming] = "Trasformazione...",
[Keys.GeneratingVideo] = "Generazione video...",
[Keys.TransformingImage] = "Trasformazione immagine...",
[Keys.Processing] = "Elaborazione",
[Keys.PleaseWait] = "Attendere prego",
[Keys.MayTakeAMoment] = "Potrebbe richiedere un momento",
[Keys.MayTakeSeveralMinutes] = "Potrebbe richiedere diversi minuti",
[Keys.TransformationComplete] = "Trasformazione completata",
[Keys.VideoGenerationComplete] = "Generazione video completata",
[Keys.TransformationCancelled] = "Trasformazione annullata",
[Keys.TapToSelectImage] = "Tocca per selezionare l'immagine",
[Keys.SourceImage] = "Origine",
[Keys.Result] = "Risultato",
[Keys.NoImageSelected] = "Nessuna immagine selezionata",
[Keys.Error] = "Errore",
[Keys.Dismiss] = "Chiudi",
[Keys.TransformationFailed] = "Trasformazione fallita",
[Keys.NoTransformerInitialized] = "Trasformatore non inizializzato",
[Keys.Starting] = "Avvio",
[Keys.Succeeded] = "Riuscito",
[Keys.Failed] = "Fallito",
[Keys.Canceled] = "Annullato",
[Keys.Unknown] = "Sconosciuto",
[Keys.Pending] = "In attesa"
});
}
}
/// <summary>
/// Extension method for easy localization access.
/// </summary>
public static class LocalizationExtensions
{
/// <summary>
/// Get the localized string for this key.
/// </summary>
public static string Localized(this string key) => ReplicateStrings.Get(key);
/// <summary>
/// Get the localized string for this key in a specific culture.
/// </summary>
public static string Localized(this string key, string culture) => ReplicateStrings.Get(key, culture);
}

View File

@ -0,0 +1,410 @@
namespace MarketAlly.Replicate.Maui
{
/// <summary>
/// Predefined model presets for popular Replicate models.
/// </summary>
public static class ModelPresets
{
#region Image Models
/// <summary>
/// HiDream E1.1 by PrunaAI - Fast high-quality image generation/editing.
/// </summary>
public static ModelPreset HiDreamE1 => new()
{
Name = "HiDream E1.1",
ModelName = "prunaai/hidream-e1.1",
ModelVersion = "433436facdc1172b6efcb801eb6f345d7858a32200d24e5febaccfb4b44ad66f",
Type = ModelType.Image,
DefaultPrompt = "anime style portrait, soft clean lines, studio ghibli inspired, friendly expression, cinematic lighting",
DefaultParameters = new Dictionary<string, object>
{
{ "speed_mode", "Juiced 🔥 (more speed)" },
{ "seed", -1 },
{ "output_format", "webp" },
{ "output_quality", 100 },
{ "guidance_scale", 2.5 },
{ "num_inference_steps", 28 },
{ "image_guidance_scale", 1.0 },
{ "refine_strength", 0.3 },
{ "clip_cfg_norm", true }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 300,
PollingDelayMs = 1500
};
/// <summary>
/// Stable Diffusion XL - Popular open-source image generation model.
/// </summary>
public static ModelPreset StableDiffusionXL => new()
{
Name = "Stable Diffusion XL",
ModelName = "stability-ai/sdxl",
ModelVersion = "7762fd07cf82c948538e41f63f77d685e02b063e37e496e96eefd46c929f9bdc",
Type = ModelType.Image,
DefaultPrompt = "high quality, detailed",
DefaultParameters = new Dictionary<string, object>
{
{ "width", 1024 },
{ "height", 1024 },
{ "num_outputs", 1 },
{ "scheduler", "K_EULER" },
{ "num_inference_steps", 25 },
{ "guidance_scale", 7.5 },
{ "refine", "expert_ensemble_refiner" }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 300,
PollingDelayMs = 1500
};
/// <summary>
/// Ideogram V3 Turbo - Best model for generating images with realistic, legible text.
/// </summary>
public static ModelPreset IdeogramV3Turbo => new()
{
Name = "Ideogram V3 Turbo",
ModelName = "ideogram-ai/ideogram-v3-turbo",
ModelVersion = "d9b3748f95c0fe3e71f010f8cc5d80e8f5252acd0e74b1c294ee889eea52a47b",
Type = ModelType.Image,
DefaultPrompt = "high quality image",
DefaultParameters = new Dictionary<string, object>
{
{ "aspect_ratio", "1:1" },
{ "magic_prompt_option", "Auto" }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 300,
PollingDelayMs = 1500
};
/// <summary>
/// Recraft V3 SVG - First major text-to-image model that generates high quality SVG images, logos, and icons.
/// </summary>
public static ModelPreset RecraftV3Svg => new()
{
Name = "Recraft V3 SVG",
ModelName = "recraft-ai/recraft-v3-svg",
ModelVersion = "df041379628fa1d16bd406409930775b0904dc2bc0f3e3f38ecd2a4389e9329d",
Type = ModelType.Image,
DefaultPrompt = "clean vector illustration",
DefaultParameters = new Dictionary<string, object>
{
{ "size", "1024x1024" },
{ "style", "any" }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 300,
PollingDelayMs = 1500
};
#endregion
#region Video Models
/// <summary>
/// Seedance 1 Pro by ByteDance - High-quality video generation with image-to-video support.
/// </summary>
public static ModelPreset SeedancePro => new()
{
Name = "Seedance 1 Pro",
ModelName = "bytedance/seedance-1-pro",
ModelVersion = "5fe042776269a7262e69b14f0b835b88b8e5eff9f990cadf31b8f984ed0419ad",
Type = ModelType.Video,
DefaultPrompt = "animate smoothly, maintain consistency",
DefaultParameters = new Dictionary<string, object>
{
{ "duration", 5 },
{ "resolution", "720p" },
{ "aspect_ratio", "16:9" },
{ "fps", 24 },
{ "camera_fixed", false }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// Ray Flash 2 720p by Luma - Fast video generation with camera control concepts.
/// </summary>
public static ModelPreset LumaRayFlash => new()
{
Name = "Luma Ray Flash 2",
ModelName = "luma/ray-flash-2-720p",
ModelVersion = "1cf3c6a9e26e7dadc776bca7497e003267acf1ec5b9ad3794d2a10a4bfa84751",
Type = ModelType.Video,
DefaultPrompt = "cinematic motion, smooth animation",
DefaultParameters = new Dictionary<string, object>
{
{ "duration", 5 },
{ "aspect_ratio", "16:9" },
{ "loop", false }
},
ImageInputKey = "start_image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// Kling 1.6 Pro by Kuaishou - Professional video generation.
/// </summary>
public static ModelPreset Kling16Pro => new()
{
Name = "Kling 1.6 Pro",
ModelName = "kwaivgi/kling-v1.6-pro",
ModelVersion = "974c9c5bc69f8f9c178ddea80d8936ba46c48081ad6b6ccca8843d44010c0642",
Type = ModelType.Video,
DefaultPrompt = "smooth cinematic video",
DefaultParameters = new Dictionary<string, object>
{
{ "duration", 5 },
{ "aspect_ratio", "16:9" }
},
ImageInputKey = "start_image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// Wan 2.1 by Alibaba - Image-to-video with 480p/720p support.
/// </summary>
public static ModelPreset Wan21 => new()
{
Name = "Wan 2.1",
ModelName = "alibaba/wan-2.1-i2v-480p",
ModelVersion = "67035bc8dd4a6058c0dcdeec7de3e65e4a5d0bf78258aeb2bb5e67096cb5cd45",
Type = ModelType.Video,
DefaultPrompt = "animate the image smoothly",
DefaultParameters = new Dictionary<string, object>
{
{ "max_area", "480p" },
{ "num_frames", 81 },
{ "fast_mode", "Balanced" }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// MiniMax Video 01 - Text and image to video generation.
/// </summary>
public static ModelPreset MiniMaxVideo => new()
{
Name = "MiniMax Video 01",
ModelName = "minimax/video-01",
ModelVersion = "c8bcc4751328608bb75043b3af7bed677d1e77e765a065bb298b8b137b94e86b",
Type = ModelType.Video,
DefaultPrompt = "cinematic video generation",
DefaultParameters = new Dictionary<string, object>(),
ImageInputKey = "first_frame_image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// Google Veo 3 Fast - State of the art video generation from Google.
/// </summary>
public static ModelPreset GoogleVeo3Fast => new()
{
Name = "Google Veo 3 Fast",
ModelName = "google/veo-3-fast",
ModelVersion = "368d4063e21ecf73746b8e6d27989837d97ba07b5eca43a4e5488c852e10c2ec",
Type = ModelType.Video,
DefaultPrompt = "cinematic video with natural motion",
DefaultParameters = new Dictionary<string, object>
{
{ "duration", 8 },
{ "aspect_ratio", "16:9" },
{ "resolution", "1080p" },
{ "generate_audio", true }
},
ImageInputKey = "image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
/// <summary>
/// Kling 2.5 Turbo Pro by Kuaishou - Latest high-quality video generation.
/// </summary>
public static ModelPreset Kling25TurboPro => new()
{
Name = "Kling 2.5 Turbo Pro",
ModelName = "kwaivgi/kling-v2.5-turbo-pro",
ModelVersion = "939cd1851c5b112f284681b57ee9b0f36d0f913ba97de5845a7eef92d52837df",
Type = ModelType.Video,
DefaultPrompt = "smooth cinematic video",
DefaultParameters = new Dictionary<string, object>
{
{ "duration", 5 },
{ "aspect_ratio", "16:9" },
{ "guidance_scale", 0.5 }
},
ImageInputKey = "start_image",
PromptKey = "prompt",
TimeoutSeconds = 600,
PollingDelayMs = 3000
};
#endregion
/// <summary>
/// Get all available presets.
/// </summary>
public static IReadOnlyList<ModelPreset> All => new[]
{
// Image models
HiDreamE1,
StableDiffusionXL,
IdeogramV3Turbo,
RecraftV3Svg,
// Video models
GoogleVeo3Fast,
Kling25TurboPro,
Kling16Pro,
SeedancePro,
LumaRayFlash,
Wan21,
MiniMaxVideo
};
/// <summary>
/// Get all image model presets.
/// </summary>
public static IReadOnlyList<ModelPreset> ImageModels => All.Where(p => p.Type == ModelType.Image).ToList();
/// <summary>
/// Get all video model presets.
/// </summary>
public static IReadOnlyList<ModelPreset> VideoModels => All.Where(p => p.Type == ModelType.Video).ToList();
/// <summary>
/// Find a preset by model name.
/// </summary>
public static ModelPreset? FindByName(string modelName)
{
return All.FirstOrDefault(p =>
p.ModelName.Equals(modelName, StringComparison.OrdinalIgnoreCase) ||
p.Name.Equals(modelName, StringComparison.OrdinalIgnoreCase));
}
}
/// <summary>
/// Type of model output.
/// </summary>
public enum ModelType
{
Image,
Video
}
/// <summary>
/// A preset configuration for a specific Replicate model.
/// </summary>
public class ModelPreset
{
/// <summary>
/// Display name for the preset.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Replicate model identifier (owner/model-name).
/// </summary>
public string ModelName { get; set; } = string.Empty;
/// <summary>
/// Specific model version hash.
/// </summary>
public string ModelVersion { get; set; } = string.Empty;
/// <summary>
/// Type of output (Image or Video).
/// </summary>
public ModelType Type { get; set; }
/// <summary>
/// Default prompt for this model.
/// </summary>
public string DefaultPrompt { get; set; } = string.Empty;
/// <summary>
/// Default parameters for this model's API.
/// </summary>
public Dictionary<string, object> DefaultParameters { get; set; } = new();
/// <summary>
/// The key name used for image input (e.g., "image", "start_image", "first_frame_image").
/// </summary>
public string ImageInputKey { get; set; } = "image";
/// <summary>
/// The key name used for the prompt (usually "prompt").
/// </summary>
public string PromptKey { get; set; } = "prompt";
/// <summary>
/// Timeout in seconds for this model.
/// </summary>
public int TimeoutSeconds { get; set; } = 300;
/// <summary>
/// Polling delay in milliseconds.
/// </summary>
public int PollingDelayMs { get; set; } = 2000;
/// <summary>
/// Create a copy of the default parameters that can be modified.
/// </summary>
public Dictionary<string, object> CloneParameters()
{
return new Dictionary<string, object>(DefaultParameters);
}
/// <summary>
/// Build the input dictionary for an API call.
/// </summary>
/// <param name="prompt">The prompt to use.</param>
/// <param name="imageData">Optional image data (base64 or URL).</param>
/// <param name="customParameters">Optional custom parameters to override defaults.</param>
public Dictionary<string, object> BuildInput(
string? prompt = null,
string? imageData = null,
Dictionary<string, object>? customParameters = null)
{
var input = CloneParameters();
// Set prompt
input[PromptKey] = prompt ?? DefaultPrompt;
// Set image if provided
if (!string.IsNullOrEmpty(imageData))
{
input[ImageInputKey] = imageData;
}
// Apply custom parameter overrides
if (customParameters != null)
{
foreach (var kvp in customParameters)
{
input[kvp.Key] = kvp.Value;
}
}
return input;
}
}
}

View File

@ -0,0 +1,440 @@
using System.Collections.Concurrent;
namespace MarketAlly.Replicate.Maui
{
/// <summary>
/// Interface for tracking prediction history and monitoring pending predictions.
/// </summary>
public interface IPredictionTracker : IDisposable
{
/// <summary>
/// Interval between status checks for pending predictions in milliseconds.
/// </summary>
int PollingIntervalMs { get; set; }
/// <summary>
/// Maximum number of predictions to keep in history.
/// </summary>
int MaxHistorySize { get; set; }
/// <summary>
/// Raised when a prediction's status changes.
/// </summary>
event EventHandler<PredictionStatusChangedEventArgs>? PredictionStatusChanged;
/// <summary>
/// Raised when a prediction completes (succeeded, failed, or canceled).
/// </summary>
event EventHandler<PredictionCompletedEventArgs>? PredictionCompleted;
/// <summary>
/// Gets all tracked predictions ordered by creation time (newest first).
/// </summary>
IReadOnlyList<TrackedPrediction> History { get; }
/// <summary>
/// Gets predictions that are still pending (starting or processing).
/// </summary>
IReadOnlyList<TrackedPrediction> PendingPredictions { get; }
/// <summary>
/// Start tracking a prediction. Automatically begins polling if not already running.
/// </summary>
TrackedPrediction Track(PredictionResult result, TransformationType type, byte[]? sourceImageBytes = null);
/// <summary>
/// Get a tracked prediction by ID.
/// </summary>
TrackedPrediction? Get(string predictionId);
/// <summary>
/// Manually refresh a prediction's status.
/// </summary>
Task<TrackedPrediction?> RefreshAsync(string predictionId, CancellationToken cancellationToken = default);
/// <summary>
/// Clear all completed predictions from history.
/// </summary>
void ClearCompleted();
/// <summary>
/// Clear all predictions from history.
/// </summary>
void ClearAll();
}
/// <summary>
/// Tracks prediction history and monitors pending predictions for completion.
/// </summary>
public class PredictionTracker : IPredictionTracker
{
private readonly ConcurrentDictionary<string, TrackedPrediction> _predictions = new();
private readonly IReplicateTransformer _transformer;
private readonly CancellationTokenSource _pollingCts = new();
private Task? _pollingTask;
private bool _disposed;
/// <summary>
/// Interval between status checks for pending predictions (default 3 seconds).
/// </summary>
public int PollingIntervalMs { get; set; } = 3000;
/// <summary>
/// Maximum number of predictions to keep in history (default 50).
/// </summary>
public int MaxHistorySize { get; set; } = 50;
/// <summary>
/// Raised when a prediction's status changes.
/// </summary>
public event EventHandler<PredictionStatusChangedEventArgs>? PredictionStatusChanged;
/// <summary>
/// Raised when a prediction completes (succeeded, failed, or canceled).
/// </summary>
public event EventHandler<PredictionCompletedEventArgs>? PredictionCompleted;
/// <summary>
/// Gets all tracked predictions ordered by creation time (newest first).
/// </summary>
public IReadOnlyList<TrackedPrediction> History =>
_predictions.Values.OrderByDescending(p => p.CreatedAt).ToList();
/// <summary>
/// Gets predictions that are still pending (starting or processing).
/// </summary>
public IReadOnlyList<TrackedPrediction> PendingPredictions =>
_predictions.Values.Where(p => !p.IsCompleted).OrderByDescending(p => p.CreatedAt).ToList();
public PredictionTracker(IReplicateTransformer transformer)
{
_transformer = transformer;
// Subscribe to prediction creation to track immediately
_transformer.PredictionCreated += OnPredictionCreated;
}
private void OnPredictionCreated(object? sender, PredictionCreatedEventArgs e)
{
// Auto-track predictions as soon as they're created
// This ensures they appear in history immediately with "starting" status
var prediction = e.Prediction;
// Only track if not already tracked (avoid duplicates)
if (!_predictions.ContainsKey(prediction.Id))
{
var tracked = new TrackedPrediction
{
Id = prediction.Id,
Type = TransformationType.Image, // Will be updated by explicit Track() call
Status = prediction.Status,
Output = prediction.Output,
Outputs = prediction.Outputs,
Error = prediction.Error,
Metrics = prediction.Metrics,
CreatedAt = prediction.CreatedAt ?? DateTimeOffset.Now,
StartedAt = prediction.StartedAt,
CompletedAt = prediction.CompletedAt,
SourceImageBytes = null // Will be updated by explicit Track() call
};
_predictions[prediction.Id] = tracked;
TrimHistory();
// Fire status event immediately
PredictionStatusChanged?.Invoke(this, new PredictionStatusChangedEventArgs(
tracked, string.Empty, prediction.Status));
// Start polling for this prediction
if (!tracked.IsCompleted)
{
StartPolling();
}
}
}
/// <summary>
/// Start tracking a prediction. Automatically begins polling if not already running.
/// If the prediction was already auto-tracked via PredictionCreated event, updates it with type and source image.
/// </summary>
public TrackedPrediction Track(PredictionResult result, TransformationType type, byte[]? sourceImageBytes = null)
{
// Check if already tracked (auto-tracked via PredictionCreated event)
if (_predictions.TryGetValue(result.Id, out var existing))
{
// Update with additional info that wasn't available at creation time
existing.Type = type;
existing.SourceImageBytes = sourceImageBytes;
// Update with latest result data
var oldStatus = existing.Status;
existing.Status = result.Status;
existing.Output = result.Output;
existing.Outputs = result.Outputs;
existing.Error = result.Error;
existing.Metrics = result.Metrics;
existing.StartedAt = result.StartedAt;
existing.CompletedAt = result.CompletedAt;
// Fire status changed if status actually changed
if (oldStatus != result.Status)
{
PredictionStatusChanged?.Invoke(this, new PredictionStatusChangedEventArgs(
existing, oldStatus, result.Status));
}
// Fire completed event if now done
if (existing.IsCompleted)
{
PredictionCompleted?.Invoke(this, new PredictionCompletedEventArgs(existing));
}
return existing;
}
// Not yet tracked - create new entry
var tracked = new TrackedPrediction
{
Id = result.Id,
Type = type,
Status = result.Status,
Output = result.Output,
Outputs = result.Outputs,
Error = result.Error,
Metrics = result.Metrics,
CreatedAt = result.CreatedAt ?? DateTimeOffset.Now,
StartedAt = result.StartedAt,
CompletedAt = result.CompletedAt,
SourceImageBytes = sourceImageBytes
};
_predictions[result.Id] = tracked;
TrimHistory();
// Fire initial status event so listeners can track from the start
PredictionStatusChanged?.Invoke(this, new PredictionStatusChangedEventArgs(
tracked, string.Empty, result.Status));
// Fire completed event if already done
if (tracked.IsCompleted)
{
PredictionCompleted?.Invoke(this, new PredictionCompletedEventArgs(tracked));
}
else
{
// Start polling if we have pending predictions
StartPolling();
}
return tracked;
}
/// <summary>
/// Get a tracked prediction by ID.
/// </summary>
public TrackedPrediction? Get(string predictionId)
{
return _predictions.TryGetValue(predictionId, out var prediction) ? prediction : null;
}
/// <summary>
/// Manually refresh a prediction's status.
/// </summary>
public async Task<TrackedPrediction?> RefreshAsync(string predictionId, CancellationToken cancellationToken = default)
{
if (!_predictions.TryGetValue(predictionId, out var tracked))
return null;
try
{
var result = await _transformer.GetPredictionAsync(predictionId, cancellationToken);
UpdateTrackedPrediction(tracked, result);
return tracked;
}
catch
{
return tracked;
}
}
/// <summary>
/// Clear all completed predictions from history.
/// </summary>
public void ClearCompleted()
{
var completedIds = _predictions.Values
.Where(p => p.IsCompleted)
.Select(p => p.Id)
.ToList();
foreach (var id in completedIds)
{
_predictions.TryRemove(id, out _);
}
}
/// <summary>
/// Clear all predictions from history.
/// </summary>
public void ClearAll()
{
_predictions.Clear();
}
private void StartPolling()
{
if (_pollingTask != null && !_pollingTask.IsCompleted)
return;
_pollingTask = Task.Run(async () =>
{
while (!_pollingCts.Token.IsCancellationRequested)
{
await Task.Delay(PollingIntervalMs, _pollingCts.Token);
var pending = PendingPredictions;
if (pending.Count == 0)
break;
foreach (var prediction in pending)
{
if (_pollingCts.Token.IsCancellationRequested)
break;
try
{
var result = await _transformer.GetPredictionAsync(prediction.Id, _pollingCts.Token);
UpdateTrackedPrediction(prediction, result);
}
catch (OperationCanceledException)
{
break;
}
catch
{
// Continue polling other predictions
}
}
}
}, _pollingCts.Token);
}
private void UpdateTrackedPrediction(TrackedPrediction tracked, PredictionResult result)
{
var previousStatus = tracked.Status;
var wasCompleted = tracked.IsCompleted;
tracked.Status = result.Status;
tracked.Output = result.Output;
tracked.Outputs = result.Outputs;
tracked.Error = result.Error;
tracked.Metrics = result.Metrics;
tracked.StartedAt = result.StartedAt;
tracked.CompletedAt = result.CompletedAt;
tracked.LastCheckedAt = DateTimeOffset.Now;
// Raise status changed event
if (previousStatus != result.Status)
{
PredictionStatusChanged?.Invoke(this, new PredictionStatusChangedEventArgs(
tracked, previousStatus, result.Status));
}
// Raise completed event
if (!wasCompleted && tracked.IsCompleted)
{
PredictionCompleted?.Invoke(this, new PredictionCompletedEventArgs(tracked));
}
}
private void TrimHistory()
{
if (_predictions.Count <= MaxHistorySize)
return;
var toRemove = _predictions.Values
.Where(p => p.IsCompleted)
.OrderBy(p => p.CreatedAt)
.Take(_predictions.Count - MaxHistorySize)
.Select(p => p.Id)
.ToList();
foreach (var id in toRemove)
{
_predictions.TryRemove(id, out _);
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Unsubscribe from transformer events
_transformer.PredictionCreated -= OnPredictionCreated;
_pollingCts.Cancel();
_pollingCts.Dispose();
}
}
/// <summary>
/// A tracked prediction with history information.
/// </summary>
public class TrackedPrediction
{
public string Id { get; set; } = string.Empty;
public TransformationType Type { get; set; }
public string Status { get; set; } = string.Empty;
public PredictionStatus StatusEnum => Status.ToPredictionStatus();
public string? Output { get; set; }
public string[]? Outputs { get; set; }
public string? Error { get; set; }
public PredictionMetrics? Metrics { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public DateTimeOffset? LastCheckedAt { get; set; }
public byte[]? SourceImageBytes { get; set; }
public bool IsCompleted => StatusEnum.IsCompleted();
public bool IsSucceeded => StatusEnum == PredictionStatus.Succeeded;
public bool IsFailed => StatusEnum == PredictionStatus.Failed;
public bool IsCanceled => StatusEnum == PredictionStatus.Canceled;
public bool IsPending => StatusEnum.IsPending();
/// <summary>
/// Time elapsed since creation.
/// </summary>
public TimeSpan Elapsed => (CompletedAt ?? DateTimeOffset.Now) - CreatedAt;
}
public class PredictionStatusChangedEventArgs : EventArgs
{
public TrackedPrediction Prediction { get; }
public string PreviousStatus { get; }
public string NewStatus { get; }
public PredictionStatus PreviousStatusEnum => PreviousStatus.ToPredictionStatus();
public PredictionStatus NewStatusEnum => NewStatus.ToPredictionStatus();
public PredictionStatusChangedEventArgs(TrackedPrediction prediction, string previousStatus, string newStatus)
{
Prediction = prediction;
PreviousStatus = previousStatus;
NewStatus = newStatus;
}
}
public class PredictionCompletedEventArgs : EventArgs
{
public TrackedPrediction Prediction { get; }
public bool Succeeded => Prediction.IsSucceeded;
public bool Failed => Prediction.IsFailed;
public bool Canceled => Prediction.IsCanceled;
public PredictionCompletedEventArgs(TrackedPrediction prediction)
{
Prediction = prediction;
}
}
}

View File

@ -0,0 +1,88 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Include net9.0 for non-MAUI usage (API only) -->
<TargetFrameworks>net9.0;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 Condition="$(TargetFramework.Contains('-'))">true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageOutputPath>C:\Users\logik\Dropbox\Nugets</PackageOutputPath>
<RootNamespace>MarketAlly.Replicate.Maui</RootNamespace>
<AssemblyName>MarketAlly.Replicate.Maui</AssemblyName>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.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>
<!-- NuGet Package Properties -->
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageId>MarketAlly.Replicate.Maui</PackageId>
<Title>MarketAlly Replicate.Maui - AI Image &amp; Video Generation for .NET</Title>
<Version>1.5.0</Version>
<Authors>MarketAlly</Authors>
<Company>MarketAlly</Company>
<Description>A production-ready .NET library for Replicate AI API integration. Generate AI images and videos using models like Stable Diffusion XL, HiDream, Google Veo 3, Kling, Luma Ray, and more. Includes ready-to-use MAUI controls for iOS, Android, Windows, and macOS, or use the API-only package for console apps, ASP.NET Core, and Blazor. Features: model presets, prediction tracking, BYOK support, localization (8 languages), and webhook integration.</Description>
<Copyright>Copyright © MarketAlly 2025</Copyright>
<PackageIcon>icon.png</PackageIcon>
<PackageTags>replicate;ai;image-generation;video-generation;stable-diffusion;maui;dotnet;api;machine-learning;generative-ai;sdxl;kling;veo;luma;hidream</PackageTags>
<PackageProjectUrl>https://github.com/MarketAlly/Replicate.Maui</PackageProjectUrl>
<RepositoryUrl>https://github.com/MarketAlly/Replicate.Maui</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageReleaseNotes>
Version 1.5.0:
- Added net9.0 target for API-only usage (console apps, ASP.NET Core, Blazor)
- Changed namespace to MarketAlly.Replicate.Maui
- Added localization support for 8 languages (en, es, fr, de, zh, ja, pt, it)
- Added overlay button position control (Top/Bottom)
- Added PredictionCreated event for immediate prediction tracking
- Added Google Veo 3 Fast model preset
- Improved prediction history with real-time status updates
</PackageReleaseNotes>
<RequireLicenseAcceptance>false</RequireLicenseAcceptance>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<None Include="icon.png">
<Pack>true</Pack>
<PackagePath>\</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Visible>true</Visible>
</None>
<None Include="..\README.md" Pack="true" PackagePath="\" />
<!-- Core dependencies for all targets -->
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
</ItemGroup>
<!-- MAUI-specific dependencies (only for platform targets) -->
<ItemGroup Condition="$(TargetFramework.Contains('-'))">
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="6.1.3" />
</ItemGroup>
<!-- Exclude MAUI-specific files from net9.0 build -->
<ItemGroup Condition="!$(TargetFramework.Contains('-'))">
<Compile Remove="Controls\**" />
<Compile Remove="ViewModels\**" />
<Compile Remove="Services\**" />
<Compile Remove="ServiceCollectionExtensions.cs" />
<None Remove="Controls\**" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,72 @@
namespace MarketAlly.Replicate.Maui
{
public class ReplicateSettings
{
public string ApiToken { get; set; } = string.Empty;
public string ApiUrl { get; set; } = "https://api.replicate.com/v1/predictions";
/// <summary>
/// Authentication scheme to use. Options: "Bearer" (default per API docs) or "Token".
/// </summary>
public string AuthScheme { get; set; } = "Token";
// Image/Anime transformation settings
public string ModelVersion { get; set; } = string.Empty;
public string ModelName { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 300;
public int PollingDelayMs { get; set; } = 1500;
public string ImagePrompt { get; set; } = "anime style portrait, soft clean lines, studio ghibli or makoto shinkai inspired, friendly expression, elegant and simplified features, cinematic lighting";
// Video generation settings
public string VideoModelName { get; set; } = string.Empty;
public string VideoModelVersion { get; set; } = string.Empty;
public string VideoPrompt { get; set; } = "animate and loop if possible, do not change the text.";
public int VideoTimeoutSeconds { get; set; } = 600;
public int VideoPollingDelayMs { get; set; } = 3000;
public ImageTransformSettings DefaultSettings { get; set; } = new();
public VideoTransformSettings VideoSettings { get; set; } = new();
}
public class ImageTransformSettings
{
public int Seed { get; set; } = 42;
public double GuidanceScale { get; set; } = 9;
public double Strength { get; set; } = 0.8;
public int NumInferenceSteps { get; set; } = 30;
}
public class VideoTransformSettings
{
/// <summary>
/// Random seed for reproducible generation.
/// </summary>
public int? Seed { get; set; }
/// <summary>
/// Duration of the generated video in seconds. Only 5 or 10 are valid.
/// </summary>
public int Duration { get; set; } = 5;
/// <summary>
/// Video resolution and aspect ratio.
/// Valid values: "1280*720", "720*1280", "1920*1080", "1080*1920"
/// </summary>
public string Size { get; set; } = "1280*720";
/// <summary>
/// Audio file URL (wav/mp3, 3-30s, ≤15MB) for voice/music synchronization.
/// </summary>
public string? AudioUrl { get; set; }
/// <summary>
/// Negative prompt to avoid certain elements in the video.
/// </summary>
public string? NegativePrompt { get; set; }
/// <summary>
/// If true, the prompt optimizer will be enabled.
/// </summary>
public bool EnablePromptExpansion { get; set; } = true;
}
}

View File

@ -0,0 +1,522 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
namespace MarketAlly.Replicate.Maui
{
public class ReplicateTransformer : IReplicateTransformer
{
private readonly HttpClient _httpClient;
private readonly ReplicateSettings _settings;
private readonly ILogger<ReplicateTransformer>? _logger;
private readonly JsonSerializerOptions _jsonOptions;
private const string BaseUrl = "https://api.replicate.com/v1";
/// <inheritdoc />
public event EventHandler<PredictionCreatedEventArgs>? PredictionCreated;
public ReplicateTransformer(
HttpClient httpClient,
IOptions<ReplicateSettings> settings,
ILogger<ReplicateTransformer>? logger = null)
{
_httpClient = httpClient;
_settings = settings.Value;
_logger = logger;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false
};
}
public async Task<PredictionResult> TransformToAnimeAsync(
byte[] imageBytes,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var base64Image = Convert.ToBase64String(imageBytes);
return await TransformToAnimeFromBase64Async(base64Image, customPrompt, options, cancellationToken);
}
public async Task<PredictionResult> TransformToAnimeFromBase64Async(
string base64Image,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
if (!base64Image.StartsWith("data:"))
{
base64Image = $"data:image/jpeg;base64,{base64Image}";
}
var prompt = customPrompt ?? _settings.ImagePrompt;
var input = new Dictionary<string, object>
{
{ "image", base64Image },
{ "prompt", prompt },
{ "seed", _settings.DefaultSettings.Seed },
{ "guidance_scale", _settings.DefaultSettings.GuidanceScale },
{ "strength", _settings.DefaultSettings.Strength },
{ "num_inference_steps", _settings.DefaultSettings.NumInferenceSteps }
};
return await CreatePredictionAsync(
_settings.ModelVersion,
input,
options,
_settings.TimeoutSeconds,
_settings.PollingDelayMs,
cancellationToken);
}
public async Task<PredictionResult> TransformToAnimeFromUrlAsync(
string imageUrl,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
// Pass URL directly - Replicate accepts HTTP URLs for inputs
var prompt = customPrompt ?? _settings.ImagePrompt;
var input = new Dictionary<string, object>
{
{ "image", imageUrl },
{ "prompt", prompt },
{ "seed", _settings.DefaultSettings.Seed },
{ "guidance_scale", _settings.DefaultSettings.GuidanceScale },
{ "strength", _settings.DefaultSettings.Strength },
{ "num_inference_steps", _settings.DefaultSettings.NumInferenceSteps }
};
return await CreatePredictionAsync(
_settings.ModelVersion,
input,
options,
_settings.TimeoutSeconds,
_settings.PollingDelayMs,
cancellationToken);
}
public async Task<PredictionResult> TransformToVideoAsync(
byte[] imageBytes,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var base64Image = Convert.ToBase64String(imageBytes);
return await TransformToVideoFromBase64Async(base64Image, customPrompt, options, cancellationToken);
}
public async Task<PredictionResult> TransformToVideoFromBase64Async(
string base64Image,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
if (!base64Image.StartsWith("data:"))
{
base64Image = $"data:image/jpeg;base64,{base64Image}";
}
var prompt = customPrompt ?? _settings.VideoPrompt;
var input = new Dictionary<string, object>
{
{ "prompt", prompt },
{ "image", base64Image },
{ "duration", _settings.VideoSettings.Duration },
{ "size", _settings.VideoSettings.Size },
{ "enable_prompt_expansion", _settings.VideoSettings.EnablePromptExpansion }
};
if (_settings.VideoSettings.Seed.HasValue)
{
input["seed"] = _settings.VideoSettings.Seed.Value;
}
if (!string.IsNullOrEmpty(_settings.VideoSettings.AudioUrl))
{
input["audio"] = _settings.VideoSettings.AudioUrl;
}
if (!string.IsNullOrEmpty(_settings.VideoSettings.NegativePrompt))
{
input["negative_prompt"] = _settings.VideoSettings.NegativePrompt;
}
return await CreatePredictionAsync(
_settings.VideoModelVersion,
input,
options,
_settings.VideoTimeoutSeconds,
_settings.VideoPollingDelayMs,
cancellationToken);
}
public async Task<PredictionResult> TransformToVideoFromUrlAsync(
string imageUrl,
string? customPrompt = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var prompt = customPrompt ?? _settings.VideoPrompt;
var input = new Dictionary<string, object>
{
{ "prompt", prompt },
{ "image", imageUrl },
{ "duration", _settings.VideoSettings.Duration },
{ "size", _settings.VideoSettings.Size },
{ "enable_prompt_expansion", _settings.VideoSettings.EnablePromptExpansion }
};
if (_settings.VideoSettings.Seed.HasValue)
{
input["seed"] = _settings.VideoSettings.Seed.Value;
}
if (!string.IsNullOrEmpty(_settings.VideoSettings.AudioUrl))
{
input["audio"] = _settings.VideoSettings.AudioUrl;
}
if (!string.IsNullOrEmpty(_settings.VideoSettings.NegativePrompt))
{
input["negative_prompt"] = _settings.VideoSettings.NegativePrompt;
}
return await CreatePredictionAsync(
_settings.VideoModelVersion,
input,
options,
_settings.VideoTimeoutSeconds,
_settings.VideoPollingDelayMs,
cancellationToken);
}
public async Task CancelPredictionAsync(string predictionId, CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/predictions/{predictionId}/cancel";
_logger?.LogInformation("Canceling prediction {PredictionId}", predictionId);
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Authorization = new AuthenticationHeaderValue(_settings.AuthScheme, _settings.ApiToken);
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogError("Failed to cancel prediction: {StatusCode} - {Response}", response.StatusCode, responseBody);
throw new ReplicateApiException($"Failed to cancel prediction: {response.StatusCode}", response.StatusCode, responseBody);
}
_logger?.LogInformation("Prediction {PredictionId} canceled", predictionId);
}
public async Task<PredictionResult> GetPredictionAsync(string predictionId, CancellationToken cancellationToken = default)
{
var url = $"{BaseUrl}/predictions/{predictionId}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Authorization = new AuthenticationHeaderValue(_settings.AuthScheme, _settings.ApiToken);
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new ReplicateApiException($"Failed to get prediction: {response.StatusCode}", response.StatusCode, responseBody);
}
// Check for auth errors in response body (API sometimes returns 200 with error)
if (responseBody.Contains("\"status\":401") || responseBody.Contains("Unauthenticated"))
{
throw new ReplicateApiException("Authentication failed - check your API token", System.Net.HttpStatusCode.Unauthorized, responseBody);
}
return ParsePredictionResponse(responseBody);
}
private async Task<PredictionResult> CreatePredictionAsync(
string version,
Dictionary<string, object> input,
PredictionOptions? options,
int timeoutSeconds,
int pollingDelayMs,
CancellationToken cancellationToken)
{
var payload = new Dictionary<string, object>
{
{ "version", version },
{ "input", input }
};
// Add webhook configuration if provided
if (!string.IsNullOrEmpty(options?.WebhookUrl))
{
payload["webhook"] = options.WebhookUrl;
if (options.WebhookEventsFilter?.Length > 0)
{
payload["webhook_events_filter"] = options.WebhookEventsFilter;
}
}
try
{
_logger?.LogInformation("Creating prediction with version {Version}", version);
using var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/predictions");
request.Headers.Authorization = new AuthenticationHeaderValue(_settings.AuthScheme, _settings.ApiToken);
request.Content = new StringContent(JsonSerializer.Serialize(payload, _jsonOptions), Encoding.UTF8, "application/json");
// Add sync mode header if requested
if (options?.SyncModeWaitSeconds.HasValue == true)
{
var waitSeconds = Math.Clamp(options.SyncModeWaitSeconds.Value, 1, 60);
request.Headers.Add("Prefer", $"wait={waitSeconds}");
}
var response = await _httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger?.LogError("Replicate API error: {StatusCode} - {Response}", response.StatusCode, responseBody);
throw new ReplicateApiException($"Replicate API Error: {response.StatusCode}", response.StatusCode, responseBody);
}
var result = ParsePredictionResponse(responseBody);
_logger?.LogInformation("Prediction created with ID: {PredictionId}, Status: {Status}", result.Id, result.Status);
// Raise event immediately so tracking can start before polling
PredictionCreated?.Invoke(this, new PredictionCreatedEventArgs(result));
// If webhook only mode or already completed (sync mode success), return immediately
if (options?.WebhookOnly == true || result.IsCompleted)
{
return result;
}
// Poll for completion
return await PollForCompletionAsync(result.Id, timeoutSeconds, pollingDelayMs, cancellationToken);
}
catch (ReplicateApiException)
{
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error creating prediction");
throw;
}
}
private async Task<PredictionResult> PollForCompletionAsync(
string predictionId,
int timeoutSeconds,
int pollingDelayMs,
CancellationToken cancellationToken)
{
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
var attempts = 0;
while (!linkedCts.Token.IsCancellationRequested)
{
await Task.Delay(pollingDelayMs, linkedCts.Token);
attempts++;
var result = await GetPredictionAsync(predictionId, linkedCts.Token);
_logger?.LogDebug("Status check {Attempt}: {Status}", attempts, result.Status);
if (result.IsCompleted)
{
if (result.Status == "failed")
{
_logger?.LogError("Prediction failed: {Error}", result.Error);
throw new ReplicateTransformationException($"Prediction failed: {result.Error}");
}
if (result.Status == "canceled")
{
_logger?.LogWarning("Prediction was canceled");
throw new ReplicateTransformationException("Prediction was canceled");
}
return result;
}
}
throw new TimeoutException($"Prediction timed out after {timeoutSeconds} seconds");
}
private PredictionResult ParsePredictionResponse(string responseBody)
{
var json = JsonDocument.Parse(responseBody);
var root = json.RootElement;
var result = new PredictionResult
{
Id = root.GetProperty("id").GetString() ?? string.Empty,
Status = root.GetProperty("status").GetString() ?? string.Empty
};
// Parse output
if (root.TryGetProperty("output", out var output) && output.ValueKind != JsonValueKind.Null)
{
if (output.ValueKind == JsonValueKind.Array)
{
var outputs = new List<string>();
foreach (var item in output.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
outputs.Add(item.GetString()!);
}
}
result.Outputs = outputs.ToArray();
result.Output = outputs.FirstOrDefault();
}
else if (output.ValueKind == JsonValueKind.String)
{
result.Output = output.GetString();
result.Outputs = result.Output != null ? new[] { result.Output } : null;
}
}
// Parse error
if (root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.String)
{
result.Error = error.GetString();
}
// Parse metrics
if (root.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Object)
{
result.Metrics = new PredictionMetrics();
if (metrics.TryGetProperty("predict_time", out var predictTime))
{
result.Metrics.PredictTime = predictTime.GetDouble();
}
if (metrics.TryGetProperty("total_time", out var totalTime))
{
result.Metrics.TotalTime = totalTime.GetDouble();
}
}
// Parse timestamps
if (root.TryGetProperty("created_at", out var createdAt) && createdAt.ValueKind == JsonValueKind.String)
{
if (DateTimeOffset.TryParse(createdAt.GetString(), out var dt))
result.CreatedAt = dt;
}
if (root.TryGetProperty("started_at", out var startedAt) && startedAt.ValueKind == JsonValueKind.String)
{
if (DateTimeOffset.TryParse(startedAt.GetString(), out var dt))
result.StartedAt = dt;
}
if (root.TryGetProperty("completed_at", out var completedAt) && completedAt.ValueKind == JsonValueKind.String)
{
if (DateTimeOffset.TryParse(completedAt.GetString(), out var dt))
result.CompletedAt = dt;
}
// Parse cancel URL
if (root.TryGetProperty("urls", out var urls) && urls.ValueKind == JsonValueKind.Object)
{
if (urls.TryGetProperty("cancel", out var cancelUrl) && cancelUrl.ValueKind == JsonValueKind.String)
{
result.CancelUrl = cancelUrl.GetString();
}
}
return result;
}
#region Preset-based Methods
public async Task<PredictionResult> RunPresetAsync(
ModelPreset preset,
byte[] imageBytes,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var base64Image = Convert.ToBase64String(imageBytes);
return await RunPresetFromBase64Async(preset, base64Image, customPrompt, customParameters, options, cancellationToken);
}
public async Task<PredictionResult> RunPresetFromBase64Async(
ModelPreset preset,
string base64Image,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
if (!base64Image.StartsWith("data:"))
{
base64Image = $"data:image/jpeg;base64,{base64Image}";
}
var input = preset.BuildInput(customPrompt, base64Image, customParameters);
return await CreatePredictionAsync(
preset.ModelVersion,
input,
options,
preset.TimeoutSeconds,
preset.PollingDelayMs,
cancellationToken);
}
public async Task<PredictionResult> RunPresetFromUrlAsync(
ModelPreset preset,
string imageUrl,
string? customPrompt = null,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var input = preset.BuildInput(customPrompt, imageUrl, customParameters);
return await CreatePredictionAsync(
preset.ModelVersion,
input,
options,
preset.TimeoutSeconds,
preset.PollingDelayMs,
cancellationToken);
}
public async Task<PredictionResult> RunPresetTextOnlyAsync(
ModelPreset preset,
string prompt,
Dictionary<string, object>? customParameters = null,
PredictionOptions? options = null,
CancellationToken cancellationToken = default)
{
var input = preset.BuildInput(prompt, imageData: null, customParameters);
return await CreatePredictionAsync(
preset.ModelVersion,
input,
options,
preset.TimeoutSeconds,
preset.PollingDelayMs,
cancellationToken);
}
#endregion
}
}

View File

@ -0,0 +1,109 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MarketAlly.Replicate.Maui
{
/// <summary>
/// Factory for creating IReplicateTransformer instances with custom API tokens.
/// Useful for BYOK (Bring Your Own Key) scenarios.
/// </summary>
public interface IReplicateTransformerFactory
{
/// <summary>
/// Creates a transformer using the default configured settings.
/// </summary>
IReplicateTransformer Create();
/// <summary>
/// Creates a transformer with a custom API token, using default settings for everything else.
/// </summary>
/// <param name="apiToken">The Replicate API token to use.</param>
IReplicateTransformer CreateWithToken(string apiToken);
/// <summary>
/// Creates a transformer with fully custom settings.
/// </summary>
/// <param name="configure">Action to configure the settings.</param>
IReplicateTransformer CreateWithSettings(Action<ReplicateSettings> configure);
}
public class ReplicateTransformerFactory : IReplicateTransformerFactory
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ReplicateSettings _defaultSettings;
private readonly ILoggerFactory? _loggerFactory;
public ReplicateTransformerFactory(
IHttpClientFactory httpClientFactory,
IOptions<ReplicateSettings> defaultSettings,
ILoggerFactory? loggerFactory = null)
{
_httpClientFactory = httpClientFactory;
_defaultSettings = defaultSettings.Value;
_loggerFactory = loggerFactory;
}
public IReplicateTransformer Create()
{
var httpClient = _httpClientFactory.CreateClient();
var logger = _loggerFactory?.CreateLogger<ReplicateTransformer>();
return new ReplicateTransformer(httpClient, Options.Create(_defaultSettings), logger);
}
public IReplicateTransformer CreateWithToken(string apiToken)
{
var settings = CloneSettings(_defaultSettings);
settings.ApiToken = apiToken;
var httpClient = _httpClientFactory.CreateClient();
var logger = _loggerFactory?.CreateLogger<ReplicateTransformer>();
return new ReplicateTransformer(httpClient, Options.Create(settings), logger);
}
public IReplicateTransformer CreateWithSettings(Action<ReplicateSettings> configure)
{
var settings = CloneSettings(_defaultSettings);
configure(settings);
var httpClient = _httpClientFactory.CreateClient();
var logger = _loggerFactory?.CreateLogger<ReplicateTransformer>();
return new ReplicateTransformer(httpClient, Options.Create(settings), logger);
}
private static ReplicateSettings CloneSettings(ReplicateSettings source)
{
return new ReplicateSettings
{
ApiToken = source.ApiToken,
ApiUrl = source.ApiUrl,
AuthScheme = source.AuthScheme,
ModelVersion = source.ModelVersion,
ModelName = source.ModelName,
TimeoutSeconds = source.TimeoutSeconds,
PollingDelayMs = source.PollingDelayMs,
ImagePrompt = source.ImagePrompt,
VideoModelName = source.VideoModelName,
VideoModelVersion = source.VideoModelVersion,
VideoPrompt = source.VideoPrompt,
VideoTimeoutSeconds = source.VideoTimeoutSeconds,
VideoPollingDelayMs = source.VideoPollingDelayMs,
DefaultSettings = new ImageTransformSettings
{
Seed = source.DefaultSettings.Seed,
GuidanceScale = source.DefaultSettings.GuidanceScale,
Strength = source.DefaultSettings.Strength,
NumInferenceSteps = source.DefaultSettings.NumInferenceSteps
},
VideoSettings = new VideoTransformSettings
{
Seed = source.VideoSettings.Seed,
Duration = source.VideoSettings.Duration,
Size = source.VideoSettings.Size,
AudioUrl = source.VideoSettings.AudioUrl,
NegativePrompt = source.VideoSettings.NegativePrompt,
EnablePromptExpansion = source.VideoSettings.EnablePromptExpansion
}
};
}
}
}

View File

@ -0,0 +1,125 @@
using CommunityToolkit.Maui;
using Microsoft.Extensions.DependencyInjection;
using MarketAlly.Replicate.Maui.Controls;
using MarketAlly.Replicate.Maui.Services;
using MarketAlly.Replicate.Maui.ViewModels;
namespace MarketAlly.Replicate.Maui
{
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Replicate transformer services to the service collection.
/// Registers both IReplicateTransformer (for default usage) and IReplicateTransformerFactory (for BYOK scenarios).
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configure">Action to configure ReplicateSettings.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReplicateTransformer(
this IServiceCollection services,
Action<ReplicateSettings> configure)
{
services.Configure(configure);
services.AddHttpClient<IReplicateTransformer, ReplicateTransformer>();
services.AddHttpClient<IFileSaveService, FileSaveService>();
services.AddHttpClient(); // For factory usage
services.AddSingleton<IReplicateTransformerFactory, ReplicateTransformerFactory>();
services.AddTransient<TransformerViewModel>();
return services;
}
/// <summary>
/// Adds Replicate transformer services to the service collection using configuration section.
/// Registers both IReplicateTransformer (for default usage) and IReplicateTransformerFactory (for BYOK scenarios).
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="settings">The pre-configured settings instance.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddReplicateTransformer(
this IServiceCollection services,
ReplicateSettings settings)
{
services.Configure<ReplicateSettings>(opt =>
{
opt.ApiToken = settings.ApiToken;
opt.ApiUrl = settings.ApiUrl;
opt.ModelVersion = settings.ModelVersion;
opt.ModelName = settings.ModelName;
opt.TimeoutSeconds = settings.TimeoutSeconds;
opt.PollingDelayMs = settings.PollingDelayMs;
opt.ImagePrompt = settings.ImagePrompt;
opt.VideoModelName = settings.VideoModelName;
opt.VideoModelVersion = settings.VideoModelVersion;
opt.VideoPrompt = settings.VideoPrompt;
opt.VideoTimeoutSeconds = settings.VideoTimeoutSeconds;
opt.VideoPollingDelayMs = settings.VideoPollingDelayMs;
opt.DefaultSettings = settings.DefaultSettings;
opt.VideoSettings = settings.VideoSettings;
});
services.AddHttpClient<IReplicateTransformer, ReplicateTransformer>();
services.AddHttpClient<IFileSaveService, FileSaveService>();
services.AddHttpClient(); // For factory usage
services.AddSingleton<IReplicateTransformerFactory, ReplicateTransformerFactory>();
services.AddTransient<TransformerViewModel>();
return services;
}
}
public static class MauiAppBuilderExtensions
{
/// <summary>
/// Configures MAUI to use Replicate.Maui controls including MediaElement for video playback.
/// Call this in your MauiProgram.cs CreateMauiApp() method.
/// </summary>
/// <example>
/// var builder = MauiApp.CreateBuilder();
/// builder
/// .UseMauiApp&lt;App&gt;()
/// .UseReplicateMaui()
/// .ConfigureFonts(...);
/// </example>
public static MauiAppBuilder UseReplicateMaui(this MauiAppBuilder builder)
{
builder.UseMauiCommunityToolkitMediaElement();
return builder;
}
/// <summary>
/// Configures MAUI to use Replicate.Maui controls and registers transformer services.
/// Call this in your MauiProgram.cs CreateMauiApp() method.
/// </summary>
/// <param name="builder">The MauiAppBuilder.</param>
/// <param name="configure">Action to configure ReplicateSettings.</param>
/// <example>
/// var builder = MauiApp.CreateBuilder();
/// builder
/// .UseMauiApp&lt;App&gt;()
/// .UseReplicateMaui(settings => settings.ApiToken = "your-token")
/// .ConfigureFonts(...);
/// </example>
public static MauiAppBuilder UseReplicateMaui(
this MauiAppBuilder builder,
Action<ReplicateSettings> configure)
{
builder.UseMauiCommunityToolkitMediaElement();
builder.Services.AddReplicateTransformer(configure);
return builder;
}
/// <summary>
/// Configures MAUI to use Replicate.Maui controls and registers transformer services.
/// Call this in your MauiProgram.cs CreateMauiApp() method.
/// </summary>
/// <param name="builder">The MauiAppBuilder.</param>
/// <param name="settings">The pre-configured settings instance.</param>
public static MauiAppBuilder UseReplicateMaui(
this MauiAppBuilder builder,
ReplicateSettings settings)
{
builder.UseMauiCommunityToolkitMediaElement();
builder.Services.AddReplicateTransformer(settings);
return builder;
}
}
}

View File

@ -0,0 +1,154 @@
namespace MarketAlly.Replicate.Maui.Services
{
/// <summary>
/// Default implementation of IFileSaveService.
/// </summary>
public class FileSaveService : IFileSaveService
{
private readonly HttpClient _httpClient;
public FileSaveService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<FileSaveResult> SaveFromUrlAsync(
string url,
TransformationType type,
string? savePath,
string filenamePattern,
string? predictionId = null,
CancellationToken cancellationToken = default)
{
try
{
// Determine save path
var basePath = savePath ?? FileSystem.CacheDirectory;
// Ensure directory exists
if (!Directory.Exists(basePath))
{
Directory.CreateDirectory(basePath);
}
// Download the file
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
// Determine file extension from content type or URL
var extension = GetFileExtension(response.Content.Headers.ContentType?.MediaType, url, type);
// Generate filename from pattern
var filename = GenerateFilename(filenamePattern, predictionId, extension);
// Full path
var filePath = Path.Combine(basePath, filename);
// Save the file
var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
await File.WriteAllBytesAsync(filePath, bytes, cancellationToken);
return FileSaveResult.Succeeded(filePath, bytes.Length);
}
catch (Exception ex)
{
return FileSaveResult.Failed(ex);
}
}
public async Task<FileSaveResult> SaveBytesAsync(
byte[] bytes,
TransformationType type,
string? savePath,
string filenamePattern,
string extension,
string? predictionId = null)
{
try
{
// Determine save path
var basePath = savePath ?? FileSystem.CacheDirectory;
// Ensure directory exists
if (!Directory.Exists(basePath))
{
Directory.CreateDirectory(basePath);
}
// Generate filename from pattern
var filename = GenerateFilename(filenamePattern, predictionId, extension);
// Full path
var filePath = Path.Combine(basePath, filename);
// Save the file
await File.WriteAllBytesAsync(filePath, bytes);
return FileSaveResult.Succeeded(filePath, bytes.Length);
}
catch (Exception ex)
{
return FileSaveResult.Failed(ex);
}
}
public string GenerateFilename(string pattern, string? predictionId, string extension)
{
var now = DateTimeOffset.Now;
var result = pattern
.Replace("{timestamp}", now.ToUnixTimeSeconds().ToString())
.Replace("{datetime}", now.ToString("yyyyMMdd_HHmmss"))
.Replace("{id}", predictionId ?? Guid.NewGuid().ToString("N")[..8])
.Replace("{guid}", Guid.NewGuid().ToString("N"));
// Sanitize filename
foreach (var c in Path.GetInvalidFileNameChars())
{
result = result.Replace(c, '_');
}
// Add extension if not already present
if (!result.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
{
result += extension;
}
return result;
}
private static string GetFileExtension(string? contentType, string url, TransformationType type)
{
// Try to get from content type
if (!string.IsNullOrEmpty(contentType))
{
var ext = contentType switch
{
"image/png" => ".png",
"image/jpeg" or "image/jpg" => ".jpg",
"image/webp" => ".webp",
"image/gif" => ".gif",
"video/mp4" => ".mp4",
"video/webm" => ".webm",
"video/quicktime" => ".mov",
_ => null
};
if (ext != null) return ext;
}
// Try to extract from URL
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath;
var ext = Path.GetExtension(path);
if (!string.IsNullOrEmpty(ext))
return ext;
}
catch { }
// Default extensions
return type == TransformationType.Image ? ".png" : ".mp4";
}
}
}

View File

@ -0,0 +1,98 @@
namespace MarketAlly.Replicate.Maui.Services
{
/// <summary>
/// Service for saving transformation results to local storage.
/// </summary>
public interface IFileSaveService
{
/// <summary>
/// Save a file from a URL to local storage.
/// </summary>
/// <param name="url">The URL to download from.</param>
/// <param name="type">The type of content (Image or Video).</param>
/// <param name="savePath">The folder path to save to. If null, uses cache directory.</param>
/// <param name="filenamePattern">The filename pattern with placeholders.</param>
/// <param name="predictionId">Optional prediction ID for filename pattern.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The full path to the saved file, or null if save failed.</returns>
Task<FileSaveResult> SaveFromUrlAsync(
string url,
TransformationType type,
string? savePath,
string filenamePattern,
string? predictionId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Save bytes directly to local storage.
/// </summary>
/// <param name="bytes">The bytes to save.</param>
/// <param name="type">The type of content (Image or Video).</param>
/// <param name="savePath">The folder path to save to. If null, uses cache directory.</param>
/// <param name="filenamePattern">The filename pattern with placeholders.</param>
/// <param name="extension">The file extension (e.g., ".png", ".mp4").</param>
/// <param name="predictionId">Optional prediction ID for filename pattern.</param>
/// <returns>The full path to the saved file, or null if save failed.</returns>
Task<FileSaveResult> SaveBytesAsync(
byte[] bytes,
TransformationType type,
string? savePath,
string filenamePattern,
string extension,
string? predictionId = null);
/// <summary>
/// Generate a filename from a pattern.
/// </summary>
/// <param name="pattern">The filename pattern with placeholders.</param>
/// <param name="predictionId">Optional prediction ID.</param>
/// <param name="extension">The file extension.</param>
/// <returns>The generated filename.</returns>
string GenerateFilename(string pattern, string? predictionId, string extension);
}
/// <summary>
/// Result of a file save operation.
/// </summary>
public class FileSaveResult
{
/// <summary>
/// Whether the save operation succeeded.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// The full path to the saved file, if successful.
/// </summary>
public string? FilePath { get; init; }
/// <summary>
/// The size of the saved file in bytes, if successful.
/// </summary>
public long FileSizeBytes { get; init; }
/// <summary>
/// The error that occurred, if the save failed.
/// </summary>
public Exception? Error { get; init; }
/// <summary>
/// Create a successful result.
/// </summary>
public static FileSaveResult Succeeded(string filePath, long fileSizeBytes) => new()
{
Success = true,
FilePath = filePath,
FileSizeBytes = fileSizeBytes
};
/// <summary>
/// Create a failed result.
/// </summary>
public static FileSaveResult Failed(Exception error) => new()
{
Success = false,
Error = error
};
}
}

View File

@ -0,0 +1,81 @@
namespace MarketAlly.Replicate.Maui
{
/// <summary>
/// Represents the current state of the ReplicateTransformerView control.
/// </summary>
public enum TransformerState
{
/// <summary>
/// No image selected - showing placeholder.
/// </summary>
Empty,
/// <summary>
/// Source image has been selected and is displayed.
/// </summary>
ImageSelected,
/// <summary>
/// A transformation is currently in progress.
/// </summary>
Processing,
/// <summary>
/// An image transformation has completed successfully.
/// </summary>
ImageResult,
/// <summary>
/// A video transformation has completed successfully.
/// </summary>
VideoResult,
/// <summary>
/// The video player is actively playing the generated video.
/// </summary>
PlayingVideo,
/// <summary>
/// An error occurred during transformation.
/// </summary>
Error
}
/// <summary>
/// Extension methods for TransformerState.
/// </summary>
public static class TransformerStateExtensions
{
/// <summary>
/// Whether the state indicates a result is available (image or video).
/// </summary>
public static bool HasResult(this TransformerState state)
{
return state is TransformerState.ImageResult or TransformerState.VideoResult or TransformerState.PlayingVideo;
}
/// <summary>
/// Whether the state indicates the control is busy.
/// </summary>
public static bool IsBusy(this TransformerState state)
{
return state == TransformerState.Processing;
}
/// <summary>
/// Whether the state allows starting a new transformation.
/// </summary>
public static bool CanTransform(this TransformerState state)
{
return state is TransformerState.ImageSelected or TransformerState.ImageResult or TransformerState.VideoResult;
}
/// <summary>
/// Whether the state allows selecting a new image.
/// </summary>
public static bool CanSelectImage(this TransformerState state)
{
return state != TransformerState.Processing;
}
}
}

View File

@ -0,0 +1,731 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using MarketAlly.Replicate.Maui;
using MarketAlly.Replicate.Maui.Services;
namespace MarketAlly.Replicate.Maui.ViewModels;
/// <summary>
/// ViewModel for the ReplicateTransformerView control.
/// Provides MVVM support with bindable properties and commands.
/// </summary>
public class TransformerViewModel : INotifyPropertyChanged, IDisposable
{
private readonly IReplicateTransformer _transformer;
private readonly IFileSaveService? _fileSaveService;
private readonly IPredictionTracker? _tracker;
private byte[]? _sourceImageBytes;
private ImageSource? _sourceImageSource;
private string? _resultUrl;
private ImageSource? _resultImageSource;
private string? _videoUrl;
private TransformerState _currentState = TransformerState.Empty;
private bool _isProcessing;
private string? _errorMessage;
private string _statusMessage = string.Empty;
private ModelPreset? _imagePreset;
private ModelPreset? _videoPreset;
private Dictionary<string, object>? _customImageParameters;
private Dictionary<string, object>? _customVideoParameters;
private string? _customImagePrompt;
private string? _customVideoPrompt;
private CancellationTokenSource? _currentCts;
private string? _currentPredictionId;
private bool _disposed;
private string? _lastSavedImagePath;
private string? _lastSavedVideoPath;
#region Events
public event PropertyChangedEventHandler? PropertyChanged;
public event EventHandler<TransformationStartedEventArgs>? TransformationStarted;
public event EventHandler<TransformationCompletedEventArgs>? TransformationCompleted;
public event EventHandler<TransformationErrorEventArgs>? TransformationError;
public event EventHandler<ImageSelectedEventArgs>? ImageSelected;
public event EventHandler<FileSavedEventArgs>? FileSaved;
public event EventHandler<FileSaveErrorEventArgs>? FileSaveError;
public event EventHandler<StateChangedEventArgs>? StateChanged;
#endregion
#region Properties
/// <summary>
/// The current state of the transformer.
/// </summary>
public TransformerState CurrentState
{
get => _currentState;
private set
{
if (_currentState != value)
{
var previous = _currentState;
_currentState = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CanTransform));
OnPropertyChanged(nameof(CanSelectImage));
OnPropertyChanged(nameof(HasResult));
StateChanged?.Invoke(this, new StateChangedEventArgs(previous, value));
}
}
}
/// <summary>
/// Whether the transformer is currently processing.
/// </summary>
public bool IsProcessing
{
get => _isProcessing;
private set
{
if (_isProcessing != value)
{
_isProcessing = value;
OnPropertyChanged();
OnPropertyChanged(nameof(CanTransform));
OnPropertyChanged(nameof(CanSelectImage));
}
}
}
/// <summary>
/// The source image bytes.
/// </summary>
public byte[]? SourceImageBytes
{
get => _sourceImageBytes;
private set
{
_sourceImageBytes = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSourceImage));
}
}
/// <summary>
/// The source image as an ImageSource.
/// </summary>
public ImageSource? SourceImageSource
{
get => _sourceImageSource;
private set
{
_sourceImageSource = value;
OnPropertyChanged();
}
}
/// <summary>
/// The result URL from the transformation.
/// </summary>
public string? ResultUrl
{
get => _resultUrl;
private set
{
_resultUrl = value;
OnPropertyChanged();
}
}
/// <summary>
/// The result image as an ImageSource.
/// </summary>
public ImageSource? ResultImageSource
{
get => _resultImageSource;
private set
{
_resultImageSource = value;
OnPropertyChanged();
}
}
/// <summary>
/// The video URL from video transformations.
/// </summary>
public string? VideoUrl
{
get => _videoUrl;
private set
{
_videoUrl = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasVideo));
}
}
/// <summary>
/// The current error message, if any.
/// </summary>
public string? ErrorMessage
{
get => _errorMessage;
private set
{
_errorMessage = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasError));
}
}
/// <summary>
/// The current status message for display.
/// </summary>
public string StatusMessage
{
get => _statusMessage;
private set
{
_statusMessage = value;
OnPropertyChanged();
}
}
/// <summary>
/// Custom prompt for image transformations.
/// </summary>
public string? CustomImagePrompt
{
get => _customImagePrompt;
set
{
_customImagePrompt = value;
OnPropertyChanged();
}
}
/// <summary>
/// Custom prompt for video transformations.
/// </summary>
public string? CustomVideoPrompt
{
get => _customVideoPrompt;
set
{
_customVideoPrompt = value;
OnPropertyChanged();
}
}
/// <summary>
/// Path to the last saved image file.
/// </summary>
public string? LastSavedImagePath
{
get => _lastSavedImagePath;
private set
{
_lastSavedImagePath = value;
OnPropertyChanged();
}
}
/// <summary>
/// Path to the last saved video file.
/// </summary>
public string? LastSavedVideoPath
{
get => _lastSavedVideoPath;
private set
{
_lastSavedVideoPath = value;
OnPropertyChanged();
}
}
/// <summary>
/// The prediction tracker for accessing history.
/// </summary>
public IPredictionTracker? Tracker => _tracker;
/// <summary>
/// Whether a source image has been selected.
/// </summary>
public bool HasSourceImage => _sourceImageBytes != null;
/// <summary>
/// Whether a result is available.
/// </summary>
public bool HasResult => CurrentState.HasResult();
/// <summary>
/// Whether a video result is available.
/// </summary>
public bool HasVideo => !string.IsNullOrEmpty(_videoUrl);
/// <summary>
/// Whether an error has occurred.
/// </summary>
public bool HasError => !string.IsNullOrEmpty(_errorMessage);
/// <summary>
/// Whether a transformation can be started.
/// </summary>
public bool CanTransform => CurrentState.CanTransform() && !IsProcessing;
/// <summary>
/// Whether an image can be selected.
/// </summary>
public bool CanSelectImage => CurrentState.CanSelectImage();
#endregion
#region Commands
/// <summary>
/// Command to perform image transformation.
/// </summary>
public ICommand TransformImageCommand { get; }
/// <summary>
/// Command to perform video transformation.
/// </summary>
public ICommand TransformVideoCommand { get; }
/// <summary>
/// Command to cancel current transformation.
/// </summary>
public ICommand CancelCommand { get; }
/// <summary>
/// Command to reset the view model.
/// </summary>
public ICommand ResetCommand { get; }
/// <summary>
/// Command to clear the result.
/// </summary>
public ICommand ClearResultCommand { get; }
/// <summary>
/// Command to dismiss error.
/// </summary>
public ICommand DismissErrorCommand { get; }
#endregion
#region Constructors
/// <summary>
/// Create a new TransformerViewModel with the specified transformer.
/// </summary>
/// <param name="transformer">The transformer service to use.</param>
/// <param name="fileSaveService">Optional file save service for auto-save functionality.</param>
/// <param name="enableTracking">Whether to enable prediction tracking.</param>
public TransformerViewModel(
IReplicateTransformer transformer,
IFileSaveService? fileSaveService = null,
bool enableTracking = true)
{
_transformer = transformer ?? throw new ArgumentNullException(nameof(transformer));
_fileSaveService = fileSaveService;
if (enableTracking)
{
_tracker = new PredictionTracker(transformer);
}
// Initialize commands
TransformImageCommand = new RelayCommand(
async () => await TransformAsync(TransformationType.Image),
() => CanTransform);
TransformVideoCommand = new RelayCommand(
async () => await TransformAsync(TransformationType.Video),
() => CanTransform);
CancelCommand = new RelayCommand(
async () => await CancelAsync(),
() => IsProcessing);
ResetCommand = new RelayCommand(Reset);
ClearResultCommand = new RelayCommand(ClearResult);
DismissErrorCommand = new RelayCommand(DismissError);
}
#endregion
#region Public Methods
/// <summary>
/// Set the source image from bytes.
/// </summary>
public async Task SetImageAsync(byte[] imageBytes)
{
SourceImageBytes = imageBytes;
SourceImageSource = ImageSource.FromStream(() => new MemoryStream(imageBytes));
ClearResult();
CurrentState = TransformerState.ImageSelected;
ImageSelected?.Invoke(this, new ImageSelectedEventArgs(imageBytes));
await Task.CompletedTask;
}
/// <summary>
/// Set the source image from a stream.
/// </summary>
public async Task SetImageAsync(Stream stream)
{
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
await SetImageAsync(ms.ToArray());
}
/// <summary>
/// Set the source image from a file path.
/// </summary>
public async Task SetImageAsync(string filePath)
{
var bytes = await File.ReadAllBytesAsync(filePath);
await SetImageAsync(bytes);
}
/// <summary>
/// Perform a transformation.
/// </summary>
/// <param name="type">The type of transformation.</param>
public async Task TransformAsync(TransformationType type)
{
if (_sourceImageBytes == null)
{
ErrorMessage = "No image selected.";
CurrentState = TransformerState.Error;
return;
}
IsProcessing = true;
CurrentState = TransformerState.Processing;
StatusMessage = type == TransformationType.Image
? "Transforming image..."
: "Generating video...";
ErrorMessage = null;
_currentCts = new CancellationTokenSource();
TransformationStarted?.Invoke(this, new TransformationStartedEventArgs(type));
try
{
PredictionResult result;
if (type == TransformationType.Image)
{
if (_imagePreset != null)
{
result = await _transformer.RunPresetAsync(
_imagePreset,
_sourceImageBytes,
CustomImagePrompt,
_customImageParameters,
cancellationToken: _currentCts.Token);
}
else
{
result = await _transformer.TransformToAnimeAsync(
_sourceImageBytes,
CustomImagePrompt,
cancellationToken: _currentCts.Token);
}
}
else
{
if (_videoPreset != null)
{
result = await _transformer.RunPresetAsync(
_videoPreset,
_sourceImageBytes,
CustomVideoPrompt,
_customVideoParameters,
cancellationToken: _currentCts.Token);
}
else
{
result = await _transformer.TransformToVideoAsync(
_sourceImageBytes,
CustomVideoPrompt,
cancellationToken: _currentCts.Token);
}
}
_currentPredictionId = result.Id;
ResultUrl = result.Output;
// Track prediction
_tracker?.Track(result, type, _sourceImageBytes);
// Update state based on result type
if (type == TransformationType.Image && result.Output != null)
{
VideoUrl = null;
ResultImageSource = ImageSource.FromUri(new Uri(result.Output));
CurrentState = TransformerState.ImageResult;
}
else if (type == TransformationType.Video && result.Output != null)
{
VideoUrl = result.Output;
ResultImageSource = SourceImageSource; // Use source as thumbnail
CurrentState = TransformerState.VideoResult;
}
StatusMessage = type == TransformationType.Image
? "Transformation complete"
: "Video generation complete";
TransformationCompleted?.Invoke(this, new TransformationCompletedEventArgs(type, result));
}
catch (OperationCanceledException)
{
StatusMessage = "Transformation cancelled";
CurrentState = HasSourceImage ? TransformerState.ImageSelected : TransformerState.Empty;
}
catch (Exception ex)
{
ErrorMessage = ex.Message;
CurrentState = TransformerState.Error;
TransformationError?.Invoke(this, new TransformationErrorEventArgs(type, ex));
}
finally
{
IsProcessing = false;
_currentCts?.Dispose();
_currentCts = null;
}
}
/// <summary>
/// Cancel the current transformation.
/// </summary>
public async Task CancelAsync()
{
_currentCts?.Cancel();
if (_currentPredictionId != null)
{
try
{
await _transformer.CancelPredictionAsync(_currentPredictionId);
}
catch
{
// Ignore cancellation errors
}
}
}
/// <summary>
/// Reset the view model to initial state.
/// </summary>
public void Reset()
{
SourceImageBytes = null;
SourceImageSource = null;
ClearResult();
ErrorMessage = null;
StatusMessage = string.Empty;
CurrentState = TransformerState.Empty;
}
/// <summary>
/// Clear only the result, keeping the source image.
/// </summary>
public void ClearResult()
{
ResultUrl = null;
ResultImageSource = null;
VideoUrl = null;
if (HasSourceImage)
{
CurrentState = TransformerState.ImageSelected;
}
}
/// <summary>
/// Dismiss the current error.
/// </summary>
public void DismissError()
{
ErrorMessage = null;
CurrentState = HasSourceImage ? TransformerState.ImageSelected : TransformerState.Empty;
}
/// <summary>
/// Set an image preset for transformations.
/// </summary>
public void SetImagePreset(ModelPreset preset, Dictionary<string, object>? customParameters = null)
{
if (preset.Type != ModelType.Image)
throw new ArgumentException($"Preset '{preset.Name}' is not an image model.", nameof(preset));
_imagePreset = preset;
_customImageParameters = customParameters;
}
/// <summary>
/// Set a video preset for transformations.
/// </summary>
public void SetVideoPreset(ModelPreset preset, Dictionary<string, object>? customParameters = null)
{
if (preset.Type != ModelType.Video)
throw new ArgumentException($"Preset '{preset.Name}' is not a video model.", nameof(preset));
_videoPreset = preset;
_customVideoParameters = customParameters;
}
/// <summary>
/// Clear presets and use default settings.
/// </summary>
public void ClearPresets()
{
_imagePreset = null;
_videoPreset = null;
_customImageParameters = null;
_customVideoParameters = null;
}
/// <summary>
/// Save the current result to a file.
/// </summary>
/// <param name="type">The type of result to save.</param>
/// <param name="savePath">The folder path to save to. If null, uses cache directory.</param>
/// <param name="filenamePattern">The filename pattern to use.</param>
/// <returns>The path to the saved file, or null if save failed.</returns>
public async Task<string?> SaveResultAsync(
TransformationType type,
string? savePath = null,
string filenamePattern = "{type}_{timestamp}")
{
var url = type == TransformationType.Image ? ResultUrl : VideoUrl;
if (string.IsNullOrEmpty(url))
return null;
if (_fileSaveService == null)
return null;
var result = await _fileSaveService.SaveFromUrlAsync(
url,
type,
savePath,
filenamePattern,
_currentPredictionId);
if (result.Success)
{
if (type == TransformationType.Image)
LastSavedImagePath = result.FilePath;
else
LastSavedVideoPath = result.FilePath;
FileSaved?.Invoke(this, new FileSavedEventArgs(result.FilePath!, type, result.FileSizeBytes));
return result.FilePath;
}
else
{
FileSaveError?.Invoke(this, new FileSaveErrorEventArgs(type, result.Error!));
return null;
}
}
#endregion
#region INotifyPropertyChanged
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#endregion
#region IDisposable
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
_currentCts?.Cancel();
_currentCts?.Dispose();
_tracker?.Dispose();
}
_disposed = true;
}
#endregion
}
/// <summary>
/// Simple ICommand implementation for MVVM.
/// </summary>
public class RelayCommand : ICommand
{
private readonly Action _execute;
private readonly Func<bool>? _canExecute;
public event EventHandler? CanExecuteChanged;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true;
public void Execute(object? parameter) => _execute();
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Async ICommand implementation for MVVM.
/// </summary>
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
public event EventHandler? CanExecuteChanged;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? canExecute = null)
{
_execute = execute ?? throw new ArgumentNullException(nameof(execute));
_canExecute = canExecute;
}
public bool CanExecute(object? parameter) => !_isExecuting && (_canExecute?.Invoke() ?? true);
public async void Execute(object? parameter)
{
if (_isExecuting) return;
_isExecuting = true;
RaiseCanExecuteChanged();
try
{
await _execute();
}
finally
{
_isExecuting = false;
RaiseCanExecuteChanged();
}
}
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

BIN
Replicate.Maui/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

14
Test.Replicate/App.xaml Normal file
View File

@ -0,0 +1,14 @@
<?xml version = "1.0" encoding = "UTF-8" ?>
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:local="clr-namespace:Test.Replicate"
x:Class="Test.Replicate.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="Resources/Styles/Colors.xaml" />
<ResourceDictionary Source="Resources/Styles/Styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -0,0 +1,29 @@
using Test.Replicate.Services;
namespace Test.Replicate
{
public partial class App : Application
{
public App(IServiceProvider serviceProvider)
{
InitializeComponent();
ServiceProvider = serviceProvider;
// Load saved settings from secure storage
_ = LoadSettingsAsync();
}
public static IServiceProvider ServiceProvider { get; private set; } = null!;
protected override Window CreateWindow(IActivationState? activationState)
{
return new Window(new AppShell());
}
private async Task LoadSettingsAsync()
{
var appSettings = ServiceProvider.GetRequiredService<AppSettings>();
await appSettings.LoadAsync();
}
}
}

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8" ?>
<Shell
x:Class="Test.Replicate.AppShell"
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:pages="clr-namespace:Test.Replicate.Pages"
Title="Replicate Transformer">
<TabBar>
<ShellContent
Title="Image"
Icon="{OnPlatform Default=''}"
ContentTemplate="{DataTemplate pages:ImageTransformPage}"
Route="ImageTransform" />
<ShellContent
Title="Video"
Icon="{OnPlatform Default=''}"
ContentTemplate="{DataTemplate pages:VideoGenerationPage}"
Route="VideoGeneration" />
<ShellContent
Title="History"
Icon="{OnPlatform Default=''}"
ContentTemplate="{DataTemplate pages:HistoryPage}"
Route="History" />
<ShellContent
Title="Settings"
Icon="{OnPlatform Default=''}"
ContentTemplate="{DataTemplate pages:SettingsPage}"
Route="Settings" />
</TabBar>
</Shell>

View File

@ -0,0 +1,10 @@
namespace Test.Replicate
{
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.Extensions.Logging;
using MarketAlly.Replicate.Maui;
using Test.Replicate.Services;
namespace Test.Replicate
{
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
})
.UseReplicateMaui(settings =>
{
// Default settings - will be overridden by AppSettings at runtime
settings.ModelName = "prunaai/hidream-e1.1";
settings.ModelVersion = "433436facdc1172b6efcb801eb6f345d7858a32200d24e5febaccfb4b44ad66f";
settings.VideoModelName = "bytedance/seedance-1-pro";
settings.VideoModelVersion = "5fe042776269a7262e69b14f0b835b88b8e5eff9f990cadf31b8f984ed0419ad";
settings.TimeoutSeconds = 300;
settings.VideoTimeoutSeconds = 600;
});
// Register shared services
builder.Services.AddSingleton<AppSettings>();
builder.Services.AddSingleton<HistoryService>();
// Register App for DI
builder.Services.AddSingleton<App>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
}

View File

@ -0,0 +1,115 @@
<?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"
x:Class="Test.Replicate.Pages.HistoryPage"
Title="History">
<Grid RowDefinitions="Auto,*,Auto" Padding="16" RowSpacing="12">
<!-- Header -->
<VerticalStackLayout Grid.Row="0" Spacing="4">
<Label Text="Prediction History"
FontSize="22"
FontAttributes="Bold"
HorizontalOptions="Center"/>
<Label x:Name="SummaryLabel"
Text="No predictions yet"
FontSize="13"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- History List -->
<CollectionView x:Name="HistoryList"
Grid.Row="1"
SelectionMode="None">
<CollectionView.EmptyView>
<VerticalStackLayout VerticalOptions="Center" HorizontalOptions="Center" Spacing="8">
<Label Text="No predictions in history"
FontSize="16"
TextColor="{AppThemeBinding Light=#888888, Dark=#666666}"
HorizontalOptions="Center"/>
<Label Text="Transform images or generate videos to see them here"
FontSize="12"
TextColor="{AppThemeBinding Light=#999999, Dark=#555555}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate>
<Frame Margin="0,4" Padding="12" CornerRadius="8"
BackgroundColor="{AppThemeBinding Light=#FFFFFF, Dark=#2A2A2A}"
BorderColor="{AppThemeBinding Light=#E0E0E0, Dark=#404040}">
<Grid ColumnDefinitions="Auto,*,Auto" ColumnSpacing="12">
<!-- Status Indicator -->
<BoxView Grid.Column="0"
WidthRequest="8"
HeightRequest="8"
CornerRadius="4"
VerticalOptions="Center"
Color="{Binding StatusColor}"/>
<!-- Prediction Info -->
<VerticalStackLayout Grid.Column="1" Spacing="2">
<Label Text="{Binding TypeDisplay}"
FontSize="14"
FontAttributes="Bold"/>
<Label Text="{Binding Id}"
FontSize="10"
TextColor="{AppThemeBinding Light=#888888, Dark=#777777}"
LineBreakMode="MiddleTruncation"/>
<Label Text="{Binding StatusDisplay}"
FontSize="12"
TextColor="{Binding StatusTextColor}"/>
<Label Text="{Binding ElapsedDisplay}"
FontSize="10"
TextColor="{AppThemeBinding Light=#999999, Dark=#666666}"/>
</VerticalStackLayout>
<!-- Output Preview/Link -->
<VerticalStackLayout Grid.Column="2" VerticalOptions="Center" Spacing="4">
<Label Text="{Binding TimeDisplay}"
FontSize="10"
TextColor="{AppThemeBinding Light=#888888, Dark=#666666}"
HorizontalOptions="End"/>
<Label Text="{Binding OutputStatus}"
FontSize="10"
TextColor="{AppThemeBinding Light=#007AFF, Dark=#0A84FF}"
HorizontalOptions="End"/>
</VerticalStackLayout>
</Grid>
</Frame>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
<!-- Actions -->
<HorizontalStackLayout Grid.Row="2" Spacing="12" HorizontalOptions="Center">
<Button x:Name="RefreshButton"
Text="Refresh"
BackgroundColor="{AppThemeBinding Light=#007AFF, Dark=#0A84FF}"
TextColor="White"
CornerRadius="8"
HeightRequest="40"
WidthRequest="100"
Clicked="OnRefreshClicked"/>
<Button x:Name="ClearCompletedButton"
Text="Clear Completed"
BackgroundColor="{AppThemeBinding Light=#FF9800, Dark=#FF9800}"
TextColor="White"
CornerRadius="8"
HeightRequest="40"
WidthRequest="130"
Clicked="OnClearCompletedClicked"/>
<Button x:Name="ClearAllButton"
Text="Clear All"
BackgroundColor="{AppThemeBinding Light=#F44336, Dark=#EF5350}"
TextColor="White"
CornerRadius="8"
HeightRequest="40"
WidthRequest="100"
Clicked="OnClearAllClicked"/>
</HorizontalStackLayout>
</Grid>
</ContentPage>

View File

@ -0,0 +1,142 @@
using MarketAlly.Replicate.Maui;
using Test.Replicate.Services;
namespace Test.Replicate.Pages
{
public partial class HistoryPage : ContentPage
{
private readonly HistoryService _historyService;
public HistoryPage()
{
InitializeComponent();
_historyService = App.ServiceProvider.GetRequiredService<HistoryService>();
_historyService.HistoryChanged += OnHistoryChanged;
}
protected override void OnAppearing()
{
base.OnAppearing();
RefreshHistory();
}
private void OnHistoryChanged()
{
MainThread.BeginInvokeOnMainThread(RefreshHistory);
}
private void RefreshHistory()
{
var items = _historyService.GetHistory()
.Select(p => new PredictionViewModel(p))
.ToList();
HistoryList.ItemsSource = items;
var pending = items.Count(i => i.IsPending);
var succeeded = items.Count(i => i.IsSucceeded);
var failed = items.Count(i => i.IsFailed);
if (items.Count == 0)
{
SummaryLabel.Text = "No predictions yet";
}
else
{
var parts = new List<string>();
if (pending > 0) parts.Add($"{pending} pending");
if (succeeded > 0) parts.Add($"{succeeded} succeeded");
if (failed > 0) parts.Add($"{failed} failed");
SummaryLabel.Text = $"{items.Count} predictions: {string.Join(", ", parts)}";
}
}
private void OnRefreshClicked(object? sender, EventArgs e)
{
RefreshHistory();
}
private void OnClearCompletedClicked(object? sender, EventArgs e)
{
_historyService.ClearCompleted();
RefreshHistory();
}
private void OnClearAllClicked(object? sender, EventArgs e)
{
_historyService.ClearAll();
RefreshHistory();
}
}
public class PredictionViewModel
{
private readonly TrackedPrediction _prediction;
public PredictionViewModel(TrackedPrediction prediction)
{
_prediction = prediction;
}
public string Id => _prediction.Id;
public string TypeDisplay => _prediction.Type == TransformationType.Image ? "Image Transform" : "Video Generation";
public string StatusDisplay => _prediction.StatusEnum switch
{
PredictionStatus.Starting => "Starting...",
PredictionStatus.Processing => "Processing...",
PredictionStatus.Succeeded => "Completed",
PredictionStatus.Failed => $"Failed: {_prediction.Error ?? "Unknown error"}",
PredictionStatus.Canceled => "Canceled",
_ => _prediction.Status
};
public bool IsPending => _prediction.IsPending;
public bool IsSucceeded => _prediction.IsSucceeded;
public bool IsFailed => _prediction.IsFailed;
public Color StatusColor => _prediction.StatusEnum switch
{
PredictionStatus.Starting => Colors.Orange,
PredictionStatus.Processing => Colors.Blue,
PredictionStatus.Succeeded => Colors.Green,
PredictionStatus.Failed => Colors.Red,
PredictionStatus.Canceled => Colors.Gray,
_ => Colors.Gray
};
public Color StatusTextColor => _prediction.StatusEnum switch
{
PredictionStatus.Starting => Colors.Orange,
PredictionStatus.Processing => Colors.Blue,
PredictionStatus.Succeeded => Colors.Green,
PredictionStatus.Failed => Colors.Red,
PredictionStatus.Canceled => Colors.Gray,
_ => Colors.Gray
};
public string TimeDisplay => _prediction.CreatedAt.ToLocalTime().ToString("HH:mm:ss");
public string ElapsedDisplay
{
get
{
var elapsed = _prediction.Elapsed;
if (elapsed.TotalMinutes >= 1)
return $"Elapsed: {elapsed.Minutes}m {elapsed.Seconds}s";
return $"Elapsed: {elapsed.TotalSeconds:F1}s";
}
}
public string OutputStatus
{
get
{
if (_prediction.IsSucceeded && _prediction.Output != null)
return "Output ready";
if (_prediction.IsPending)
return "In progress...";
return "";
}
}
}
}

View File

@ -0,0 +1,126 @@
<?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:replicate="clr-namespace:MarketAlly.Replicate.Maui.Controls;assembly=MarketAlly.Replicate.Maui"
x:Class="Test.Replicate.Pages.ImageTransformPage"
Title="Image Transform">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Padding="16" RowSpacing="12">
<!-- Header -->
<VerticalStackLayout Grid.Row="0" Spacing="4">
<Label Text="Image Transformation"
FontSize="22"
FontAttributes="Bold"
HorizontalOptions="Center"/>
<Label Text="Transform your photos using AI models"
FontSize="13"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- Model Selection and Prompt -->
<Frame Grid.Row="1"
Padding="12"
BackgroundColor="{AppThemeBinding Light=#E3F2FD, Dark=#1A237E}"
BorderColor="{AppThemeBinding Light=#64B5F6, Dark=#5C6BC0}"
CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<VerticalStackLayout Grid.Column="0" Spacing="4">
<Label Text="Model:" FontSize="12" FontAttributes="Bold"/>
<Picker x:Name="ImageModelPicker"
FontSize="13"
SelectedIndexChanged="OnImageModelChanged"/>
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" Spacing="4">
<Label Text="Speed Mode:" FontSize="12" FontAttributes="Bold"/>
<Picker x:Name="SpeedModePicker" FontSize="13">
<Picker.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>Lightly Juiced 🍊</x:String>
<x:String>Juiced 🔥</x:String>
<x:String>Extra Juiced 🚀</x:String>
</x:Array>
</Picker.ItemsSource>
</Picker>
</VerticalStackLayout>
</Grid>
<VerticalStackLayout Spacing="4">
<Label Text="Prompt (optional override):" FontSize="12" FontAttributes="Bold"/>
<Editor x:Name="PromptEditor"
Placeholder="Leave empty to use model's default prompt..."
HeightRequest="50"
FontSize="12"/>
</VerticalStackLayout>
</VerticalStackLayout>
</Frame>
<!-- Transformer Control (Image only) - Side by Side Layout -->
<replicate:ReplicateTransformerView
x:Name="TransformerView"
Grid.Row="2"
LayoutMode="SideBySide"
ShowVideoOptions="False"
TrackPredictions="True"
ShowSideBySideSourceButtons="True"
ShowSideBySideResultButtons="True"
ShowSideBySideTransformButton="True"
DefaultButtonDisplayMode="Both"
SideBySideCaptureIconText="📸"
SideBySideCaptureLabel="Take"
SideBySidePickIconText="🖼"
SideBySidePickLabel="Pick"
SideBySideTransformIconText="✨"
SideBySideTransformLabel="Transform"
SideBySideClearIconText="🗑"
SideBySideClearLabel="Clear"
SideBySideRedoIconText="↺"
SideBySideRedoLabel="Redo"
PlaceholderText="Select image"/>
<!-- Cancel Button -->
<Button x:Name="CancelButton"
Grid.Row="3"
Text="Cancel Transformation"
BackgroundColor="{AppThemeBinding Light=#F44336, Dark=#EF5350}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
IsVisible="False"
Clicked="OnCancelClicked"/>
<!-- Status Area -->
<Frame Grid.Row="4"
Padding="12"
BackgroundColor="{AppThemeBinding Light=#F0F0F0, Dark=#2A2A2A}"
BorderColor="Transparent"
CornerRadius="8">
<VerticalStackLayout Spacing="6">
<Label x:Name="StatusLabel"
Text="Configure your API token in Settings to get started"
FontSize="14"
HorizontalOptions="Center"
HorizontalTextAlignment="Center"/>
<Label x:Name="PredictionIdLabel"
Text=""
FontSize="11"
TextColor="{AppThemeBinding Light=#888888, Dark=#777777}"
HorizontalOptions="Center"/>
<Label x:Name="ResultUrlLabel"
Text=""
FontSize="12"
TextColor="{AppThemeBinding Light=#007AFF, Dark=#0A84FF}"
HorizontalOptions="Center"
LineBreakMode="MiddleTruncation"
MaxLines="2"/>
<Label x:Name="MetricsLabel"
Text=""
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Frame>
</Grid>
</ContentPage>

View File

@ -0,0 +1,249 @@
using MarketAlly.Replicate.Maui;
using MarketAlly.Replicate.Maui.Controls;
using Test.Replicate.Services;
namespace Test.Replicate.Pages
{
public partial class ImageTransformPage : ContentPage
{
private readonly AppSettings _appSettings;
private readonly IReplicateTransformerFactory _factory;
private readonly HistoryService _historyService;
private readonly List<ModelPreset> _imagePresets;
public ImageTransformPage()
{
InitializeComponent();
_appSettings = App.ServiceProvider.GetRequiredService<AppSettings>();
_factory = App.ServiceProvider.GetRequiredService<IReplicateTransformerFactory>();
_historyService = App.ServiceProvider.GetRequiredService<HistoryService>();
// Get available image presets
_imagePresets = ModelPresets.ImageModels.ToList();
// Initialize with factory
TransformerView.Initialize(_factory);
// Populate model picker
ImageModelPicker.ItemsSource = _imagePresets.Select(p => p.Name).ToList();
ImageModelPicker.SelectedIndex = 0;
SpeedModePicker.SelectedIndex = 1; // Default to "Juiced"
// Subscribe to control events
TransformerView.ImageSelected += OnImageSelected;
TransformerView.TransformationStarted += OnTransformationStarted;
TransformerView.TransformationCompleted += OnTransformationCompleted;
TransformerView.TransformationError += OnTransformationError;
// Subscribe to prediction tracking events (forwarded from internal tracker)
TransformerView.PredictionTracked += OnPredictionTracked;
TransformerView.PredictionCompleted += OnPredictionCompleted;
// Show/hide cancel button based on processing state
TransformerView.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(TransformerView.IsProcessing))
{
CancelButton.IsVisible = TransformerView.IsProcessing;
}
};
// Listen for settings changes
_appSettings.SettingsChanged += OnSettingsChanged;
// Apply initial settings
ApplySettings();
}
protected override void OnAppearing()
{
base.OnAppearing();
ApplySettings();
UpdateStatus();
}
private void OnSettingsChanged()
{
MainThread.BeginInvokeOnMainThread(() =>
{
ApplySettings();
UpdateStatus();
});
}
private void ApplySettings()
{
if (!_appSettings.IsConfigured)
return;
// Configure API token
TransformerView.ConfigureSettings(settings =>
{
settings.ApiToken = _appSettings.ApiToken;
});
// Apply selected preset
ApplySelectedPreset();
}
private void OnImageModelChanged(object? sender, EventArgs e)
{
ApplySelectedPreset();
UpdateStatus();
}
private void ApplySelectedPreset()
{
if (ImageModelPicker.SelectedIndex < 0 || ImageModelPicker.SelectedIndex >= _imagePresets.Count)
return;
var preset = _imagePresets[ImageModelPicker.SelectedIndex];
// Build custom parameters based on UI selections
var customParams = new Dictionary<string, object>();
// Speed mode (only for HiDream)
if (preset.Name.Contains("HiDream") && SpeedModePicker.SelectedIndex >= 0)
{
var speedModes = new[]
{
"Lightly Juiced 🍊 (more consistent)",
"Juiced 🔥 (more speed)",
"Extra Juiced 🚀 (even more speed)"
};
customParams["speed_mode"] = speedModes[SpeedModePicker.SelectedIndex];
}
TransformerView.SetImagePreset(preset, customParams.Count > 0 ? customParams : null);
}
private void UpdateStatus()
{
if (!_appSettings.IsConfigured)
{
StatusLabel.Text = "Configure your API token in Settings to get started";
}
else if (ImageModelPicker.SelectedIndex >= 0 && ImageModelPicker.SelectedIndex < _imagePresets.Count)
{
var preset = _imagePresets[ImageModelPicker.SelectedIndex];
StatusLabel.Text = $"Ready with {preset.Name} - select an image to transform";
}
else
{
StatusLabel.Text = "Ready - select an image to transform";
}
}
private async void OnCancelClicked(object? sender, EventArgs e)
{
StatusLabel.Text = "Canceling...";
await TransformerView.CancelTransformationAsync();
StatusLabel.Text = "Transformation canceled";
ClearResults();
}
private void OnImageSelected(object? sender, ImageSelectedEventArgs e)
{
if (!_appSettings.IsConfigured)
{
StatusLabel.Text = "Please configure your API token in Settings first";
return;
}
// Apply custom prompt if provided
var customPrompt = PromptEditor.Text?.Trim();
TransformerView.CustomImagePrompt = string.IsNullOrEmpty(customPrompt) ? null : customPrompt;
// Re-apply preset with current settings
ApplySelectedPreset();
var modelName = ImageModelPicker.SelectedIndex >= 0 && ImageModelPicker.SelectedIndex < _imagePresets.Count
? _imagePresets[ImageModelPicker.SelectedIndex].Name
: "selected model";
StatusLabel.Text = $"Image selected ({e.ImageBytes.Length / 1024:N0} KB) - tap Transform to process with {modelName}";
ClearResults();
}
private void OnTransformationStarted(object? sender, TransformationStartedEventArgs e)
{
// Update prompt right before transformation
var customPrompt = PromptEditor.Text?.Trim();
TransformerView.CustomImagePrompt = string.IsNullOrEmpty(customPrompt) ? null : customPrompt;
var modelName = TransformerView.ImagePreset?.Name ?? "model";
StatusLabel.Text = $"Transforming with {modelName}...";
ClearResults();
}
private void OnTransformationCompleted(object? sender, TransformationCompletedEventArgs e)
{
var result = e.Result;
var modelName = TransformerView.ImagePreset?.Name ?? "transformation";
StatusLabel.Text = $"{modelName} complete!";
PredictionIdLabel.Text = $"Prediction ID: {result.Id}";
ResultUrlLabel.Text = result.Output ?? "No output URL";
if (result.Metrics != null)
{
var parts = new List<string>();
if (result.Metrics.PredictTime.HasValue)
parts.Add($"Predict: {result.Metrics.PredictTime:F1}s");
if (result.Metrics.TotalTime.HasValue)
parts.Add($"Total: {result.Metrics.TotalTime:F1}s");
MetricsLabel.Text = string.Join(" | ", parts);
}
}
private void OnTransformationError(object? sender, TransformationErrorEventArgs e)
{
StatusLabel.Text = $"Error: {e.Error.Message}";
ClearResults();
}
private void ClearResults()
{
PredictionIdLabel.Text = "";
ResultUrlLabel.Text = "";
MetricsLabel.Text = "";
}
private void OnPredictionTracked(object? sender, PredictionStatusChangedEventArgs e)
{
_historyService.TrackPrediction(e.Prediction);
MainThread.BeginInvokeOnMainThread(() =>
{
StatusLabel.Text = e.NewStatusEnum switch
{
PredictionStatus.Starting => "Starting transformation...",
PredictionStatus.Processing => "Processing image...",
_ => $"Status: {e.NewStatus}"
};
});
}
private void OnPredictionCompleted(object? sender, PredictionCompletedEventArgs e)
{
_historyService.TrackPrediction(e.Prediction);
MainThread.BeginInvokeOnMainThread(() =>
{
if (e.Succeeded)
{
StatusLabel.Text = "Image transformation complete!";
}
else if (e.Failed)
{
StatusLabel.Text = $"Failed: {e.Prediction.Error ?? "Unknown error"}";
}
else if (e.Canceled)
{
StatusLabel.Text = "Transformation was canceled";
}
});
}
}
}

View File

@ -0,0 +1,101 @@
<?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"
x:Class="Test.Replicate.Pages.SettingsPage"
Title="Settings">
<ScrollView>
<VerticalStackLayout Padding="16" Spacing="16">
<!-- API Configuration -->
<Frame Padding="16"
BackgroundColor="{AppThemeBinding Light=#FFF8E1, Dark=#3E2723}"
BorderColor="{AppThemeBinding Light=#FFD54F, Dark=#8D6E63}"
CornerRadius="8">
<VerticalStackLayout Spacing="12">
<Label Text="API Configuration"
FontSize="18"
FontAttributes="Bold"/>
<Label Text="Enter your Replicate API token to enable transformations."
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Entry x:Name="ApiTokenEntry"
Placeholder="Enter your Replicate API token"
IsPassword="True"/>
<Button x:Name="SaveTokenButton"
Text="Save Token"
BackgroundColor="{AppThemeBinding Light=#FF9800, Dark=#FF9800}"
TextColor="White"
CornerRadius="8"
Clicked="OnSaveTokenClicked"/>
<Label x:Name="TokenStatusLabel"
Text=""
FontSize="12"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Frame>
<!-- Available Models Info -->
<Frame Padding="16"
BackgroundColor="{AppThemeBinding Light=#E8F5E9, Dark=#1B5E20}"
BorderColor="{AppThemeBinding Light=#81C784, Dark=#4CAF50}"
CornerRadius="8">
<VerticalStackLayout Spacing="12">
<Label Text="Available Models"
FontSize="18"
FontAttributes="Bold"/>
<Label Text="Image Models:"
FontSize="14"
FontAttributes="Bold"
TextColor="{AppThemeBinding Light=#2E7D32, Dark=#A5D6A7}"/>
<Label x:Name="ImageModelsLabel"
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="Video Models:"
FontSize="14"
FontAttributes="Bold"
TextColor="{AppThemeBinding Light=#1565C0, Dark=#90CAF9}"/>
<Label x:Name="VideoModelsLabel"
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
</VerticalStackLayout>
</Frame>
<!-- About -->
<Frame Padding="16"
BackgroundColor="{AppThemeBinding Light=#F5F5F5, Dark=#424242}"
BorderColor="{AppThemeBinding Light=#E0E0E0, Dark=#616161}"
CornerRadius="8">
<VerticalStackLayout Spacing="8">
<Label Text="About Replicate.Maui"
FontSize="18"
FontAttributes="Bold"/>
<Label Text="A .NET MAUI control library for integrating Replicate AI models into your mobile and desktop applications."
FontSize="12"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="Features:"
FontSize="12"
FontAttributes="Bold"
Margin="0,8,0,0"/>
<Label Text="• Drop-in UI control with image capture/pick"
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="• Multiple model presets (HiDream, SDXL, Luma, Seedance, etc.)"
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="• BYOK (Bring Your Own Key) support"
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="• Prediction tracking and history"
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
<Label Text="• Webhook and sync mode support"
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"/>
</VerticalStackLayout>
</Frame>
</VerticalStackLayout>
</ScrollView>
</ContentPage>

View File

@ -0,0 +1,68 @@
using MarketAlly.Replicate.Maui;
using Test.Replicate.Services;
namespace Test.Replicate.Pages
{
public partial class SettingsPage : ContentPage
{
private readonly AppSettings _settings;
public SettingsPage()
{
InitializeComponent();
_settings = App.ServiceProvider.GetRequiredService<AppSettings>();
LoadSettings();
DisplayAvailableModels();
}
private void LoadSettings()
{
ApiTokenEntry.Text = _settings.ApiToken;
UpdateTokenStatus();
}
private void DisplayAvailableModels()
{
// Display image models
var imageModels = ModelPresets.ImageModels
.Select(p => $"• {p.Name} ({p.ModelName})")
.ToList();
ImageModelsLabel.Text = string.Join("\n", imageModels);
// Display video models
var videoModels = ModelPresets.VideoModels
.Select(p => $"• {p.Name} ({p.ModelName})")
.ToList();
VideoModelsLabel.Text = string.Join("\n", videoModels);
}
private void OnSaveTokenClicked(object? sender, EventArgs e)
{
var token = ApiTokenEntry.Text?.Trim();
if (string.IsNullOrEmpty(token))
{
TokenStatusLabel.Text = "Please enter a valid token";
TokenStatusLabel.TextColor = Colors.Red;
return;
}
_settings.ApiToken = token;
_settings.NotifySettingsChanged();
UpdateTokenStatus();
}
private void UpdateTokenStatus()
{
if (_settings.IsConfigured)
{
TokenStatusLabel.Text = "Token configured - you're ready to go!";
TokenStatusLabel.TextColor = Colors.Green;
}
else
{
TokenStatusLabel.Text = "Token not set";
TokenStatusLabel.TextColor = Colors.Orange;
}
}
}
}

View File

@ -0,0 +1,170 @@
<?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:replicate="clr-namespace:MarketAlly.Replicate.Maui.Controls;assembly=MarketAlly.Replicate.Maui"
x:Class="Test.Replicate.Pages.VideoGenerationPage"
Title="Video Generation">
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Padding="16" RowSpacing="12">
<!-- Header -->
<VerticalStackLayout Grid.Row="0" Spacing="4">
<Label Text="Video Generation"
FontSize="22"
FontAttributes="Bold"
HorizontalOptions="Center"/>
<Label Text="Generate animated videos from your images"
FontSize="13"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
<!-- Model Selection and Options -->
<Frame Grid.Row="1"
Padding="12"
BackgroundColor="{AppThemeBinding Light=#F3E5F5, Dark=#4A148C}"
BorderColor="{AppThemeBinding Light=#BA68C8, Dark=#9C27B0}"
CornerRadius="8">
<VerticalStackLayout Spacing="8">
<!-- Model Selection -->
<Grid ColumnDefinitions="*,*" ColumnSpacing="8">
<VerticalStackLayout Grid.Column="0" Spacing="4">
<Label Text="Model:" FontSize="12" FontAttributes="Bold"/>
<Picker x:Name="VideoModelPicker"
FontSize="13"
SelectedIndexChanged="OnVideoModelChanged"/>
</VerticalStackLayout>
<VerticalStackLayout Grid.Column="1" Spacing="4">
<Label Text="Duration:" FontSize="12" FontAttributes="Bold"/>
<Picker x:Name="DurationPicker" FontSize="13"/>
</VerticalStackLayout>
</Grid>
<!-- Model-specific options -->
<Grid x:Name="AspectRatioGrid" ColumnDefinitions="*,*" ColumnSpacing="8">
<VerticalStackLayout Grid.Column="0" Spacing="4">
<Label Text="Aspect Ratio:" FontSize="11"/>
<Picker x:Name="AspectRatioPicker" FontSize="12">
<Picker.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>16:9</x:String>
<x:String>9:16</x:String>
<x:String>4:3</x:String>
<x:String>3:4</x:String>
<x:String>1:1</x:String>
</x:Array>
</Picker.ItemsSource>
</Picker>
</VerticalStackLayout>
<VerticalStackLayout x:Name="ResolutionStack" Grid.Column="1" Spacing="4">
<Label Text="Resolution:" FontSize="11"/>
<Picker x:Name="ResolutionPicker" FontSize="12">
<Picker.ItemsSource>
<x:Array Type="{x:Type x:String}">
<x:String>480p</x:String>
<x:String>720p</x:String>
<x:String>1080p</x:String>
</x:Array>
</Picker.ItemsSource>
</Picker>
</VerticalStackLayout>
</Grid>
<!-- Luma-specific: Loop option -->
<HorizontalStackLayout x:Name="LoopStack" Spacing="8" IsVisible="False">
<CheckBox x:Name="LoopCheckBox" IsChecked="False"/>
<Label Text="Loop video (seamless playback)"
FontSize="11"
VerticalOptions="Center"/>
</HorizontalStackLayout>
<!-- Seedance-specific: Camera fixed -->
<HorizontalStackLayout x:Name="CameraFixedStack" Spacing="8" IsVisible="False">
<CheckBox x:Name="CameraFixedCheckBox" IsChecked="False"/>
<Label Text="Fix camera position"
FontSize="11"
VerticalOptions="Center"/>
</HorizontalStackLayout>
<!-- Prompt -->
<VerticalStackLayout Spacing="4">
<Label Text="Prompt (optional override):" FontSize="12" FontAttributes="Bold"/>
<Editor x:Name="PromptEditor"
Placeholder="Leave empty to use model's default prompt..."
HeightRequest="50"
FontSize="12"/>
</VerticalStackLayout>
</VerticalStackLayout>
</Frame>
<!-- Transformer Control (Video) - SideBySide Layout -->
<replicate:ReplicateTransformerView
x:Name="TransformerView"
Grid.Row="2"
LayoutMode="SideBySide"
ShowVideoOptions="True"
TrackPredictions="True"
AutoTransformOnSelect="True"
AutoTransformType="Video"
SideBySideTransformType="Video"
ShowSideBySideSourceButtons="True"
ShowSideBySideResultButtons="True"
ShowSideBySideTransformButton="True"
DefaultButtonDisplayMode="Both"
SideBySideCaptureIconText="📸"
SideBySideCaptureLabel="Take"
SideBySidePickIconText="🖼"
SideBySidePickLabel="Pick"
SideBySideTransformIconText="🎬"
SideBySideTransformLabel="Generate"
SideBySideClearIconText="🗑"
SideBySideClearLabel="Clear"
SideBySideRedoIconText="↺"
SideBySideRedoLabel="Redo"
PlaceholderText="Select image"/>
<!-- Cancel Button -->
<Button x:Name="CancelButton"
Grid.Row="3"
Text="Cancel Generation"
BackgroundColor="{AppThemeBinding Light=#F44336, Dark=#EF5350}"
TextColor="White"
CornerRadius="8"
HeightRequest="44"
IsVisible="False"
Clicked="OnCancelClicked"/>
<!-- Status Area -->
<Frame Grid.Row="4"
Padding="12"
BackgroundColor="{AppThemeBinding Light=#F0F0F0, Dark=#2A2A2A}"
BorderColor="Transparent"
CornerRadius="8">
<VerticalStackLayout Spacing="6">
<Label x:Name="StatusLabel"
Text="Configure your API token in Settings to get started"
FontSize="14"
HorizontalOptions="Center"
HorizontalTextAlignment="Center"/>
<Label x:Name="PredictionIdLabel"
Text=""
FontSize="11"
TextColor="{AppThemeBinding Light=#888888, Dark=#777777}"
HorizontalOptions="Center"/>
<Label x:Name="ResultUrlLabel"
Text=""
FontSize="12"
TextColor="{AppThemeBinding Light=#007AFF, Dark=#0A84FF}"
HorizontalOptions="Center"
LineBreakMode="MiddleTruncation"
MaxLines="2"/>
<Label x:Name="MetricsLabel"
Text=""
FontSize="11"
TextColor="{AppThemeBinding Light=#666666, Dark=#999999}"
HorizontalOptions="Center"/>
</VerticalStackLayout>
</Frame>
</Grid>
</ContentPage>

View File

@ -0,0 +1,329 @@
using MarketAlly.Replicate.Maui;
using MarketAlly.Replicate.Maui.Controls;
using Test.Replicate.Services;
namespace Test.Replicate.Pages
{
public partial class VideoGenerationPage : ContentPage
{
private readonly AppSettings _appSettings;
private readonly IReplicateTransformerFactory _factory;
private readonly HistoryService _historyService;
private readonly List<ModelPreset> _videoPresets;
public VideoGenerationPage()
{
InitializeComponent();
_appSettings = App.ServiceProvider.GetRequiredService<AppSettings>();
_factory = App.ServiceProvider.GetRequiredService<IReplicateTransformerFactory>();
_historyService = App.ServiceProvider.GetRequiredService<HistoryService>();
// Get available video presets
_videoPresets = ModelPresets.VideoModels.ToList();
// Initialize with factory
TransformerView.Initialize(_factory);
// Populate model picker
VideoModelPicker.ItemsSource = _videoPresets.Select(p => p.Name).ToList();
VideoModelPicker.SelectedIndex = 0;
// Set default selections
AspectRatioPicker.SelectedIndex = 0;
ResolutionPicker.SelectedIndex = 1; // 720p
// Subscribe to control events
TransformerView.ImageSelected += OnImageSelected;
TransformerView.TransformationStarted += OnTransformationStarted;
TransformerView.TransformationCompleted += OnTransformationCompleted;
TransformerView.TransformationError += OnTransformationError;
// Subscribe to prediction tracking events (forwarded from internal tracker)
TransformerView.PredictionTracked += OnPredictionTracked;
TransformerView.PredictionCompleted += OnPredictionCompleted;
// Show/hide cancel button based on processing state
TransformerView.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(TransformerView.IsProcessing))
{
CancelButton.IsVisible = TransformerView.IsProcessing;
}
};
// Listen for settings changes
_appSettings.SettingsChanged += OnSettingsChanged;
// Apply initial settings
UpdateModelOptions();
ApplySettings();
}
protected override void OnAppearing()
{
base.OnAppearing();
ApplySettings();
UpdateStatus();
}
private void OnSettingsChanged()
{
MainThread.BeginInvokeOnMainThread(() =>
{
ApplySettings();
UpdateStatus();
});
}
private void OnVideoModelChanged(object? sender, EventArgs e)
{
UpdateModelOptions();
ApplySelectedPreset();
UpdateStatus();
}
private void UpdateModelOptions()
{
if (VideoModelPicker.SelectedIndex < 0 || VideoModelPicker.SelectedIndex >= _videoPresets.Count)
return;
var preset = _videoPresets[VideoModelPicker.SelectedIndex];
// Update duration options based on model
if (preset.Name.Contains("Luma"))
{
DurationPicker.ItemsSource = new List<int> { 5, 9 };
LoopStack.IsVisible = true;
CameraFixedStack.IsVisible = false;
ResolutionStack.IsVisible = false;
}
else if (preset.Name.Contains("Seedance"))
{
DurationPicker.ItemsSource = new List<int> { 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
LoopStack.IsVisible = false;
CameraFixedStack.IsVisible = true;
ResolutionStack.IsVisible = true;
}
else if (preset.Name.Contains("Kling"))
{
DurationPicker.ItemsSource = new List<int> { 5, 10 };
LoopStack.IsVisible = false;
CameraFixedStack.IsVisible = false;
ResolutionStack.IsVisible = false;
}
else if (preset.Name.Contains("Wan"))
{
DurationPicker.ItemsSource = new List<int> { 5 };
LoopStack.IsVisible = false;
CameraFixedStack.IsVisible = false;
ResolutionStack.IsVisible = false;
}
else if (preset.Name.Contains("Veo"))
{
// Google Veo 3 Fast only supports 4, 6, or 8 seconds
DurationPicker.ItemsSource = new List<int> { 4, 6, 8 };
LoopStack.IsVisible = false;
CameraFixedStack.IsVisible = false;
ResolutionStack.IsVisible = true; // Veo supports 720p/1080p
}
else
{
DurationPicker.ItemsSource = new List<int> { 5, 10 };
LoopStack.IsVisible = false;
CameraFixedStack.IsVisible = false;
ResolutionStack.IsVisible = false;
}
DurationPicker.SelectedIndex = 0;
}
private void ApplySettings()
{
if (!_appSettings.IsConfigured)
return;
// Configure API token
TransformerView.ConfigureSettings(settings =>
{
settings.ApiToken = _appSettings.ApiToken;
});
// Apply selected preset
ApplySelectedPreset();
}
private void ApplySelectedPreset()
{
if (VideoModelPicker.SelectedIndex < 0 || VideoModelPicker.SelectedIndex >= _videoPresets.Count)
return;
var preset = _videoPresets[VideoModelPicker.SelectedIndex];
// Build custom parameters based on UI selections
var customParams = new Dictionary<string, object>();
// Duration
if (DurationPicker.SelectedItem is int duration)
{
customParams["duration"] = duration;
}
// Aspect ratio (most models support this)
if (AspectRatioPicker.SelectedItem is string aspectRatio)
{
customParams["aspect_ratio"] = aspectRatio;
}
// Model-specific parameters
if (preset.Name.Contains("Luma"))
{
customParams["loop"] = LoopCheckBox.IsChecked;
}
else if (preset.Name.Contains("Seedance"))
{
customParams["camera_fixed"] = CameraFixedCheckBox.IsChecked;
if (ResolutionPicker.SelectedItem is string resolution)
{
customParams["resolution"] = resolution;
}
}
TransformerView.SetVideoPreset(preset, customParams);
}
private void UpdateStatus()
{
if (!_appSettings.IsConfigured)
{
StatusLabel.Text = "Configure your API token in Settings to get started";
}
else if (VideoModelPicker.SelectedIndex >= 0 && VideoModelPicker.SelectedIndex < _videoPresets.Count)
{
var preset = _videoPresets[VideoModelPicker.SelectedIndex];
StatusLabel.Text = $"Ready with {preset.Name} - select an image to animate";
}
else
{
StatusLabel.Text = "Ready - select an image to generate video";
}
}
private async void OnCancelClicked(object? sender, EventArgs e)
{
StatusLabel.Text = "Canceling...";
await TransformerView.CancelTransformationAsync();
StatusLabel.Text = "Video generation canceled";
ClearResults();
}
private void OnImageSelected(object? sender, ImageSelectedEventArgs e)
{
if (!_appSettings.IsConfigured)
{
StatusLabel.Text = "Please configure your API token in Settings first";
return;
}
// Apply custom prompt if provided
var customPrompt = PromptEditor.Text?.Trim();
TransformerView.CustomVideoPrompt = string.IsNullOrEmpty(customPrompt) ? null : customPrompt;
// Re-apply preset with current settings
ApplySelectedPreset();
var modelName = VideoModelPicker.SelectedIndex >= 0 && VideoModelPicker.SelectedIndex < _videoPresets.Count
? _videoPresets[VideoModelPicker.SelectedIndex].Name
: "selected model";
StatusLabel.Text = $"Image selected ({e.ImageBytes.Length / 1024:N0} KB) - tap Generate Video with {modelName}";
ClearResults();
}
private void OnTransformationStarted(object? sender, TransformationStartedEventArgs e)
{
if (e.Type != TransformationType.Video)
return;
// Update prompt right before transformation
var customPrompt = PromptEditor.Text?.Trim();
TransformerView.CustomVideoPrompt = string.IsNullOrEmpty(customPrompt) ? null : customPrompt;
var modelName = TransformerView.VideoPreset?.Name ?? "model";
StatusLabel.Text = $"Generating video with {modelName} (this may take several minutes)...";
ClearResults();
}
private void OnTransformationCompleted(object? sender, TransformationCompletedEventArgs e)
{
if (e.Type != TransformationType.Video)
return;
var result = e.Result;
var modelName = TransformerView.VideoPreset?.Name ?? "generation";
StatusLabel.Text = $"{modelName} complete!";
PredictionIdLabel.Text = $"Prediction ID: {result.Id}";
ResultUrlLabel.Text = result.Output ?? "No output URL";
if (result.Metrics != null)
{
var parts = new List<string>();
if (result.Metrics.PredictTime.HasValue)
parts.Add($"Predict: {result.Metrics.PredictTime:F1}s");
if (result.Metrics.TotalTime.HasValue)
parts.Add($"Total: {result.Metrics.TotalTime:F1}s");
MetricsLabel.Text = string.Join(" | ", parts);
}
}
private void OnTransformationError(object? sender, TransformationErrorEventArgs e)
{
StatusLabel.Text = $"Error: {e.Error.Message}";
ClearResults();
}
private void ClearResults()
{
PredictionIdLabel.Text = "";
ResultUrlLabel.Text = "";
MetricsLabel.Text = "";
}
private void OnPredictionTracked(object? sender, PredictionStatusChangedEventArgs e)
{
_historyService.TrackPrediction(e.Prediction);
MainThread.BeginInvokeOnMainThread(() =>
{
StatusLabel.Text = e.NewStatusEnum switch
{
PredictionStatus.Starting => "Starting video generation...",
PredictionStatus.Processing => "Generating video (this may take several minutes)...",
_ => $"Status: {e.NewStatus}"
};
});
}
private void OnPredictionCompleted(object? sender, PredictionCompletedEventArgs e)
{
_historyService.TrackPrediction(e.Prediction);
MainThread.BeginInvokeOnMainThread(() =>
{
if (e.Succeeded)
{
StatusLabel.Text = "Video generation complete!";
}
else if (e.Failed)
{
StatusLabel.Text = $"Failed: {e.Prediction.Error ?? "Unknown error"}";
}
else if (e.Canceled)
{
StatusLabel.Text = "Video generation was canceled";
}
});
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@ -0,0 +1,11 @@
using Android.App;
using Android.Content.PM;
using Android.OS;
namespace Test.Replicate
{
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
public class MainActivity : MauiAppCompatActivity
{
}
}

View File

@ -0,0 +1,16 @@
using Android.App;
using Android.Runtime;
namespace Test.Replicate
{
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership)
: base(handle, ownership)
{
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#512BD4</color>
<color name="colorPrimaryDark">#2B0B98</color>
<color name="colorAccent">#2B0B98</color>
</resources>

View File

@ -0,0 +1,10 @@
using Foundation;
namespace Test.Replicate
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<!-- See https://aka.ms/maui-publish-app-store#add-entitlements for more information about adding entitlements.-->
<dict>
<!-- App Sandbox must be enabled to distribute a MacCatalyst app through the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- When App Sandbox is enabled, this value is required to open outgoing network connections. -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The Mac App Store requires you specify if the app uses encryption. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/itsappusesnonexemptencryption -->
<!-- <key>ITSAppUsesNonExemptEncryption</key> -->
<!-- Please indicate <true/> or <false/> here. -->
<!-- Specify the category for your app here. -->
<!-- Please consult https://developer.apple.com/documentation/bundleresources/information_property_list/lsapplicationcategorytype -->
<!-- <key>LSApplicationCategoryType</key> -->
<!-- <string>public.app-category.YOUR-CATEGORY-HERE</string> -->
<key>UIDeviceFamily</key>
<array>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace Test.Replicate
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
using System;
namespace Test.Replicate
{
internal class Program : MauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
static void Main(string[] args)
{
var app = new Program();
app.Run(args);
}
}
}

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="maui-application-id-placeholder" version="0.0.0" api-version="9" xmlns="http://tizen.org/ns/packages">
<profile name="common" />
<ui-application appid="maui-application-id-placeholder" exec="Test.Replicate.dll" multiple="false" nodisplay="false" taskmanage="true" type="dotnet" launch_mode="single">
<label>maui-application-title-placeholder</label>
<icon>maui-appicon-placeholder</icon>
<metadata key="http://tizen.org/metadata/prefer_dotnet_aot" value="true" />
</ui-application>
<shortcut-list />
<privileges>
<privilege>http://tizen.org/privilege/internet</privilege>
</privileges>
<dependencies />
<provides-appdefined-privileges />
</manifest>

View File

@ -0,0 +1,8 @@
<maui:MauiWinUIApplication
x:Class="Test.Replicate.WinUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:maui="using:Microsoft.Maui"
xmlns:local="using:Test.Replicate.WinUI">
</maui:MauiWinUIApplication>

View File

@ -0,0 +1,25 @@
using Microsoft.UI.Xaml;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace Test.Replicate.WinUI
{
/// <summary>
/// Provides application-specific behavior to supplement the default Application class.
/// </summary>
public partial class App : MauiWinUIApplication
{
/// <summary>
/// Initializes the singleton application object. This is the first line of authored code
/// executed, and as such is the logical equivalent of main() or WinMain().
/// </summary>
public App()
{
this.InitializeComponent();
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity Name="maui-package-name-placeholder" Publisher="CN=User Name" Version="0.0.0.0" />
<mp:PhoneIdentity PhoneProductId="D7ACD08E-EC01-4AFB-AE69-5B4B6BDC7A1E" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
<Properties>
<DisplayName>$placeholder$</DisplayName>
<PublisherDisplayName>User Name</PublisherDisplayName>
<Logo>$placeholder$.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
</Dependencies>
<Resources>
<Resource Language="x-generate" />
</Resources>
<Applications>
<Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$">
<uap:VisualElements
DisplayName="$placeholder$"
Description="$placeholder$"
Square150x150Logo="$placeholder$.png"
Square44x44Logo="$placeholder$.png"
BackgroundColor="transparent">
<uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" />
<uap:SplashScreen Image="$placeholder$.png" />
</uap:VisualElements>
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="Test.Replicate.WinUI.app"/>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- The combination of below two tags have the following effect:
1) Per-Monitor for >= Windows 10 Anniversary Update
2) System < Windows 10 Anniversary Update
-->
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/PM</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@ -0,0 +1,10 @@
using Foundation;
namespace Test.Replicate
{
[Register("AppDelegate")]
public class AppDelegate : MauiUIApplicationDelegate
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
}

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
<integer>2</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>XSAppIconAssets</key>
<string>Assets.xcassets/appicon.appiconset</string>
</dict>
</plist>

View File

@ -0,0 +1,16 @@
using ObjCRuntime;
using UIKit;
namespace Test.Replicate
{
public class Program
{
// This is the main entry point of the application.
static void Main(string[] args)
{
// if you want to use a different Application Delegate class from "AppDelegate"
// you can specify it here.
UIApplication.Main(args, null, typeof(AppDelegate));
}
}
}

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
This is the minimum required version of the Apple Privacy Manifest for .NET MAUI apps.
The contents below are needed because of APIs that are used in the .NET framework and .NET MAUI SDK.
You are responsible for adding extra entries as needed for your application.
More information: https://aka.ms/maui-privacy-manifest
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>E174.1</string>
</array>
</dict>
<!--
The entry below is only needed when you're using the Preferences API in your app.
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict> -->
</array>
</dict>
</plist>

View File

@ -0,0 +1,8 @@
{
"profiles": {
"Windows Machine": {
"commandName": "Project",
"nativeDebugging": false
}
}
}

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="456" height="456" fill="#512BD4" />
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -0,0 +1,15 @@
Any raw assets you want to be deployed with your application can be placed in
this directory (and child directories). Deployment of the asset to your application
is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
These files will be deployed with your package and will be accessible using Essentials:
async Task LoadMauiAsset()
{
using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
using var reader = new StreamReader(stream);
var contents = reader.ReadToEnd();
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="456" height="456" viewBox="0 0 456 456" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="m 105.50037,281.60863 c -2.70293,0 -5.00091,-0.90042 -6.893127,-2.70209 -1.892214,-1.84778 -2.837901,-4.04181 -2.837901,-6.58209 0,-2.58722 0.945687,-4.80389 2.837901,-6.65167 1.892217,-1.84778 4.190197,-2.77167 6.893127,-2.77167 2.74819,0 5.06798,0.92389 6.96019,2.77167 1.93749,1.84778 2.90581,4.06445 2.90581,6.65167 0,2.54028 -0.96832,4.73431 -2.90581,6.58209 -1.89221,1.80167 -4.212,2.70209 -6.96019,2.70209 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 213.56111,280.08446 H 195.99044 L 149.69953,207.0544 c -1.17121,-1.84778 -2.14037,-3.76515 -2.90581,-5.75126 h -0.40578 c 0.36051,2.12528 0.54076,6.67515 0.54076,13.6496 v 65.13172 h -15.54349 v -99.36009 h 18.71925 l 44.7374,71.29798 c 1.89222,2.95695 3.1087,4.98917 3.64945,6.09751 h 0.26996 c -0.45021,-2.6325 -0.67573,-7.09015 -0.67573,-13.37293 v -64.02256 h 15.47557 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="m 289.25134,280.08446 h -54.40052 v -99.36009 h 52.23835 v 13.99669 h -36.15411 v 28.13085 h 33.31621 v 13.9271 h -33.31621 v 29.37835 h 38.31628 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
<path d="M 366.56466,194.72106 H 338.7222 v 85.3634 h -16.08423 v -85.3634 h -27.77455 v -13.99669 h 71.70124 z" style="fill:#ffffff;fill-rule:nonzero;stroke-width:0.838376" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<!-- Note: For Android please see also Platforms\Android\Resources\values\colors.xml -->
<Color x:Key="Primary">#512BD4</Color>
<Color x:Key="PrimaryDark">#ac99ea</Color>
<Color x:Key="PrimaryDarkText">#242424</Color>
<Color x:Key="Secondary">#DFD8F7</Color>
<Color x:Key="SecondaryDarkText">#9880e5</Color>
<Color x:Key="Tertiary">#2B0B98</Color>
<Color x:Key="White">White</Color>
<Color x:Key="Black">Black</Color>
<Color x:Key="Magenta">#D600AA</Color>
<Color x:Key="MidnightBlue">#190649</Color>
<Color x:Key="OffBlack">#1f1f1f</Color>
<Color x:Key="Gray100">#E1E1E1</Color>
<Color x:Key="Gray200">#C8C8C8</Color>
<Color x:Key="Gray300">#ACACAC</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="Gray950">#141414</Color>
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource Primary}"/>
<SolidColorBrush x:Key="SecondaryBrush" Color="{StaticResource Secondary}"/>
<SolidColorBrush x:Key="TertiaryBrush" Color="{StaticResource Tertiary}"/>
<SolidColorBrush x:Key="WhiteBrush" Color="{StaticResource White}"/>
<SolidColorBrush x:Key="BlackBrush" Color="{StaticResource Black}"/>
<SolidColorBrush x:Key="Gray100Brush" Color="{StaticResource Gray100}"/>
<SolidColorBrush x:Key="Gray200Brush" Color="{StaticResource Gray200}"/>
<SolidColorBrush x:Key="Gray300Brush" Color="{StaticResource Gray300}"/>
<SolidColorBrush x:Key="Gray400Brush" Color="{StaticResource Gray400}"/>
<SolidColorBrush x:Key="Gray500Brush" Color="{StaticResource Gray500}"/>
<SolidColorBrush x:Key="Gray600Brush" Color="{StaticResource Gray600}"/>
<SolidColorBrush x:Key="Gray900Brush" Color="{StaticResource Gray900}"/>
<SolidColorBrush x:Key="Gray950Brush" Color="{StaticResource Gray950}"/>
</ResourceDictionary>

View File

@ -0,0 +1,440 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xaml-comp compile="true" ?>
<ResourceDictionary
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">
<Style TargetType="ActivityIndicator">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="IndicatorView">
<Setter Property="IndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}"/>
<Setter Property="SelectedIndicatorColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray100}}"/>
</Style>
<Style TargetType="Border">
<Setter Property="Stroke" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="StrokeShape" Value="Rectangle"/>
<Setter Property="StrokeThickness" Value="1"/>
</Style>
<Style TargetType="BoxView">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Button">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource PrimaryDarkText}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource PrimaryDark}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="14,10"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="CheckBox">
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Color" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="DatePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Editor">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Entry">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ImageButton">
<Setter Property="Opacity" Value="1" />
<Setter Property="BorderColor" Value="Transparent"/>
<Setter Property="BorderWidth" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="Opacity" Value="0.5" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOver" />
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Label" x:Key="Headline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="32" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="Label" x:Key="SubHeadline">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource MidnightBlue}, Dark={StaticResource White}}" />
<Setter Property="FontSize" Value="24" />
<Setter Property="HorizontalOptions" Value="Center" />
<Setter Property="HorizontalTextAlignment" Value="Center" />
</Style>
<Style TargetType="ListView">
<Setter Property="SeparatorColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray500}}" />
<Setter Property="RefreshControlColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="Picker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="TitleColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="ProgressBar">
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="ProgressColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RadioButton">
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="RefreshView">
<Setter Property="RefreshColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="SearchBar">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="CancelButtonColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SearchHandler">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="PlaceholderColor" Value="{StaticResource Gray500}" />
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="FontFamily" Value="OpenSansRegular" />
<Setter Property="FontSize" Value="14" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="PlaceholderColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="Shadow">
<Setter Property="Radius" Value="15" />
<Setter Property="Opacity" Value="0.5" />
<Setter Property="Brush" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource White}}" />
<Setter Property="Offset" Value="10,10" />
</Style>
<Style TargetType="Slider">
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="MinimumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="MaximumTrackColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}"/>
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="SwipeItem">
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
</Style>
<Style TargetType="Switch">
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
<Setter Property="ThumbColor" Value="{StaticResource White}" />
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="On">
<VisualState.Setters>
<Setter Property="OnColor" Value="{AppThemeBinding Light={StaticResource Secondary}, Dark={StaticResource Gray200}}" />
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Primary}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Off">
<VisualState.Setters>
<Setter Property="ThumbColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<Style TargetType="TimePicker">
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource White}}" />
<Setter Property="BackgroundColor" Value="Transparent"/>
<Setter Property="FontFamily" Value="OpenSansRegular"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="MinimumHeightRequest" Value="44"/>
<Setter Property="MinimumWidthRequest" Value="44"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Property="TextColor" Value="{AppThemeBinding Light={StaticResource Gray300}, Dark={StaticResource Gray600}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
<!--
<Style TargetType="TitleBar">
<Setter Property="MinimumHeightRequest" Value="32"/>
<Setter Property="VisualStateManager.VisualStateGroups">
<VisualStateGroupList>
<VisualStateGroup x:Name="TitleActiveStates">
<VisualState x:Name="TitleBarTitleActive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="Transparent" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource White}}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="TitleBarTitleInactive">
<VisualState.Setters>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="ForegroundColor" Value="{AppThemeBinding Light={StaticResource Gray400}, Dark={StaticResource Gray500}}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateGroupList>
</Setter>
</Style>
-->
<Style TargetType="Page" ApplyToDerivedTypes="True">
<Setter Property="Padding" Value="0"/>
<Setter Property="BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
</Style>
<Style TargetType="Shell" ApplyToDerivedTypes="True">
<Setter Property="Shell.BackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="Shell.ForegroundColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.TitleColor" Value="{AppThemeBinding Light={StaticResource Black}, Dark={StaticResource SecondaryDarkText}}" />
<Setter Property="Shell.DisabledColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="Shell.UnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray200}}" />
<Setter Property="Shell.NavBarHasShadow" Value="False" />
<Setter Property="Shell.TabBarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Black}}" />
<Setter Property="Shell.TabBarForegroundColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarTitleColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="Shell.TabBarUnselectedColor" Value="{AppThemeBinding Light={StaticResource Gray900}, Dark={StaticResource Gray200}}" />
</Style>
<Style TargetType="NavigationPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource OffBlack}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
<Setter Property="IconColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource White}}" />
</Style>
<Style TargetType="TabbedPage">
<Setter Property="BarBackgroundColor" Value="{AppThemeBinding Light={StaticResource White}, Dark={StaticResource Gray950}}" />
<Setter Property="BarTextColor" Value="{AppThemeBinding Light={StaticResource Magenta}, Dark={StaticResource White}}" />
<Setter Property="UnselectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray200}, Dark={StaticResource Gray950}}" />
<Setter Property="SelectedTabColor" Value="{AppThemeBinding Light={StaticResource Gray950}, Dark={StaticResource Gray200}}" />
</Style>
</ResourceDictionary>

View File

@ -0,0 +1,116 @@
namespace Test.Replicate.Services
{
/// <summary>
/// Shared application settings service for storing API configuration.
/// Uses SecureStorage for sensitive data like API tokens.
/// </summary>
public class AppSettings
{
private const string ApiTokenKey = "replicate_api_token";
private string _apiToken = string.Empty;
private bool _isLoaded;
/// <summary>
/// The Replicate API token.
/// </summary>
public string ApiToken
{
get => _apiToken;
set
{
if (_apiToken != value)
{
_apiToken = value;
_ = SaveTokenAsync(value);
}
}
}
/// <summary>
/// Whether the API token has been configured.
/// </summary>
public bool IsConfigured => !string.IsNullOrEmpty(ApiToken);
/// <summary>
/// Raised when settings change.
/// </summary>
public event Action? SettingsChanged;
/// <summary>
/// Notify listeners that settings have changed.
/// </summary>
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
/// <summary>
/// Load the API token from secure storage.
/// Call this on app startup.
/// </summary>
public async Task LoadAsync()
{
if (_isLoaded)
return;
try
{
var token = await SecureStorage.Default.GetAsync(ApiTokenKey);
if (!string.IsNullOrEmpty(token))
{
_apiToken = token;
}
}
catch (Exception)
{
// SecureStorage may not be available on all platforms/emulators
// Fall back to preferences
_apiToken = Preferences.Default.Get(ApiTokenKey, string.Empty);
}
_isLoaded = true;
}
private async Task SaveTokenAsync(string token)
{
try
{
if (string.IsNullOrEmpty(token))
{
SecureStorage.Default.Remove(ApiTokenKey);
}
else
{
await SecureStorage.Default.SetAsync(ApiTokenKey, token);
}
}
catch (Exception)
{
// SecureStorage may not be available on all platforms/emulators
// Fall back to preferences
if (string.IsNullOrEmpty(token))
{
Preferences.Default.Remove(ApiTokenKey);
}
else
{
Preferences.Default.Set(ApiTokenKey, token);
}
}
}
/// <summary>
/// Clear the stored API token.
/// </summary>
public async Task ClearTokenAsync()
{
_apiToken = string.Empty;
try
{
SecureStorage.Default.Remove(ApiTokenKey);
}
catch
{
Preferences.Default.Remove(ApiTokenKey);
}
NotifySettingsChanged();
}
}
}

View File

@ -0,0 +1,94 @@
using MarketAlly.Replicate.Maui;
namespace Test.Replicate.Services
{
/// <summary>
/// Shared service for tracking prediction history across pages.
/// </summary>
public class HistoryService
{
private readonly List<TrackedPrediction> _history = new();
private readonly object _lock = new();
/// <summary>
/// Raised when history changes (prediction added or status updated).
/// </summary>
public event Action? HistoryChanged;
/// <summary>
/// Add or update a prediction in history.
/// </summary>
public void TrackPrediction(TrackedPrediction prediction)
{
lock (_lock)
{
var existing = _history.FirstOrDefault(p => p.Id == prediction.Id);
if (existing != null)
{
// Update existing
var index = _history.IndexOf(existing);
_history[index] = prediction;
}
else
{
// Add new
_history.Insert(0, prediction);
// Trim to max 50 items
while (_history.Count > 50)
{
_history.RemoveAt(_history.Count - 1);
}
}
}
HistoryChanged?.Invoke();
}
/// <summary>
/// Get all tracked predictions ordered by creation time (newest first).
/// </summary>
public IReadOnlyList<TrackedPrediction> GetHistory()
{
lock (_lock)
{
return _history.OrderByDescending(p => p.CreatedAt).ToList();
}
}
/// <summary>
/// Get pending predictions only.
/// </summary>
public IReadOnlyList<TrackedPrediction> GetPending()
{
lock (_lock)
{
return _history.Where(p => p.IsPending).OrderByDescending(p => p.CreatedAt).ToList();
}
}
/// <summary>
/// Clear completed predictions.
/// </summary>
public void ClearCompleted()
{
lock (_lock)
{
_history.RemoveAll(p => p.IsCompleted);
}
HistoryChanged?.Invoke();
}
/// <summary>
/// Clear all predictions.
/// </summary>
public void ClearAll()
{
lock (_lock)
{
_history.Clear();
}
HistoryChanged?.Invoke();
}
}
}

View File

@ -0,0 +1,71 @@
<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> -->
<!-- Note for MacCatalyst:
The default runtime is maccatalyst-x64, except in Release config, in which case the default is maccatalyst-x64;maccatalyst-arm64.
When specifying both architectures, use the plural <RuntimeIdentifiers> instead of the singular <RuntimeIdentifier>.
The Mac App Store will NOT accept apps with ONLY maccatalyst-arm64 indicated;
either BOTH runtimes must be indicated or ONLY macatalyst-x64. -->
<!-- For example: <RuntimeIdentifiers>maccatalyst-x64;maccatalyst-arm64</RuntimeIdentifiers> -->
<OutputType>Exe</OutputType>
<RootNamespace>Test.Replicate</RootNamespace>
<UseMaui>true</UseMaui>
<SingleProject>true</SingleProject>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Display name -->
<ApplicationTitle>Test.Replicate</ApplicationTitle>
<!-- App Identifier -->
<ApplicationId>com.companyname.test.replicate</ApplicationId>
<!-- Versions -->
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
<!-- To develop, package, and publish an app to the Microsoft Store, see: https://aka.ms/MauiTemplateUnpackaged -->
<WindowsPackageType>None</WindowsPackageType>
<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>
<!-- App Icon -->
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#512BD4" />
<!-- Splash Screen -->
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#512BD4" BaseSize="128,128" />
<!-- Images -->
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\dotnet_bot.png" Resize="True" BaseSize="300,185" />
<!-- Custom Fonts -->
<MauiFont Include="Resources\Fonts\*" />
<!-- Raw Assets (also remove the "Resources\Raw" prefix) -->
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="9.0.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Replicate.Maui\Replicate.Maui.csproj" />
</ItemGroup>
</Project>