We will use the DialogPrefab mechanism from Asv.Avalonia to create modal windows that can be invoked directly from the ViewModel.
Place the RecipeEditDialogViewModel in the Dialogs directory of your project.
// RecipeEditDialogViewModel.cs
using System.Collections.Generic;
using Asv.Avalonia;
using Asv.Common;
using Microsoft.Extensions.Logging;
using R3;
namespace RecipeBook.ViewModels;
public class RecipeEditDialogViewModel : DialogViewModelBase
{
public const string DialogId = $"{BaseId}.recipe_edit";
public RecipeEditDialogViewModel(ILoggerFactory loggerFactory)
: base(DialogId, loggerFactory)
{
Title = new BindableReactiveProperty<string?>().DisposeItWith(Disposable);
Category = new BindableReactiveProperty<string?>().DisposeItWith(Disposable);
}
public BindableReactiveProperty<string?> Title { get; }
public BindableReactiveProperty<string?> Category { get; }
public override IEnumerable<IRoutable> GetChildren()
{
return [];
}
}
Define RecipeEditDialogPrefab to handle data exchange with the dialog, enabling both payload injection and result retrieval.
// RecipeEditDialogPrefab.cs
using System.Composition;
using System.Threading.Tasks;
using Asv.Avalonia;
using Microsoft.Extensions.Logging;
namespace RecipeBook.ViewModels;
public sealed class RecipeEditDialogPayload
{
public required string Title { get; init; }
public required string Category { get; init; }
}
[ExportDialogPrefab]
[Shared]
[method: ImportingConstructor]
public sealed class RecipeEditDialogPrefab(INavigationService nav, ILoggerFactory loggerFactory)
: IDialogPrefab<RecipeEditDialogPayload, RecipeEditDialogPayload?>
{
public async Task<RecipeEditDialogPayload?> ShowDialogAsync(RecipeEditDialogPayload dialogPayload)
{
using var vm = new RecipeEditDialogViewModel(loggerFactory);
vm.Title.Value = dialogPayload.Title;
vm.Category.Value = dialogPayload.Category;
var dialogContent = new ContentDialog(vm, nav)
{
Title = dialogPayload.Title,
PrimaryButtonText = RS.DialogButton_Yes,
SecondaryButtonText = RS.DialogButton_No,
DefaultButton = ContentDialogButton.Primary,
};
var result = await dialogContent.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return null;
}
return new RecipeEditDialogPayload
{
Title = vm.Title.Value,
Category = vm.Category.Value
};
}
}
Design the RecipeEditDialogView layout to allow users to input the recipe title and category during creation.
//RecipeEditDialogView.axaml.cs
using Asv.Avalonia;
using Avalonia.Controls;
using RecipeBook.ViewModels;
namespace RecipeBook.Views;
[ExportViewFor(typeof(RecipeEditDialogViewModel))]
public partial class RecipeEditDialogView : UserControl
{
public RecipeEditDialogView()
{
InitializeComponent();
}
}
Inside RecipePageViewModel, add a field to store the dialog prefab retrieved from the dialog service, along with a command to create a new recipe.
private readonly RecipeEditDialogPrefab _recipeEditDialog;
public ReactiveCommand CreateRecipeCommand { get; }
Initialize the command and retrieve DialogPrefab in RecipePageViewModel constructor.
_recipeEditDialog = dialogService.GetDialogPrefab<RecipeEditDialogPrefab>();
CreateRecipeCommand = new ReactiveCommand(CreateRecipeAsync).DisposeItWith(Disposable);
Define the handler for the recipe creation process.
// RecipePageViewModel.cs
private async ValueTask CreateRecipeAsync(Unit unit, CancellationToken cancellationToken)
{
var payload = new RecipeEditDialogPayload
{
Title = "Recipe Title",
Category = "Category",
};
var createdRecipePayload = await _recipeEditDialog.ShowDialogAsync(payload);
if (createdRecipePayload == null)
{
return;
}
var recipeViewModel = new RecipeViewModel(
Guid.NewGuid().ToString(),
createdRecipePayload.Title,
createdRecipePayload.Category,
string.Empty,
[],
_loggerFactory
);
_recipes.Add(recipeViewModel);
SelectedRecipe.Value = recipeViewModel;
}
Place the "Add Recipe" button immediately after the recipe list container within the sidebar RecipePageView.
Let's implement the notification system, starting with a confirmation message when an ingredient is added. But first, we need to ensure we can create recipes.
Add the ingredient creation command to RecipeViewModel:
public ReactiveCommand CreateIngredientCommand { get; }
Add an initialization in RecipeViewModel constructor:
CreateIngredientCommand = new ReactiveCommand(AddIngredientAsync).DisposeItWith(Disposable);
Handle ingredient addition and send a toast notification:
public async ValueTask AddIngredientAsync(Unit unit, CancellationToken cancellationToken)
{
var ingredient = new IngredientViewModel(
Guid.NewGuid().ToString(),
"Ingredient",
string.Empty,
_loggerFactory
);
_ingredients.Add(ingredient);
var msg = new ShellMessage(
"Added ingredient",
"Ingredient was created",
ShellErrorState.Normal,
"This is description",
MaterialIconKind.Info
);
await this.RaiseShellInfoMessage(msg, cancellationToken);
}
Notification on Recipe Creation
Events
Removing Ingredients
We need to notify the parent viewmodel that an ingredient has been removed. To achieve this, create a RemoveIngredientEvent in the Events directory at Pages directory.
// RemoveIngredientEvent.cs
using System.Threading;
using System.Threading.Tasks;
using Asv.Avalonia;
using Asv.Common;
namespace RecipeBook.Events;
public sealed class RemoveIngredientEvent(IRoutable source) : AsyncRoutedEvent<IRoutable>(source, RoutingStrategy.Bubble);
public static class RemoveIngredientEventMixin
{
public static ValueTask RequestRemoveIngredient(this IRoutable src, CancellationToken cancel = default)
{
return src.Rise(new RemoveIngredientEvent(src), cancel);
}
}
Add the DeleteIngredientCommand to the IngredientViewModel.
public ReactiveCommand DeleteIngredientCommand { get; }
Initialize the command in the constructor:
...
public IngredientViewModel(string id, string name, string amount, ILoggerFactory loggerFactory)
: base(new NavigationId(BaseId, id), loggerFactory)
{
...
Amount = new HistoricalStringProperty(
nameof(Amount),
_amount,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
// new command
DeleteIngredientCommand = new ReactiveCommand(RemoveIngredientAsync).DisposeItWith(Disposable);
}
...
Raise the event using the extension method defined in RemoveIngredientEvent.
Intercept the bubbling events by implementing InternalCatchEvent in the RecipeViewModel.
private ValueTask InternalCatchEvent(IRoutable src, AsyncRoutedEvent<IRoutable> e)
{
if (e is not RemoveIngredientEvent)
{
return default;
}
var vm = _ingredients.First(i => i.Id == e.Sender.Id);
_ingredients.Remove(vm);
return default;
}
Subscribe to events in RecipeViewModel constructor.
...
public RecipeViewModel(string id, string title, string? category, string? instruction,
IEnumerable<IngredientViewModel> ingredients, ILoggerFactory loggerFactory)
: base(new NavigationId(BaseId, id), loggerFactory)
{
...
CreateIngredientCommand = new ReactiveCommand(AddIngredientAsync).DisposeItWith(Disposable);
// here we subscribe to events
Events.Subscribe(InternalCatchEvent).DisposeItWith(Disposable);
}
...