| name | sanet-mvvm |
| description | Use this skill whenever working in a project that references Sanet.MVVM packages (Sanet.MVVM.Core, Sanet.MVVM.Navigation.Avalonia, Sanet.MVVM.Views.Avalonia, Sanet.MVVM.DI.Avalonia, Sanet.MVVM.ExternalNavigation.*). Triggers include: creating or editing ViewModels, setting up navigation, implementing modal dialogs, registering views/VMs, configuring DI, wiring up commands, managing lifecycle (AttachHandlers / DetachHandlers), bootstrapping an AvaloniaUI app with this framework, or opening URLs / emails via IExternalNavigationService. Also trigger when the user asks how to pass data between screens, show action dialogs, compose child ViewModels, handle reactive subscriptions, or open external links inside a Sanet.MVVM project. Consult this skill even for seemingly small tasks like adding a command or a new screen — the framework has specific patterns that must be followed. |
| metadata | {"author":"Anton Makarevich","version":"1.1","framework":"Sanet.MVVM","platform":"AvaloniaUI"} |
Sanet.MVVM – Agent Reference
Lightweight MVVM framework for AvaloniaUI. Key packages:
Sanet.MVVM.Core · Sanet.MVVM.Navigation.Avalonia ·
Sanet.MVVM.Views.Avalonia · Sanet.MVVM.DI.Avalonia ·
Sanet.MVVM.ExternalNavigation.Desktop · Sanet.MVVM.ExternalNavigation.Android ·
Sanet.MVVM.ExternalNavigation.iOS · Sanet.MVVM.ExternalNavigation.Browser
Quick Decision Tree
Before writing any code, pick the right path:
| Task | Go to |
|---|
| New screen / ViewModel | §3 ViewModel + §4 View + §5 Registration |
| Navigate to existing screen | §6 Navigation |
| Modal / popup dialog (with or without a return value) | §7 Modal Dialogs |
| Simple yes/no action dialog | §8 Action Dialogs |
| First-time app setup | §1 Bootstrap + §2 DI |
| Pass data to the next screen | §6 → Data Passing |
| Subscriptions / observables | §9 Lifecycle Patterns |
| Child VM (no navigation) | §9 → Child ViewModels |
| Open URL / email in external app | §12 External Navigation |
§1 — App Bootstrap
AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseDependencyInjection(services =>
{
services.RegisterAppServices();
services.RegisterViewModels();
})
.StartWithClassicDesktopLifetime(args);
if (Resources[AppBuilderExtensions.ServiceCollectionResourceKey]
is not IServiceCollection services)
throw new Exception("Services not initialized");
var sp = services.BuildServiceProvider();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var navService = sp.GetRequiredService<INavigationService>();
RegisterViews(navService);
var mainVm = sp.GetRequiredService<MainViewModel>();
await navService.NavigateToViewModelAsync(mainVm);
}
§2 — Dependency Injection
Rules:
INavigationService → singleton, constructed with the lifetime object and the service provider.
- ViewModels → transient (fresh state on every
GetNewViewModel<T>() call).
- Shared services (repositories, settings, etc.) → singleton.
IExternalNavigationService → singleton, registered via platform-specific extension method.
services.AddSingleton<INavigationService>(sp =>
{
var lifetime = sp.GetRequiredService<IClassicDesktopStyleApplicationLifetime>();
return new NavigationService(lifetime, sp);
});
services.AddSingleton<INavigationService>(sp =>
{
var lifetime = sp.GetRequiredService<ISingleViewApplicationLifetime>();
var wrapper = new ContentControl();
return new SingleViewNavigationService(lifetime, wrapper, sp);
});
services.AddTransient<MainViewModel>();
services.AddTransient<DetailViewModel>();
services.AddTransient<FilterViewModel>();
services.AddSingleton<IGameService, GameService>();
services.AddDesktopExternalNavigation();
services.AddAndroidExternalNavigation();
services.AddIosExternalNavigation();
services.AddBrowserExternalNavigation();
§3 — ViewModels
Inherit from BaseViewModel. Do not start work in the constructor.
public class MyViewModel : BaseViewModel
{
private string _title = string.Empty;
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public ICommand GoToDetailCommand { get; }
public MyViewModel(IMyService service)
{
_service = service;
GoToDetailCommand = new AsyncCommand(GoToDetail);
}
public override void AttachHandlers()
{
base.AttachHandlers();
_cts = new CancellationTokenSource();
LoadDataAsync(_cts.Token).SafeFireAndForget();
_service.SomethingChanged += OnSomethingChanged;
}
public override void DetachHandlers()
{
_cts?.Cancel();
_service.SomethingChanged -= OnSomethingChanged;
base.DetachHandlers();
}
private async Task GoToDetail()
{
var vm = NavigationService.GetNewViewModel<DetailViewModel>()
?? throw new InvalidOperationException("DetailViewModel not registered");
vm.ItemId = _selectedId;
await NavigationService.NavigateToViewModelAsync(vm);
}
}
Provided by BaseViewModel:
| Member | Purpose |
|---|
NavigationService | Lazy INavigationService; throws if not injected |
SetNavigationService(INavigationService) | Injects the navigation service (required for VMs not navigated to through the framework, e.g. modal VMs created with new) |
IsBusy | Bindable busy flag |
ExpectsResult | Set by framework for modal VMs |
BackCommand | Calls NavigateBackAsync |
CloseAsync(result) | Closes the view, fires OnResult |
SetProperty(ref field, value) | Property + change notification |
NotifyPropertyChanged(nameof(X)) | Manual single-prop notification |
NotifyAllPropertiesChanged() | Refresh all bindings |
§4 — Views
public partial class MyView : BaseView<MyViewModel>
{
public MyView() => InitializeComponent();
protected override void OnViewModelSet()
{
}
}
The framework automatically calls AttachHandlers() / DetachHandlers() on visual-tree events — no manual wiring needed.
§5 — View Registration
Every ViewModel–View pair must be registered. Do this after building the service provider, before the first navigation.
void RegisterViews(INavigationService nav)
{
nav.RegisterViews(typeof(MainView), typeof(MainViewModel));
nav.RegisterViews(typeof(DetailView), typeof(DetailViewModel));
nav.RegisterViews(typeof(FilterView), typeof(FilterViewModel));
nav.RegisterViews(typeof(SettingsView), typeof(SettingsViewModel));
if (IsMobile())
nav.RegisterViews(typeof(DetailViewNarrow), typeof(DetailViewModel));
else
nav.RegisterViews(typeof(DetailViewWide), typeof(DetailViewModel));
}
⚠️ Missing registration → runtime exception on first navigation to that VM.
§6 — Navigation
var vm = NavigationService.GetNewViewModel<DetailViewModel>()
?? throw new InvalidOperationException("DetailViewModel not registered");
vm.ItemId = selectedId;
await NavigationService.NavigateToViewModelAsync(vm);
var vm = NavigationService.GetViewModel<SharedViewModel>();
await NavigationService.NavigateToViewModelAsync(vm);
await NavigationService.NavigateBackAsync();
await NavigationService.NavigateToRootAsync();
bool ok = NavigationService.HasViewModel<SomeViewModel>();
Data-passing patterns:
vm.Item = selectedItem;
await NavigationService.NavigateToViewModelAsync(vm);
vm.Initialize(game, reason);
await NavigationService.NavigateToViewModelAsync(vm);
§7 — Modal Dialogs (Overlay / Popup Style)
Use ShowViewModelForResultAsync whenever you need a modal overlay — whether or not the dialog returns a value. The overlay is rendered via OverlayLayer with a semi-transparent backdrop and is not pushed onto the back stack.
7a — Modal without a return value
When you just need to show a popup and don't need data back, implement IResultProvider<bool> (or any throwaway type) and resolve it on close:
public class InfoDialogViewModel : BaseViewModel, IResultProvider<bool>
{
private readonly TaskCompletionSource<bool> _tcs = new();
public Task<bool> GetResultAsync() => _tcs.Task;
public async Task Close()
{
_tcs.SetResult(true);
await CloseAsync();
}
}
var vm = NavigationService.GetViewModel<InfoDialogViewModel>();
await NavigationService.ShowViewModelForResultAsync<InfoDialogViewModel, bool>(vm);
7b — Modal with a typed return value
When the dialog must hand data back to the caller, use a meaningful result type:
public class FilterViewModel : BaseViewModel, IResultProvider<FilterCriteria?>
{
private readonly TaskCompletionSource<FilterCriteria?> _tcs = new();
public Task<FilterCriteria?> GetResultAsync() => _tcs.Task;
public async Task Apply()
{
_tcs.SetResult(BuildCriteria());
await CloseAsync();
}
public async Task Cancel()
{
_tcs.SetResult(null);
await CloseAsync();
}
}
var filterVm = NavigationService.GetViewModel<FilterViewModel>();
var criteria = await NavigationService
.ShowViewModelForResultAsync<FilterViewModel, FilterCriteria?>(filterVm);
if (criteria != null)
ApplyFilter(criteria);
Rules for all modal dialogs:
- Always resolve
_tcs before calling CloseAsync() — never leave it hanging.
- Do not use
BackCommand or NavigateBackAsync inside modal VMs; close via CloseAsync().
- The caller's
await unblocks as soon as GetResultAsync() completes.
7c — Sub-navigation from a modal VM
ShowViewModelForResultAsync does not inject NavigationService into the target VM. If a modal VM needs to show its own sub-dialogs (e.g. an info popup triggered from within the modal), the parent must call SetNavigationService before showing the modal:
var modalVm = new MyModalViewModel(data, service);
modalVm.SetNavigationService(NavigationService);
await NavigationService.ShowViewModelForResultAsync<MyModalViewModel, ResultType>(modalVm);
⚠️ Missing SetNavigationService → the modal VM's NavigationService property will throw ArgumentNullException on first access.
§8 — Action Dialogs (Built-in)
For simple user choices that don't need a custom screen.
var yes = new UiAction { Title = "Delete" };
var no = new UiAction { Title = "Cancel" };
var chosen = await NavigationService.AskForActionAsync(
"Delete item?",
"This cannot be undone.",
yes, no);
if (chosen == yes)
await DeleteAsync();
UiAction also supports Command / CommandParameter for button-specific logic.
§9 — Lifecycle Patterns
Reactive subscriptions
private IDisposable? _sub;
public override void AttachHandlers()
{
base.AttachHandlers();
_sub = _service.Updates
.ObserveOn(_dispatcher.Scheduler)
.Subscribe(OnUpdate);
}
public override void DetachHandlers()
{
_sub?.Dispose();
base.DetachHandlers();
}
Child ViewModels (composed inline, no navigation)
public class ParentViewModel : BaseViewModel
{
public MapConfigViewModel MapConfig { get; }
public ParentViewModel(IMapService mapService)
{
MapConfig = new MapConfigViewModel(mapService);
}
}
IDisposable
Implement when the VM owns resources that outlive visual-tree detach:
public class MyViewModel : BaseViewModel, IDisposable
{
public void Dispose()
{
_subscription?.Dispose();
GC.SuppressFinalize(this);
}
}
§10 — Checklist for New Screens
When adding a screen, do all of these steps:
§11 — Anti-Patterns to Avoid
| ❌ Don't | ✅ Do instead |
|---|
new MyViewModel(...) directly | NavigationService.GetNewViewModel<MyViewModel>() |
| Start async work in constructor | Use AttachHandlers |
| Subscribe in constructor | Subscribe in AttachHandlers, unsubscribe in DetachHandlers |
| Forget to register view/VM pair | Always add to RegisterViews |
Use NavigateToViewModelAsync for modal/popup dialogs | Use ShowViewModelForResultAsync |
Resolve NavigationService in constructor | Access it lazily via the NavigationService property |
Use NavigationService in a modal VM without calling SetNavigationService first | Parent calls modalVm.SetNavigationService(NavigationService) before ShowViewModelForResultAsync |
§12 — External Navigation
IExternalNavigationService (from Sanet.MVVM.Core.Services) opens URLs and emails in the user's default browser or mail client. Platform-specific implementations are provided by Sanet.MVVM.ExternalNavigation.* packages.
DI Registration
Register one platform-specific implementation per head project (see §2):
services.AddDesktopExternalNavigation();
services.AddAndroidExternalNavigation();
services.AddIosExternalNavigation();
services.AddBrowserExternalNavigation();
Usage in a ViewModel
Inject IExternalNavigationService via constructor and wrap calls in AsyncCommand:
using System.Windows.Input;
using AsyncAwaitBestPractices.MVVM;
using Sanet.MVVM.Core.Services;
using Sanet.MVVM.Core.ViewModels;
public class AboutViewModel : BaseViewModel
{
public AboutViewModel(IExternalNavigationService externalNavigationService)
{
OpenGitHubCommand = new AsyncCommand(
() => externalNavigationService.OpenUrlAsync("https://github.com/example"));
OpenContactCommand = new AsyncCommand(
() => externalNavigationService.OpenEmailAsync("user@example.com", "Subject"));
}
public ICommand OpenGitHubCommand { get; }
public ICommand OpenContactCommand { get; }
}
Available methods:
| Method | Purpose |
|---|
OpenUrlAsync(string url) | Opens the URL in the default browser or external app |
OpenEmailAsync(string emailAddress, string subject) | Opens the mail client with pre-filled recipient and subject |
Rules:
- Both methods are fire-and-forget (return
Task.CompletedTask on most platforms, await on iOS).
- Exceptions are caught internally — never re-thrown. Log if needed.
- Always wrap in
AsyncCommand (or similar) when binding to UI commands.
- The service is a singleton — safe to inject and hold in any ViewModel.
Testing
Mock IExternalNavigationService with NSubstitute:
using NSubstitute;
using Sanet.MVVM.Core.Services;
var navService = Substitute.For<IExternalNavigationService>();
var vm = new MyViewModel(navService);
await navService.Received(1).OpenUrlAsync("https://example.com");