Source code
AsvAvaloniaTest/
├── Assets/
├── Shell/
│ ├── ShellRegistrations.cs
│ └── Pages/
│ ├── PagesRegistrations.cs
│ └── Recipes/
│ ├── Dialogs/
│ │ ├── RecipeEditDialogPrefab.cs
│ │ ├── RecipeEditDialogView.axaml
│ │ ├── RecipeEditDialogView.axaml.cs
│ │ └── RecipeEditDialogViewModel.cs
│ ├── Events/
│ │ └── RemoveIngredientEvent.cs
│ ├── Ingredients/
│ │ └── IngredientViewModel.cs
│ ├── HomePageRecipeExtension.cs
│ ├── RecipePageRegistrations.cs
│ ├── RecipePageView.axaml
│ ├── RecipePageView.axaml.cs
│ ├── RecipePageViewModel.cs
│ └── RecipeViewModel.cs
├── App.axaml
├── App.axaml.cs
├── RecipeBookRegistrations.cs
├── app.manifest
└── Program.cs
using System;
using System.Threading.Tasks;
using Asv.Avalonia;
using Avalonia;
using Avalonia.Controls;
namespace AsvAvaloniaTest;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
try
{
BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args, ShutdownMode.OnMainWindowClose);
AppHost.Instance.StopAsync().GetAwaiter().GetResult();
Task.Factory.StartNew(AppHost.Instance.Dispose).GetAwaiter().GetResult();
}
catch (Exception e)
{
AppHost.HandleApplicationCrash(e);
}
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseAsv(builder =>
{
builder
.RegisterDefault()
.RegisterDesktopShell()
.RegisterRecipeBookApp();
});
}
using System;
using Asv.Avalonia;
using Microsoft.Extensions.Hosting;
namespace AsvAvaloniaTest;
public static class RecipeBookRegistrations
{
extension(IHostApplicationBuilder builder)
{
public Builder RecipeBook => new(builder);
public IHostApplicationBuilder RegisterRecipeBookApp(Action<Builder>? configure = null)
{
configure ??= recipeBook => recipeBook.RegisterDefault();
configure(new Builder(builder));
return builder;
}
}
public class Builder(IHostApplicationBuilder builder) : IDependencyBuilder
{
public IHostApplicationBuilder AppBuilder => builder;
public Builder RegisterDefault()
{
this.RegisterShell();
return this;
}
}
}
using System;
using Asv.Avalonia;
using Microsoft.Extensions.Hosting;
namespace AsvAvaloniaTest;
public static class ShellRegistrations
{
extension(RecipeBookRegistrations.Builder builder)
{
public Builder Shell => new(builder);
public RecipeBookRegistrations.Builder RegisterShell(Action<Builder>? configure = null)
{
configure ??= shell => shell.RegisterDefault();
configure(new Builder(builder));
return builder;
}
}
public class Builder(RecipeBookRegistrations.Builder builder) : IDependencyBuilder
{
public IHostApplicationBuilder AppBuilder => builder.AppBuilder;
public Builder RegisterDefault()
{
this.RegisterPages();
return this;
}
}
}
using System;
using Asv.Avalonia;
using Microsoft.Extensions.Hosting;
namespace AsvAvaloniaTest;
public static class PagesRegistrations
{
extension(ShellRegistrations.Builder builder)
{
public Builder Pages => new(builder);
public ShellRegistrations.Builder RegisterPages(Action<Builder>? configure = null)
{
configure ??= pages => pages.RegisterDefault();
configure(new Builder(builder));
return builder;
}
}
public class Builder(ShellRegistrations.Builder builder) : IDependencyBuilder
{
public IHostApplicationBuilder AppBuilder => builder.AppBuilder;
public Builder RegisterDefault()
{
this.RegisterRecipePage();
return this;
}
}
}
using Asv.Avalonia;
namespace AsvAvaloniaTest;
public static class RecipePageRegistrations
{
extension(PagesRegistrations.Builder builder)
{
public PagesRegistrations.Builder RegisterRecipePage()
{
builder.AppBuilder.Pages.Register<RecipePageViewModel, RecipePageView>(
RecipePageViewModel.PageId
);
builder.AppBuilder.Extensions.Register<IHomePage, HomePageRecipeExtension>();
builder.AppBuilder.Dialogs.RegisterPrefab<RecipeEditDialogPrefab>();
builder.AppBuilder.ViewLocator.RegisterViewFor<
RecipeEditDialogViewModel,
RecipeEditDialogView
>();
return builder;
}
}
}
using Asv.Avalonia;
using Avalonia.Markup.Xaml;
namespace AsvAvaloniaTest;
public class App : AsvApplication
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
}
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="AsvAvaloniaTest.App"
RequestedThemeVariant="Default">
<Application.Styles>
<StyleInclude Source="avares://Asv.Avalonia/Theme.axaml" />
</Application.Styles>
</Application>
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:avalonia="clr-namespace:Asv.Avalonia;assembly=Asv.Avalonia"
xmlns:local="clr-namespace:AsvAvaloniaTest"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="AsvAvaloniaTest.RecipePageView"
x:DataType="local:RecipePageViewModel">
<Grid Margin="0,30,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="300" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Left panel (Sidebar) -->
<Grid Grid.Column="0"
RowDefinitions="Auto, *, Auto"
Background="#252526">
<avalonia:SearchBoxView DockPanel.Dock="Right"
Margin="5"
DataContext="{Binding Search}" />
<!-- Recipe list -->
<ListBox Grid.Row="1"
ItemsSource="{Binding Recipes}"
SelectedItem="{Binding SelectedRecipe.Value}"
Background="Transparent"
SelectionMode="Single"
Padding="0,0,0,10">
<ListBox.ItemTemplate>
<DataTemplate>
<!-- Recipe description -->
<StackPanel Margin="12,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Title.ViewValue.Value}"
FontWeight="SemiBold"
Foreground="#DDD"
TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Category.ViewValue.Value}"
FontSize="11"
Foreground="#888" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Add recipe -->
<Border Grid.Row="2"
Padding="15"
BorderBrush="#333"
BorderThickness="0,1,0,0">
<Button HorizontalAlignment="Stretch"
Background="#3C3C3C"
HorizontalContentAlignment="Center"
Command="{Binding CreateRecipeCommand}"
CornerRadius="4">
<StackPanel Orientation="Horizontal"
Spacing="8">
<TextBlock Text="+"
FontWeight="Bold"
Foreground="#4CC2FF" />
<TextBlock Text="Add Recipe"
Foreground="#DDD" />
</StackPanel>
</Button>
</Border>
</Grid>
<GridSplitter Grid.Column="1"
Background="Black"
Width="5" />
<!-- Right panel (Editor) -->
<Border Grid.Column="2"
Background="#1E1E1E"
Padding="30">
<Grid>
<Grid RowDefinitions="Auto, *"
IsVisible="{Binding SelectedRecipe.Value, Converter={x:Static ObjectConverters.IsNotNull}}">
<!-- Recipe title -->
<StackPanel Grid.Row="0"
Margin="0,0,0,25">
<TextBlock Text="{Binding SelectedRecipe.Value.Title.ViewValue.Value}"
FontSize="28"
FontWeight="Bold"
Foreground="White" />
<StackPanel Orientation="Horizontal"
Spacing="10"
Margin="0,5,0,0">
<Border Background="#333"
CornerRadius="3"
Padding="6,2">
<TextBlock Text="{Binding SelectedRecipe.Value.Category.ViewValue.Value}"
FontSize="12"
Foreground="#AAA" />
</Border>
</StackPanel>
</StackPanel>
<!-- Instructions (Left) | Ingredients (Right) -->
<Grid Grid.Row="1"
ColumnDefinitions="*, 320">
<DockPanel Grid.Row="0"
Margin="0,0,30,0">
<TextBlock Text="INSTRUCTIONS"
DockPanel.Dock="Top"
FontSize="12"
FontWeight="Bold"
Foreground="#666"
Margin="0,0,0,10" />
<TextBox Text="{Binding SelectedRecipe.Value.Instruction.ViewValue.Value}"
AcceptsReturn="True"
TextWrapping="Wrap"
Background="#252526"
BorderThickness="0"
Padding="15"
CornerRadius="6"
VerticalAlignment="Stretch"
VerticalContentAlignment="Top"
Foreground="#CCC" />
</DockPanel>
<DockPanel Grid.Column="1"
Margin="0,0,30,0">
<TextBlock Text="INGREDIENTS"
DockPanel.Dock="Top"
FontSize="12"
FontWeight="Bold"
Foreground="#666"
Margin="0,0,0,10" />
<ScrollViewer>
<StackPanel Spacing="10">
<!-- Ingredient list -->
<ItemsControl ItemsSource="{Binding SelectedRecipe.Value.Ingredients}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#252526"
CornerRadius="4"
Margin="0,0,0,8"
Padding="10">
<Grid ColumnDefinitions="*, 10, 80, Auto">
<!-- Name -->
<TextBox Grid.Column="0"
Text="{Binding Name.ViewValue.Value}"
Background="Transparent"
BorderThickness="0"
Foreground="#DDD"
PlaceholderText="Name" />
<!-- Amount -->
<TextBox Grid.Column="2"
Text="{Binding Amount.ViewValue.Value}"
Background="#333"
BorderThickness="0"
CornerRadius="3"
Foreground="#AAA"
HorizontalContentAlignment="Center"
PlaceholderText="Qty" />
<!-- Delete -->
<Button Grid.Column="3"
Margin="5,0,0,0"
Background="Transparent"
Foreground="#666"
Command="{Binding DeleteIngredientCommand}"
Padding="5">
<TextBlock Text="×"
FontSize="18"
Margin="0,-3,0,0" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button HorizontalAlignment="Stretch"
Background="Transparent"
BorderBrush="#333"
BorderThickness="1"
CornerRadius="4"
Command="{Binding SelectedRecipe.Value.CreateIngredientCommand}"
Padding="10">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
Spacing="6">
<TextBlock Text="+"
Foreground="#555" />
<TextBlock Text="Add Ingredient"
Foreground="#666"
FontSize="12" />
</StackPanel>
</Button>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Grid>
</Grid>
</Grid>
</Border>
</Grid>
</UserControl>
using Avalonia.Controls;
namespace AsvAvaloniaTest;
public partial class RecipePageView : UserControl
{
public RecipePageView()
{
InitializeComponent();
}
}
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:AsvAvaloniaTest"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="AsvAvaloniaTest.RecipeEditDialogView"
x:DataType="local:RecipeEditDialogViewModel">
<StackPanel>
<TextBlock Text="Recipe Title" />
<TextBox Text="{CompiledBinding Title.Value}" />
<TextBlock Text="Category" />
<TextBox Text="{CompiledBinding Category.Value}" />
</StackPanel>
</UserControl>
using Avalonia.Controls;
namespace AsvAvaloniaTest;
public partial class RecipeEditDialogView : UserControl
{
public RecipeEditDialogView()
{
InitializeComponent();
}
}
using System.Threading;
using System.Threading.Tasks;
using Asv.Avalonia;
using Asv.Modeling;
namespace AsvAvaloniaTest;
public sealed class RemoveIngredientEvent(IViewModel source)
: AsyncRoutedEvent<IViewModel>(source, RoutingStrategy.Bubble);
public static class RemoveIngredientEventMixin
{
public static ValueTask RequestRemoveIngredient(
this IViewModel src,
CancellationToken cancel = default
)
{
return src.Rise(new RemoveIngredientEvent(src), cancel);
}
}
using Asv.Avalonia;
using Asv.Common;
using Asv.Modeling;
using R3;
namespace AsvAvaloniaTest;
public class HomePageRecipeExtension : IExtensionFor<IHomePage>
{
// A unique ID for the extension
public const string StaticId = "ext.home.recipe-book";
public string Id => StaticId;
public void Extend(IHomePage context, CompositeDisposable contextDispose)
{
var action = new ActionViewModel("open-recipe-book")
{
Header = "Recipe Book",
Description = "Open recipes",
Icon = RecipePageViewModel.PageIcon,
Command = new ReactiveCommand(_ =>
context.GoTo(new NavPath(new NavId(RecipePageViewModel.PageId)))
).DisposeItWith(contextDispose),
}.DisposeItWith(contextDispose);
context.Tools.Add(action);
}
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Asv.Avalonia;
using Asv.Common;
using Asv.Modeling;
using Microsoft.Extensions.Logging;
using R3;
namespace AsvAvaloniaTest;
public class IngredientViewModel : ViewModel
{
public const string BaseId = "ingredient";
private readonly ReactiveProperty<string?> _name;
private readonly ReactiveProperty<string?> _amount;
public IngredientViewModel(string id, string name, string amount, ILoggerFactory loggerFactory)
: base(BaseId, new NavArgs(new KeyValuePair<string, string?>("id", id)))
{
IngredientId = id;
_name = new ReactiveProperty<string?>(name).DisposeItWith(Disposable);
Name = new HistoricalStringProperty(
nameof(Name),
_name,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_amount = new ReactiveProperty<string?>(amount).DisposeItWith(Disposable);
Amount = new HistoricalStringProperty(
nameof(Amount),
_amount,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
DeleteIngredientCommand = new ReactiveCommand(RemoveIngredientAsync).DisposeItWith(Disposable);
}
public string IngredientId { get; }
public HistoricalStringProperty Name { get; }
public HistoricalStringProperty Amount { get; }
public ReactiveCommand DeleteIngredientCommand { get; }
public override IEnumerable<IViewModel> GetChildren()
{
yield return Name;
yield return Amount;
}
private async ValueTask RemoveIngredientAsync(Unit unit, CancellationToken cancellationToken)
{
await this.RequestRemoveIngredient(cancellationToken);
}
}
using System.Threading.Tasks;
using Asv.Avalonia;
namespace AsvAvaloniaTest;
public sealed class RecipeEditDialogPayload
{
public required string Title { get; init; }
public required string Category { get; init; }
}
public sealed class RecipeEditDialogPrefab
: IDialogPrefab<RecipeEditDialogPayload, RecipeEditDialogPayload?>
{
public async Task<RecipeEditDialogPayload?> ShowDialogAsync(RecipeEditDialogPayload dialogPayload)
{
using var vm = new RecipeEditDialogViewModel();
vm.Title.Value = dialogPayload.Title;
vm.Category.Value = dialogPayload.Category;
var dialogContent = new ContentDialog(vm)
{
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
};
}
}
using Asv.Avalonia;
using Asv.Common;
using R3;
namespace AsvAvaloniaTest;
public class RecipeEditDialogViewModel : DialogViewModelBase
{
public const string DialogId = $"{BaseId}.recipe_edit";
public RecipeEditDialogViewModel()
: base(DialogId)
{
Title = new BindableReactiveProperty<string?>().DisposeItWith(Disposable);
Category = new BindableReactiveProperty<string?>().DisposeItWith(Disposable);
}
public BindableReactiveProperty<string?> Title { get; }
public BindableReactiveProperty<string?> Category { get; }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Asv.Avalonia;
using Asv.Cfg;
using Asv.Common;
using Asv.Modeling;
using Material.Icons;
using Microsoft.Extensions.Logging;
using ObservableCollections;
using R3;
namespace AsvAvaloniaTest;
public class RecipePageViewModelLayoutConfig
{
public string? SelectedRecipe { get; set; }
}
public class RecipePageViewModelConfig
{
public IEnumerable<RecipeDto> Recipes { get; init; } = [];
}
public record RecipeDto
{
public required string Id { get; init; }
public required string Title { get; init; }
public string? Category { get; init; }
public string? Instruction { get; init; }
public IEnumerable<IngredientDto> Ingredients { get; init; } = [];
}
public record IngredientDto(string Id, string Name, string? Amount);
public class RecipePageViewModel : PageViewModel<RecipePageViewModel>
{
public const string PageId = "recipe_page";
public const MaterialIconKind PageIcon = MaterialIconKind.ViewGallery;
private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _configuration;
private readonly RecipeEditDialogPrefab _recipeEditDialog;
private readonly ObservableList<RecipeViewModel> _recipes = [];
private readonly ISynchronizedView<RecipeViewModel, RecipeViewModel> _view;
public RecipePageViewModel(IPageContext context, IConfiguration configuration, ILoggerFactory loggerFactory,
IDialogService dialogService, IExtensionService ext)
: base(PageId, context, loggerFactory, dialogService, ext)
{
_loggerFactory = loggerFactory;
Header = "Recipe Book";
Icon = PageIcon;
SelectedRecipe = new BindableReactiveProperty<RecipeViewModel?>().DisposeItWith(Disposable);
_recipes.SetRoutableParent(this).DisposeItWith(Disposable);
_recipes.DisposeRemovedItems().DisposeItWith(Disposable);
_recipeEditDialog = dialogService.GetDialogPrefab<RecipeEditDialogPrefab>();
CreateRecipeCommand = new ReactiveCommand(CreateRecipeAsync).DisposeItWith(Disposable);
_configuration = configuration;
var recipeConfig = configuration.Get<RecipePageViewModelConfig>();
Load(recipeConfig);
Search = new SearchBoxViewModel(
nameof(Search),
loggerFactory,
UpdateRecipeList,
TimeSpan.FromMilliseconds(500)
)
.SetRoutableParent(this)
.DisposeItWith(Disposable);
_view = _recipes.CreateView(x => x).DisposeItWith(Disposable);
Recipes = _view.ToNotifyCollectionChanged().DisposeItWith(Disposable);
}
public NotifyCollectionChangedSynchronizedViewList<RecipeViewModel> Recipes { get; }
public BindableReactiveProperty<RecipeViewModel?> SelectedRecipe { get; }
public ReactiveCommand CreateRecipeCommand { get; }
public SearchBoxViewModel Search { get; }
public override IEnumerable<IViewModel> GetChildren()
{
yield return Search;
foreach (var recipe in _recipes)
{
yield return recipe;
}
}
protected override void AfterLoadExtensions()
{
var layoutSink = Layout.Register<RecipePageViewModelLayoutConfig>(
nameof(RecipePageViewModel),
(config, _) =>
{
SelectedRecipe.Value = _recipes.FirstOrDefault(x => x.RecipeId == config.SelectedRecipe);
return ValueTask.CompletedTask;
}
);
layoutSink.DisposeItWith(Disposable);
SelectedRecipe
.Skip(1)
.SubscribeAwait(
(recipe, cancel) => layoutSink.SaveAsync(
new RecipePageViewModelLayoutConfig { SelectedRecipe = recipe?.RecipeId },
cancel
),
AwaitOperation.Drop
)
.DisposeItWith(Disposable);
Layout.LoadWhenRootAttached(RootTracking).DisposeItWith(Disposable);
}
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;
Save();
}
private Task UpdateRecipeList(string? text, IProgress<double> progress, CancellationToken cancel)
{
progress.Report(0);
if (string.IsNullOrWhiteSpace(text))
{
_view.ResetFilter();
return Task.CompletedTask;
}
_view.AttachFilter(x => x.Title.ViewValue.Value != null
&& x.Title.ViewValue.Value.Contains(text, StringComparison.InvariantCultureIgnoreCase));
progress.Report(1);
return Task.CompletedTask;
}
private void Load(RecipePageViewModelConfig recipeConfig)
{
var recipeVms = recipeConfig.Recipes.Select(r =>
new RecipeViewModel(
r.Id,
r.Title,
r.Category,
r.Instruction,
r.Ingredients.Select(i => new IngredientViewModel(i.Id, i.Name, i.Amount ?? string.Empty, _loggerFactory)),
_loggerFactory));
_recipes.AddRange(recipeVms);
}
private void Save()
{
var config = new RecipePageViewModelConfig
{
Recipes = _recipes.Select(r => new RecipeDto
{
Id = r.RecipeId,
Title = r.Title.ViewValue.Value ?? string.Empty,
Category = r.Category.ViewValue.Value,
Instruction = r.Instruction.ViewValue.Value,
Ingredients = r.Ingredients.Select(i =>
new IngredientDto(
i.IngredientId,
i.Name.ViewValue.Value ?? string.Empty,
i.Amount.ViewValue.Value ?? string.Empty
)
)
})
};
_configuration.Set(config);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Asv.Avalonia;
using Asv.Avalonia.InfoMessage;
using Asv.Common;
using Asv.Modeling;
using Material.Icons;
using Microsoft.Extensions.Logging;
using ObservableCollections;
using R3;
namespace AsvAvaloniaTest;
public class RecipeViewModel : ViewModel
{
public const string BaseId = "recipe";
private readonly ILoggerFactory _loggerFactory;
private readonly ReactiveProperty<string?> _title;
private readonly ReactiveProperty<string?> _category;
private readonly ReactiveProperty<string?> _instruction;
private readonly ObservableList<IngredientViewModel> _ingredients = [];
public RecipeViewModel(string id, string title, string? category, string? instruction,
IEnumerable<IngredientViewModel> ingredients,
ILoggerFactory loggerFactory)
: base(BaseId, new NavArgs(new KeyValuePair<string, string?>("id", id)))
{
_loggerFactory = loggerFactory;
RecipeId = id;
_title = new ReactiveProperty<string?>(title).DisposeItWith(Disposable);
Title = new HistoricalStringProperty(
nameof(Title),
_title,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_category = new ReactiveProperty<string?>(category).DisposeItWith(Disposable);
Category = new HistoricalStringProperty(
nameof(Category),
_category,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_instruction = new ReactiveProperty<string?>(instruction).DisposeItWith(Disposable);
Instruction = new HistoricalStringProperty(
nameof(Instruction),
_instruction,
loggerFactory
).SetRoutableParent(this)
.DisposeItWith(Disposable);
_ingredients.AddRange(ingredients);
_ingredients.SetRoutableParent(this).DisposeItWith(Disposable);
_ingredients.DisposeRemovedItems().DisposeItWith(Disposable);
Ingredients = _ingredients.ToNotifyCollectionChangedSlim(SynchronizationContextCollectionEventDispatcher.Current)
.DisposeItWith(Disposable);
CreateIngredientCommand = new ReactiveCommand(AddIngredientAsync).DisposeItWith(Disposable);
Events.Catch(InternalCatchEvent).DisposeItWith(Disposable);
}
public string RecipeId { get; }
public HistoricalStringProperty Title { get; }
public HistoricalStringProperty Category { get; }
public HistoricalStringProperty Instruction { get; }
public NotifyCollectionChangedSynchronizedViewList<IngredientViewModel> Ingredients { get; }
public ReactiveCommand CreateIngredientCommand { get; }
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.RiseShellInfoMessage(msg, cancellationToken);
}
public override IEnumerable<IViewModel> GetChildren()
{
foreach (var ingredient in _ingredients)
{
yield return ingredient;
}
yield return Title;
yield return Category;
yield return Instruction;
}
private ValueTask InternalCatchEvent(IViewModel src, AsyncRoutedEvent<IViewModel> e, CancellationToken cancel)
{
if (e is not RemoveIngredientEvent)
{
return default;
}
var vm = _ingredients.First(i => i.Id == e.Sender.Id);
_ingredients.Remove(vm);
return default;
}
}
Last modified: 24 July 2026