Chinook.DynamicMvvm.Abstractions
1.0.0
See the version list below for details.
dotnet add package Chinook.DynamicMvvm.Abstractions --version 1.0.0
NuGet\Install-Package Chinook.DynamicMvvm.Abstractions -Version 1.0.0
<PackageReference Include="Chinook.DynamicMvvm.Abstractions" Version="1.0.0" />
paket add Chinook.DynamicMvvm.Abstractions --version 1.0.0
#r "nuget: Chinook.DynamicMvvm.Abstractions, 1.0.0"
// Install Chinook.DynamicMvvm.Abstractions as a Cake Addin #addin nuget:?package=Chinook.DynamicMvvm.Abstractions&version=1.0.0 // Install Chinook.DynamicMvvm.Abstractions as a Cake Tool #tool nuget:?package=Chinook.DynamicMvvm.Abstractions&version=1.0.0
Chinook.DynamicMvvm
The Chinook.DynamicMvvm
packages assists in MVVM (Model - View - ViewModel) development.
Cornerstones
- Highly Extensible
- Everything is interface-based to easily allow more implementations.
- A single framework can't cover everything. Our architecture is designed in a way that allows you to integrate your favorites tools easily.
- Declarative Syntax
- We aim to understand the behavior of a property by glancing at its declaration.
Getting Started
Add the
Chinook.DynamicMvvm
nuget package to your project.Create your first ViewModel. Here's one that covers the basics.
public class MainPageViewModel : ViewModelBase { public string Content { get => this.Get(initialValue: string.Empty); set => this.Set(value); } public IDynamicCommand Submit => this.GetCommand(() => { Result = Content; }); public string Result { get => this.Get(initialValue: string.Empty); private set => this.Set(value); } }
π‘ Want to go fast? We recommend installing the Chinook Snippets Visual Studio Extension to benefit from code snippets. All our snippets start with
"ck"
(for "Chinook") and they will help you write those properties and commands extra fast.Set this
MainPageViewModel
as theDataContext
of yourMainPage
inMainPage.xaml.cs
.public MainPage() { this.InitializeComponent(); DataContext = new MainPageViewModel(); }
Here is some xaml for that
MainPage.xaml
that demonstrates the basics.<Page x:Class="ChinookSample.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <StackPanel> <TextBox Text="{Binding Content, Mode=TwoWay}" /> <Button Content="Submit" Command="{Binding Submit}" /> <TextBlock Text="{Binding Result}" /> </StackPanel> </Page>
Configure a
System.IServiceProvider
containing the following:IDynamicCommandBuilderFactory
IDynamicPropertyFactory
Here is a simple code sample that does that using
Microsoft.Extensions.DependencyInjection
andMicrosoft.Extensions.Hosting
.var serviceProvider = new HostBuilder() .ConfigureServices(serviceCollection => serviceCollection .AddSingleton<IDynamicCommandBuilderFactory, DynamicCommandBuilderFactory>() .AddSingleton<IDynamicPropertyFactory, DynamicPropertyFactory>() ) .Build() .Services;
Set the
IServiceProvider
intoViewModelBase.DefaultServiceProvider
in the startup of your application.ViewModelBase.DefaultServiceProvider = serviceProvider;
π‘ It's also possible to avoid using this public static provider and pass it via the constructor of
ViewModelBase
.You're all set. You can start your app!
Features
The previous setup is pretty basic. Let's see what else we can do!
Dispatcher
Set an IDispatcher
to allow setting properties from any thread.
The IDispatcher
ensures the INotifyPropertyChanged.PropertyChanged
event is raised on the main thread.
This is optional, but you'll likely need it.
public MainPage()
{
this.InitializeComponent();
DataContext = new MainPageViewModel()
{
Dispatcher = new CoreDispatcherDispatcher(this)
};
}
Create simple properties
Using IViewModel.Get
, you can declare ViewModel properties that will raise the INotifyPropertyChanged.PropertyChanged
event of the ViewModel when set.
Under the hood, an IDynamicProperty
is lazy-initialized.
π¬
IDynamicProperty
simply represents a property of a ViewModel. It has a name, a value, and an event to notify that the property's value changed. Having this interface is great because it allows the creation of custom implementations with various behaviors. You'll see that with the next sections of this document.
public string Content
{
get => this.Get(initialValue: string.Empty);
set => this.Set(value);
}
π‘ If you use Chinook Snippets, you can quickly generate a property from value using the snippets
"ckpropv"
(chinook property from value) or"ckpropvg"
(chinook property from value get-only).
π We like to call "dynamic properties" the properties of a ViewModel that are backed with a
IDynamicProperty
. You can still used regular properties in your ViewModels, but they will not raise thePropertyChanged
event automatically when they change.public string Title => "Hello"; // Regular property public string Subtitle { get; } // Regular property public long Counter => this.GetFromObservable(ObserveTimer()); // Dynamic Property public bool IsFavorite // Dynamic property { get => this.Get(initialValue: false); set => this.Set(value); }
You should prefer regular properties over dynamic properties for data that never changes, simply because dynamic properties allocate more memory.
Create properties from IObservable<T>
If you're familiar with System.Reactive, you'll probably like this.
Using IViewModel.GetFromObservable
, you can declare a ViewModel property from an IObservable<T>
.
The property automatically update itself when the observable pushes a new value.
using System.Reactive.Linq;
// (...)
public long Counter => this.GetFromObservable(Observable.Timer(
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(1))
);
π‘ If you use Chinook Snippets, you can quickly generate a property from observable using the snippets
"ckpropo"
(chinook property from observable) or"ckpropog"
(chinook property from observable get-only).
Create properties from Task<T>
Using IViewModel.GetFromTask
, you can create a property that updates itself based on a Task<T>
result.
// This property is initialized with the value 10, but changes to 100 after 1 second.
public int Number => this.GetFromTask(async ct =>
{
await Task.Delay(1000, ct);
return 100;
}, initialValue: 10);
π‘ If you use Chinook Snippets, you can quickly generate a property from task using the snippets
"ckpropt"
(chinook property from task) or"ckproptg"
(chinook property from task get-only).
Decide whether you want a property setter
This could seem obvious, but any IDynamicProperty
-backed property can be readonly by simply omitting the property setter.
public long Counter1 => this.GetFromObservable(ObserveTimer());
public long Counter2
{
get => this.GetFromObservable(ObserveTimer());
set => this.Set(value);
}
public long Counter3
{
get => this.GetFromObservable(ObserveTimer());
private set => this.Set(value);
}
private IObservable<long> ObserveTimer() => Observable.Timer(
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(1));
private void SomeMethod()
{
Counter1 = 0; // Doesn't build
Counter2 = 0; // Builds
Counter3 = 0; // Builds
}
In this code, all properties are updated from an observable. However,
Counter1
can't be set manually.Counter2
can be set manually from anywhere (includingTwoWay
bindings).Counter3
can be set manually only from the ViewModel (which excludesTwoWay
bindings).
π‘ If you use Chinook Snippets, you can quickly generate a get-only property using the
"ckprop"
snippets ending with"g"
(for get-only).
Access the underlying IDynamicProperty
instance
You can access the backing IDynamicProperty<T>
instance of any property from any ViewModel by using IViewModel.GetProperty()
.
public MainPageViewModel()
{
IDynamicProperty<string> contentProperty;
contentProperty = this.GetProperty(vm => vm.Content);
// or
contentProperty = this.GetProperty<string>(nameof(Content));
}
public string Content
{
get => this.Get(initialValue: string.Empty);
set => this.Set(value);
}
You can then interact with the property object itself.
contentProperty.Value = "Hello";
// This sets the property value. It also raises the PropertyChanged event on the ViewModel.
contentProperty.ValueChanged += prop => Console.WriteLine($"Property {prop.Name} changed to {prop.Value}.");
// This allows you to easily observe changes to a property value. Note that prop.Value is strongly type as a string here.
π‘ With the
IDynamicProperty
instance, even a get-only property can be set manually. Use this knowledge responsively.
Observe a property value using IObservable
If you are used to System.Reactive, you can install the Chinook.DynamicMvvm.Reactive
package to benefit from some more extensions methods.
IObservable<string> observable;
observable = contentProperty.Observe(); // Gets an IObservable that yields when the value changes.
observable = contentProperty.GetAndObserve(); // Gets an IObservable that yields when the value changes and starts with the current value.
Create commands from Action
or Action<T>
Using IViewModel.GetCommand
, you can create a command using an Action
, or Action<T>
when you want a command parameter.
public IDynamicCommand SayHi => this.GetCommand(() =>
{
Console.WriteLine("Hi");
});
public IDynamicCommand SaySomething => this.GetCommand<string>(parameter =>
{
Console.WriteLine(parameter);
});
π‘ If you use Chinook Snippets, you can quickly generate a command from
Action
using the snippets"ckcmda"
(chinook command from action) or"ckcmdap"
(chinook command from action with parameter).
Create commands from an async method
Using IViewModel.GetCommand
, you can create a async
command using a Func<Task>
, or Func<T, Task>
when you want a command parameter.
public IDynamicCommand WaitASecond => this.GetCommandFromTask(async ct =>
{
await Task.Delay(1000, ct);
});
public IDynamicCommand Wait => this.GetCommandFromTask<int>(async (ct, parameter) =>
{
await Task.Delay(TimeSpan.FromSeconds(parameter), ct);
});
π‘ If you use Chinook Snippets, you can quickly generate a command from
Task
using the snippets"ckcmdt"
(chinook command from task) or"ckcmdtp"
(chinook command from task with parameter).
The provided CancellationToken ct
is cancelled when the IDynamicCommand
is disposed.
The command itself is disposed when the ViewModel is disposed.
You decide when the ViewModel gets disposed.
π‘ Check out Chinook.Navigation if you want a ViewModel-based navigation system that automatically deals with disposing ViewModels.
Customize command behavior
The IDynamicCommand
declarations come with an optional builder.
You can use this builder to customize the behavior of any command.
π¬The command implementation is done using a strategy pattern (very similarly to the HTTP Message Handlers). The builder simply accumulates strategies and chains them together when the command is built.
You can create a base configuration for all your commands at the factory level.
.AddSingleton<IDynamicCommandBuilderFactory>(s =>
new DynamicCommandBuilderFactory(builder => builder
.WithLogs(s.GetRequiredService<ILogger<IDynamicCommand>>())
.OnBackgroundThread()
))
You can add more configuration at the command declaration level. The builders are additive, meaning that the configuration at the command declaration level is applied after the one at the factory level.
public IDynamicCommand Submit => this.GetCommand(() =>
{
Result = Content;
}, builder => builder
.SkipWhileExecuting()
);
π¬ For the previous code the strategy chain looks like this:
- LoggerCommandStrategy (factory level) - BackgroundCommandStrategy (factory level) - SkipWhileExecutingCommandStrategy (command declaration level) - ActionCommandStrategy, which actually executes the method (command declaration level)
The strategy execution starts from the top and goes down the chain and then comes back up the chain for any subsequent processing. This means that strategies allow adding behavior both before and after the actual command execution.
Supported strategies
- BackgroundCommandStrategy : Executes the command on a background thread.
- CanExecuteCommandStrategy : Attaches the
CanExecute
to the value of aIDynamicProperty
. - ErrorHandlerCommandStrategy : Catches any exception during the execution and delegates it to an error handler.
- LogCommandStrategy : Adds logs to the command execution.
- LockCommandStrategy : Locks the command execution.
- CancelPreviousCommandStrategy : Cancels the previous command execution when executing the command.
- SkipWhileExecutingCommandStrategy : Skips executions if the command is already executing.
- DisableWhileExecutingCommandStrategy : Disables the command when it's executing.
- RaiseCanExecuteOnDispatcherCommandStrategy : Raises the
CanExecuteChanged
on the ViewModel'sIDispatcher
.
Observe whether a command is executing
IDynamicCommand
adds an IsExecuting
property and an IsExecutingChanged
event to the classic System.Windows.Input.ICommand
.
IDynamicCommand
also implements INotifyPropertyChanged
, meaning that you can do a XAML binding on IsExecuting
.
π‘ This can be usefull if you want to add a loading indicator in your button's
ControlTemplate
.
Add child ViewModels
Using IViewModel.GetChild
, you can declare an inner ViewModel in an existing ViewModel.
This is great to extract repeating code or simply to separate concerns.
public SettingsViewModel Settings => this.GetChild<SettingsViewModel>();
β When creating child ViewModels, it's important to use the
GetChild
,AttachChild
, orAttachOrReplaceChild
methods to ensure linking theIDispatcher
of the child to its parent.Consider the following code.
public SettingsViewModel Settings { get; } = new SettingsViewModel();
This might seem to work at first, but know that the
IDispatcher
ofSettings
is not set. Therefore,PropertyChanged
events might not be raised on the correct thread, which could result in errors.
ItemViewModels
Child ViewModels are also very useful when using what we like to call ItemViewModels, meaning an item from a list. This recipe is quite powerful when you want to change a property on a list item without updating the whole list itself.
Here's an example where ItemViewModel.IsFavorite
can be manipulated directly and any XAML binding to it will update as expected.
public class ItemViewModel : ViewModelBase
{
public string Title { get; init; }
public bool IsFavorite
{
get => this.Get(initialValue: false);
set => this.Set(value);
}
}
// (...)
public MainPageViewModel()
{
IEnumerable<string> someSource = Enumerable
.Range(0, 10)
.Select(i => i.ToString());
Items = someSource
.Select(title => this.AttachChild(new ItemViewModel { Title = title }, name: title))
.ToArray();
}
public ItemViewModel[] Items { get; }
π‘ ItemViewModels are great when individual list items change overtime. However, when your list items don't update themselves, you should probably avoid creating ItemViewModels.
Resolve services from a ViewModel
Using IViewModel.GetService
, you can easily get a service from the service provider that you set.
Note that the IServiceProvider
is also directly exposed via IViewModel.ServiceProvider
.
var logger = this.GetService<ILogger<MainPageViewModel>>();
Add disposables to a ViewModel
Using IViewModel.AddDisposable
, you can add any IDisposable
object to a IViewModel
.
When the ViewModel is disposed, all added disposables are disposed as well.
You can also get or remove previously added disposables using IViewModel.TryGetDisposable
and IViewModel.RemoveDisposable
.
π‘ Adding disposables can be useful when subscribing to observables or events. You can easily setup the unsubscription to happen when the ViewModel is disposed.
Check out Chinook.Navigation if you want a ViewModel-based navigation system that automatically deals with disposing ViewModels.
π¬
IViewModel
can be seen as a dictionary ofIDisposable
objects.IDynamicProperty
andIDynamicCommand
both implementIDisposable
, so that's how they're actually stored. It's the same thing for child ViewModels. This architecture contributes to the great extensibility of this library. You can see Chinook.DataLoader as a demonstration of extensibility.
Add errors
IViewModel
implements INotifyDataErrorInfo
.
You can use IViewModel.SetErrors
and IViewModel.ClearErrors
to manipulate the error info.
Add validation using FluentValidations
You can install Chinook.DynamicMvvm.FluentValidation
package to gain access to helpful extension methods.
The first step to add validation is to declare a validator on your ViewModel.
public class MainPageValidator : AbstractValidator<MainPageViewModel>
{
public MainPageValidator()
{
RuleFor(vm => vm.Content).NotEmpty();
}
}
You can add all validators of your app to the service provider by using this line in your configuration.
serviceCollection.AddValidatorsFromAssemblyContaining(typeof(App), ServiceLifetime.Singleton)
For any dynamic property, you can use IViewModel.AddValidation
to automatically run the validation rules when a property's value changes.
public MainPageViewModel()
{
this.AddValidation(this.GetProperty(vm => vm.Content));
}
You can also run the validation manually.
public IDynamicCommand Submit => this.GetCommandFromTask(async ct =>
{
var result = await this.Validate(ct);
if (result.IsValid)
{
Result = Content;
}
});
Changelog
Please consult the CHANGELOG for more information about version history.
License
This project is licensed under the Apache 2.0 license - see the LICENSE file for details.
Contributing
Please read CONTRIBUTING.md for details on the process for contributing to this project.
Be mindful of our Code of Conduct.
Contributors
<table> <tr> <td align="center"><a href="https://github.com/jeanplevesque"><img src="https://avatars3.githubusercontent.com/u/39710855?v=4" width="100px;" alt=""/><br /><sub><b>Jean-Philippe LΓ©vesque</b></sub></a><br /><a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=jeanplevesque" title="Code">π»</a> <a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=jeanplevesque" title="Tests">β οΈ</a></td> <td align="center"><a href="https://github.com/jeremiethibeault"><img src="https://avatars3.githubusercontent.com/u/5444226?v=4" width="100px;" alt=""/><br /><sub><b>JΓ©rΓ©mie Thibeault</b></sub></a><br /><a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=jeremiethibeault" title="Tests">β οΈ</a> <a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=jeremiethibeault" title="Code">π»</a></td> <td align="center"><a href="https://github.com/MatFillion"><img src="https://avatars0.githubusercontent.com/u/7029537?v=4" width="100px;" alt=""/><br /><sub><b>Mathieu Fillion</b></sub></a><br /><a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=MatFillion" title="Code">π»</a></td> <td align="center"><a href="https://github.com/jcantin-nventive"><img src="https://avatars0.githubusercontent.com/u/43351943?v=4" width="100px;" alt=""/><br /><sub><b>Julie Cantin</b></sub></a><br /><a href="https://github.com/nventive/Chinook.DynamicMvvm/commits?author=jcantin-nventive" title="doc">π</a></td> </tr> </table>
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. |
.NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
.NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
.NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
MonoAndroid | monoandroid was computed. |
MonoMac | monomac was computed. |
MonoTouch | monotouch was computed. |
Tizen | tizen40 was computed. tizen60 was computed. |
Xamarin.iOS | xamarinios was computed. |
Xamarin.Mac | xamarinmac was computed. |
Xamarin.TVOS | xamarintvos was computed. |
Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
NuGet packages (7)
Showing the top 5 NuGet packages that depend on Chinook.DynamicMvvm.Abstractions:
Package | Downloads |
---|---|
Chinook.DynamicMvvm
Chinook.DynamicMvvm is a collection of extensible MVVM libraries for declarative ViewModels. |
|
Chinook.DataLoader.DynamicMvvm
Recipes for loading asynchronous data in a MVVM context. |
|
Chinook.DynamicMvvm.Reactive
Chinook.DynamicMvvm is a collection of extensible MVVM libraries for declarative ViewModels. |
|
Chinook.DynamicMvvm.FluentValidation
Chinook.DynamicMvvm is a collection of extensible MVVM libraries for declarative ViewModels. |
|
Chinook.DynamicMvvm.Uno
Chinook.DynamicMvvm is a collection of extensible MVVM libraries for declarative ViewModels. |
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last updated |
---|---|---|
2.1.1 | 1,698 | 6/18/2024 |
2.1.0 | 2,161 | 2/9/2024 |
2.0.1 | 9,491 | 1/10/2024 |
2.0.0 | 2,167 | 12/22/2023 |
2.0.0-feature.Uno5Update.6 | 163 | 12/6/2023 |
2.0.0-feature.Uno5Update.4 | 1,743 | 11/28/2023 |
1.4.3 | 1,862 | 6/19/2024 |
1.4.2 | 8,353 | 12/18/2023 |
1.4.1 | 1,549 | 12/15/2023 |
1.4.0 | 1,491 | 12/15/2023 |
1.3.0 | 1,566 | 12/14/2023 |
1.2.2 | 1,757 | 12/11/2023 |
1.2.2-feature.Uno5Update.2 | 67 | 11/3/2023 |
1.2.1 | 3,291 | 10/17/2023 |
1.2.0 | 8,298 | 7/3/2023 |
1.1.5 | 12,648 | 4/4/2023 |
1.1.4 | 5,038 | 3/10/2023 |
1.1.3 | 42,593 | 12/2/2022 |
1.1.2 | 31,317 | 10/21/2022 |
1.1.1 | 3,951 | 10/12/2022 |
1.1.0 | 4,548 | 10/3/2022 |
1.1.0-feature.dotnet6.7 | 142 | 9/21/2022 |
1.0.0 | 102,265 | 8/18/2022 |
0.13.1 | 5,270 | 8/18/2022 |
0.13.0 | 3,367 | 8/17/2022 |
0.11.0-dev.126 | 139 | 8/16/2022 |
0.11.0-dev.124 | 125 | 8/16/2022 |
0.11.0-dev.122 | 133 | 8/16/2022 |
0.10.0-dev.120 | 306 | 8/15/2022 |
0.10.0-dev.118 | 147 | 8/12/2022 |
0.10.0-dev.116 | 157 | 8/11/2022 |
0.9.0-dev.114 | 14,043 | 8/8/2022 |
0.9.0-dev.112 | 301 | 8/4/2022 |
0.9.0-dev.110 | 133 | 8/4/2022 |
0.9.0-dev.108 | 8,582 | 6/13/2022 |
0.9.0-dev.106 | 8,002 | 5/20/2022 |
0.9.0-dev.104 | 156 | 5/19/2022 |
0.9.0-dev.102 | 174 | 5/16/2022 |
0.9.0-dev.100 | 179 | 5/16/2022 |
0.9.0-dev.98 | 163 | 5/16/2022 |
0.9.0-dev.96 | 153 | 5/16/2022 |
0.9.0-dev.93 | 152 | 5/13/2022 |
0.9.0-dev.91 | 154 | 5/12/2022 |
0.9.0-dev.89 | 168 | 5/9/2022 |
0.9.0-dev.87 | 178 | 5/3/2022 |
0.9.0-dev.85 | 31,071 | 3/29/2022 |
0.8.0-feature.uno-ui-4.77 | 167 | 3/14/2022 |
0.8.0-dev.82 | 5,497 | 3/15/2022 |
0.8.0-dev.80 | 3,873 | 3/14/2022 |
0.7.0-feature.uno-ui-4.76 | 3,466 | 12/21/2021 |
0.7.0-dev.77 | 5,697 | 3/14/2022 |
0.7.0-dev.75 | 21,672 | 11/18/2021 |
0.7.0-dev.73 | 1,080 | 11/1/2021 |
0.7.0-dev.71 | 665 | 10/25/2021 |
0.7.0-dev.69 | 8,525 | 10/19/2021 |
0.7.0-dev.67 | 42,795 | 8/30/2021 |
0.6.0-dev.65 | 205 | 8/20/2021 |
0.6.0-dev.63 | 2,008 | 7/31/2021 |
0.5.0-dev.61 | 589 | 7/26/2021 |
0.5.0-dev.59 | 2,284 | 7/26/2021 |
0.4.0-dev.57 | 2,954 | 7/13/2021 |
0.4.0-dev.54 | 1,555 | 7/5/2021 |
0.4.0-dev.52 | 12,451 | 4/28/2021 |
0.4.0-dev.48 | 185 | 4/27/2021 |
0.4.0-dev.46 | 196 | 4/22/2021 |
0.4.0-dev.41 | 24,629 | 3/17/2021 |
0.4.0-dev.39 | 628 | 3/16/2021 |
0.3.0-dev.34 | 2,541 | 12/17/2020 |
0.3.0-dev.32 | 3,176 | 11/2/2020 |
0.3.0-dev.30 | 249 | 10/28/2020 |
0.3.0-dev.28 | 41,078 | 10/27/2020 |
0.2.0-dev.26 | 282 | 10/13/2020 |
0.2.0-dev.24 | 6,534 | 9/8/2020 |
0.2.0-dev.22 | 2,641 | 8/13/2020 |
0.2.0-dev.19 | 2,734 | 6/29/2020 |
0.2.0-dev.17 | 480 | 6/26/2020 |