Merge Common to Desktop

This commit is contained in:
Jöran Malek 2024-02-16 02:23:58 +01:00
parent 26915defe1
commit e9c6e14965
40 changed files with 447 additions and 282 deletions

View file

@ -0,0 +1,11 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="InkForge.Desktop.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>

View file

@ -0,0 +1,75 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using InkForge.Desktop.ViewModels;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using ReactiveUI;
using Splat;
using Splat.Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop;
public partial class App : Application
{
public static readonly StyledProperty<IServiceProvider> ServiceProviderProperty
= AvaloniaProperty.Register<App, IServiceProvider>(
name: nameof(ServiceProvider),
coerce: OnServiceProviderChanged);
public IServiceProvider ServiceProvider => GetValue(ServiceProviderProperty);
public static void Configure(IServiceCollection services, IConfigurationManager configuration)
{
configuration.SetBasePath(AppContext.BaseDirectory);
configuration.AddJsonFile(
new ManifestEmbeddedFileProvider(typeof(App).Assembly),
"Properties/appsettings.json", false, false);
configuration.AddJsonFile(
Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolderOption.DoNotVerify),
"InkForge",
"usersettings.json"), true, true);
configuration.AddJsonFile("appsettings.json", true, true);
services.UseMicrosoftDependencyResolver();
Locator.CurrentMutable.InitializeSplat();
Locator.CurrentMutable.InitializeReactiveUI();
services.AddInkForge();
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var viewModel = ActivatorUtilities.GetServiceOrCreateInstance<AppViewModel>(ServiceProvider);
var view = ViewLocator.Current.ResolveView(viewModel)!;
view.ViewModel = viewModel;
_ = ApplicationLifetime switch
{
IClassicDesktopStyleApplicationLifetime desktop => desktop.MainWindow = view as Window,
ISingleViewApplicationLifetime singleView => singleView.MainView = view as Control,
_ => throw new NotSupportedException(),
};
base.OnFrameworkInitializationCompleted();
}
private static IServiceProvider OnServiceProviderChanged(AvaloniaObject @object, IServiceProvider provider)
{
provider.UseMicrosoftDependencyResolver();
return provider;
}
}

View file

@ -0,0 +1,68 @@
using InkForge.Data;
using InkForge.Desktop.Models;
using InkForge.Desktop.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
namespace InkForge.Desktop.Controllers;
public class WorkspaceController : ReactiveObject
{
private readonly IServiceProvider _serviceProvider;
private Workspace _workspace;
public Workspace Workspace
{
get => _workspace;
set => this.RaiseAndSetIfChanged(ref _workspace, value);
}
public WorkspaceController(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task OpenWorkspace(string path, bool createFile = false)
{
if (await CreateWorkspace(path, createFile) is { } workspace)
{
Workspace = workspace;
}
}
private async ValueTask<Workspace?> CreateWorkspace(string path, bool createFile)
{
FileInfo file = new(path);
if (!(createFile || file.Exists))
{
return null;
}
file.Directory!.Create();
var scope = _serviceProvider.CreateScope();
var scopeServiceProvider = scope.ServiceProvider;
var context = scope.ServiceProvider.GetRequiredService<WorkspaceContext>();
context.DbPath = path;
var db = scopeServiceProvider.GetRequiredService<NoteDbContext>();
await using (var transaction = await db.Database.BeginTransactionAsync().ConfigureAwait(false))
{
try
{
await db.Database.MigrateAsync().ConfigureAwait(false);
}
catch
{
await transaction.RollbackAsync().ConfigureAwait(false);
return null;
}
await transaction.CommitAsync().ConfigureAwait(false);
}
return new(scope);
}
}

View file

@ -0,0 +1,27 @@
using InkForge.Desktop.Services;
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using SmartFormat;
namespace InkForge.Desktop.Data;
public class NoteDbContextFactory(WorkspaceContext context, IConfiguration configuration) : IDbContextFactory<NoteDbContext>
{
private string? _connectionString;
public NoteDbContext CreateDbContext()
{
_connectionString ??= Smart.Format(configuration.GetConnectionString("DefaultConnection")!, new
{
WorkspaceFile = context.DbPath
});
DbContextOptionsBuilder<NoteDbContext> builder = new();
builder.UseSqlite(_connectionString, o => o.MigrationsAssembly("InkForge.Sqlite"));
return new NoteDbContext(builder.Options);
}
}

View file

@ -6,16 +6,32 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>InkForge</AssemblyName>
<RootNamespace>InkForge.Desktop</RootNamespace>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.ReactiveUI" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Dock.Avalonia" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="SmartFormat" />
<PackageReference Include="Splat.Microsoft.Extensions.DependencyInjection" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InkForge.Common\InkForge.Common.csproj" />
<ProjectReference Include="..\..\shared\InkForge.Data\InkForge.Data.csproj" />
<ProjectReference Include="..\..\shared\migrations\InkForge.Sqlite\InkForge.Sqlite.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Properties\appsettings.json" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,33 @@
using InkForge.Desktop.Controllers;
using InkForge.Desktop.Data;
using InkForge.Desktop.Services;
using InkForge.Desktop.ViewModels;
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
using ReactiveUI;
using Splat;
namespace Microsoft.Extensions.DependencyInjection;
public static class InkForgeServiceCollections
{
public static IServiceCollection AddInkForge(this IServiceCollection services)
{
services.AddHttpClient();
services.AddScoped<IDbContextFactory<NoteDbContext>, NoteDbContextFactory>();
services.AddScoped(s => s.GetRequiredService<IDbContextFactory<NoteDbContext>>().CreateDbContext());
services.AddScoped<WorkspaceContext>();
services.AddSingleton<LandingViewModel>();
services.AddSingleton<WorkspaceController>();
Locator.CurrentMutable.RegisterViewsForViewModels(typeof(InkForgeServiceCollections).Assembly);
return services;
}
}

View file

@ -0,0 +1,22 @@
namespace Microsoft.Extensions.DependencyInjection
{
public static class TypeFactory<TFactory, T>
where TFactory : struct, IObjectParameters<TFactory>
{
private static ObjectFactory<T>? s_objectFactory;
public static T Create(in TFactory factory, IServiceProvider serviceProvider)
{
s_objectFactory ??= ActivatorUtilities.CreateFactory<T>(TFactory.Types);
return s_objectFactory(serviceProvider, (object[])factory);
}
}
public interface IObjectParameters<T>
where T : struct, IObjectParameters<T>
{
abstract static Type[] Types { get; }
abstract static implicit operator object[](in T self);
}
}

View file

@ -0,0 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.Models;
public class Workspace(IServiceScope scope)
{
public IServiceProvider ServiceProvider => scope.ServiceProvider;
}

View file

@ -3,29 +3,18 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using InkForge.Common;
using InkForge.Common.ViewModels;
using InkForge.Desktop.Views;
using InkForge.Desktop;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
static class Program
{
private static readonly ConfigurationManager Configuration = new();
[STAThread]
public static void Main(string[] args)
=> BuildAvaloniaApp()
.UseMicrosoftDependencyInjection()
.StartWithClassicDesktopLifetime(args, WithMicrosoftDependencyInjection);
private static void WithMicrosoftDependencyInjection(IClassicDesktopStyleApplicationLifetime lifetime)
{
Configuration.AddCommandLine(lifetime.Args ?? []);
}
.UseMicrosoftDependencyInjection(out var configuration)
.StartWithClassicDesktopLifetime(args, configuration.WithMicrosoftDependencyInjection);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
@ -34,12 +23,7 @@ static class Program
.WithInterFont()
.LogToTrace();
private static void ConfigureServices(IServiceCollection services)
{
services.AddTransient<IViewFor<AppViewModel>, MainWindow>();
}
private static void OnSetup(this IServiceCollection services, AppBuilder appBuilder)
private static void SetupApp(this IServiceCollection services, AppBuilder appBuilder)
{
var dispatcher = Dispatcher.UIThread;
var app = appBuilder.Instance!;
@ -49,19 +33,52 @@ static class Program
.AddSingleton(app.PlatformSettings!)
.AddSingleton(dispatcher);
ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
app.SetValue(App.ServiceProviderProperty, serviceProvider);
dispatcher.ShutdownFinished += (_, _) => serviceProvider.Dispose();
_ = new ServiceProviderDisposer(serviceProvider, dispatcher);
}
private static AppBuilder UseMicrosoftDependencyInjection(this AppBuilder builder)
private static AppBuilder UseMicrosoftDependencyInjection(this AppBuilder builder, out ConfigurationManager configuration)
{
configuration = new();
ServiceCollection services = [];
App.Configure(services, Configuration);
services.AddSingleton<IConfiguration>(configuration);
App.Configure(services, configuration);
builder.AfterSetup(services.OnSetup);
builder.AfterSetup(services.SetupApp);
return builder;
}
private static void WithMicrosoftDependencyInjection(this ConfigurationManager configuration, IClassicDesktopStyleApplicationLifetime lifetime)
{
configuration.AddCommandLine(lifetime.Args ?? []);
}
private class ServiceProviderDisposer
{
private readonly ServiceProvider _serviceProvider;
private readonly TaskCompletionSource<ValueTask> _shutdownTask = new();
public ServiceProviderDisposer(ServiceProvider serviceProvider, Dispatcher dispatcher)
{
dispatcher.ShutdownStarted += OnShutdownStarted;
dispatcher.ShutdownFinished += OnShutdownFinished;
_serviceProvider = serviceProvider;
}
private void OnShutdownFinished(object? sender, EventArgs e)
{
if (_shutdownTask.Task.Result is { IsCompleted: false } disposeTask)
{
disposeTask.GetAwaiter().GetResult();
}
}
private void OnShutdownStarted(object? sender, EventArgs e)
{
#pragma warning disable CA2012 // This will only ever be awaited once in ShutdownFinished
_shutdownTask.SetResult(_serviceProvider.DisposeAsync());
#pragma warning restore CA2012
}
}
}

View file

@ -0,0 +1,6 @@
namespace InkForge.Desktop.Properties;
public class ApplicationSettings
{
public List<string> History { get; } = [];
}

View file

@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace InkForge.Desktop.Properties;
[JsonSerializable(typeof(ApplicationSettings))]
[JsonSerializable(typeof(IDictionary<string, object>))]
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
public partial class ConfigContext : JsonSerializerContext;

View file

@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source={WorkspaceFile}"
}
}

View file

@ -0,0 +1,10 @@
using ReactiveUI;
namespace InkForge.Desktop.ReactiveUI;
public abstract class RoutableReactiveObject(IScreen screen) : ReactiveObject, IRoutableViewModel
{
public abstract string? UrlPathSegment { get; }
public IScreen HostScreen => screen;
}

View file

@ -0,0 +1,23 @@
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.ReactiveUI;
public interface IViewModelFactory<T, TCreator>
{
abstract static ObjectFactory<T> CreateObjectFactory();
abstract static TCreator GetCreator(ObjectFactory<T> factory, IServiceProvider serviceProvider);
}
public class ViewModelFactory<T, TFactory, TCreator>
where TFactory : IViewModelFactory<T, TCreator>
where TCreator : Delegate
{
private static ObjectFactory<T>? s_factory;
public TCreator CreateFactory(IServiceProvider serviceProvider)
{
s_factory ??= TFactory.CreateObjectFactory();
return TFactory.GetCreator(s_factory, serviceProvider);
}
}

View file

@ -0,0 +1,13 @@
using Avalonia.Platform.Storage;
namespace InkForge.Desktop.Services;
public static class StorageProviderExtensions
{
public static IStorageProvider? GetStorageProvider(this object? context)
{
ArgumentNullException.ThrowIfNull(context);
return TopLevels.GetTopLevelForContext(context)?.StorageProvider;
}
}

View file

@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
namespace InkForge.Desktop.Services;
public class TopLevels
{
public static readonly AttachedProperty<object?> RegisterProperty
= AvaloniaProperty.RegisterAttached<TopLevels, Visual, object?>("Register");
private static readonly Dictionary<object, Visual> RegistrationMapper = [];
static TopLevels()
{
RegisterProperty.Changed.AddClassHandler<Visual>(RegisterChanged);
}
public static object? GetRegister(AvaloniaObject element)
{
return element.GetValue(RegisterProperty);
}
public static TopLevel? GetTopLevelForContext(object context)
{
return TopLevel.GetTopLevel(GetVisualForContext(context));
}
public static Visual? GetVisualForContext(object context)
{
return RegistrationMapper.TryGetValue(context, out var result) ? result : null;
}
public static void SetRegister(AvaloniaObject element, object value)
{
element.SetValue(RegisterProperty, value);
}
private static void RegisterChanged(Visual sender, AvaloniaPropertyChangedEventArgs e)
{
ArgumentNullException.ThrowIfNull(sender);
// Unregister any old registered context
if (e.OldValue != null)
{
RegistrationMapper.Remove(e.OldValue);
}
// Register any new context
if (e.NewValue != null)
{
RegistrationMapper.Add(e.NewValue, sender);
}
}
}

View file

@ -0,0 +1,6 @@
namespace InkForge.Desktop.Services;
public class WorkspaceContext
{
public string DbPath { get; set; }
}

View file

@ -0,0 +1,36 @@
using InkForge.Desktop.Controllers;
using InkForge.Desktop.Models;
using ReactiveUI;
namespace InkForge.Desktop.ViewModels;
public class AppViewModel : ReactiveObject
{
private readonly LandingViewModel _landingViewModel;
private readonly WorkspaceController _workspace;
private object _view;
public object View
{
get => _view;
set => this.RaiseAndSetIfChanged(ref _view, value);
}
public AppViewModel(WorkspaceController workspace, LandingViewModel landingViewModel)
{
_workspace = workspace;
_landingViewModel = landingViewModel;
this.WhenAnyValue(v => v._workspace.Workspace).Subscribe(OnWorkspaceChanged);
}
private void OnWorkspaceChanged(Workspace workspace)
{
View = workspace switch
{
null => _landingViewModel,
{ } => new WorkspaceViewModel(workspace) // scoped?
};
}
}

View file

@ -0,0 +1,89 @@
using System.Collections.ObjectModel;
using System.Reactive;
using Avalonia.Platform.Storage;
using InkForge.Desktop.Controllers;
using InkForge.Desktop.Services;
using ReactiveUI;
namespace InkForge.Desktop.ViewModels;
public class LandingViewModel : ReactiveObject
{
private ReadOnlyObservableCollection<RecentItemViewModel> _recentItems;
private readonly WorkspaceController _workspaceController;
public ReactiveCommand<Unit, Unit> CreateNew { get; }
public ReactiveCommand<Unit, Unit> OpenNew { get; }
public ReadOnlyObservableCollection<RecentItemViewModel> RecentItems => _recentItems;
public LandingViewModel(WorkspaceController workspaceController)
{
_workspaceController = workspaceController;
CreateNew = ReactiveCommand.CreateFromTask(OnCreateNew);
OpenNew = ReactiveCommand.CreateFromTask(OnOpenNew);
}
private async Task OnCreateNew()
{
var storageProvider = this.GetStorageProvider()!;
var documents = await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions()
{
DefaultExtension = ".ifdb",
FileTypeChoices =
[
new FilePickerFileType("InkForge Database File")
{
Patterns = [ "*.ifdb" ],
},
],
SuggestedStartLocation = documents,
Title = "Select InkForge Database Name",
});
if (file?.TryGetLocalPath() is not { } filePath)
{
return;
}
await _workspaceController.OpenWorkspace(filePath, true);
}
private async Task OnOpenNew()
{
var storageProvider = this.GetStorageProvider()!;
var documents = await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions()
{
AllowMultiple = false,
SuggestedStartLocation = documents,
FileTypeFilter =
[
new FilePickerFileType("InkForge Database File")
{
Patterns = [ "*.ifdb" ]
}
],
Title = "Open InkForge Database file"
});
if (files.Count != 1)
{
return;
}
if (files[0].TryGetLocalPath() is not { } filePath)
{
return;
}
await _workspaceController.OpenWorkspace(filePath, false);
}
}

View file

@ -0,0 +1,9 @@
using ReactiveUI;
namespace InkForge.Desktop.ViewModels;
public record class RecentItemViewModel(
DateTimeOffset Created,
string Name,
DateTimeOffset LastUsed
) : ReactiveRecord;

View file

@ -0,0 +1,15 @@
using InkForge.Desktop.Models;
using ReactiveUI;
namespace InkForge.Desktop.ViewModels;
public class WorkspaceViewModel : ReactiveObject
{
private readonly Workspace _workspace;
public WorkspaceViewModel(Workspace workspace)
{
_workspace = workspace;
}
}

View file

@ -0,0 +1,42 @@
<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:reactiveui="http://reactiveui.net"
xmlns:vm="using:InkForge.Desktop.ViewModels"
xmlns:services="using:InkForge.Desktop.Services"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.LandingView"
x:DataType="vm:LandingViewModel"
services:TopLevels.Register="{CompiledBinding}">
<Grid RowDefinitions="Auto, *, Auto">
<Label Content="Open Recent"
Grid.Row="0" />
<DataGrid IsEnabled="False"
IsReadOnly="True"
ItemsSource="{CompiledBinding RecentItems}"
Grid.Row="1">
<DataGrid.Columns>
<DataGridTextColumn Header="Created"
Binding="{CompiledBinding Created, StringFormat={}{0:d}}" />
<DataGridTextColumn Header="Name"
Width="*"
Binding="{CompiledBinding Name}" />
<DataGridTextColumn Header="Last Used"
Binding="{CompiledBinding LastUsed, StringFormat={}{0:d}}" />
</DataGrid.Columns>
</DataGrid>
<Menu Grid.Row="2">
<MenuItem Header="Create New"
Command="{CompiledBinding CreateNew}" />
<MenuItem Header="Open"
IsEnabled="False" />
<MenuItem Header="Open File"
Command="{CompiledBinding OpenNew}" />
</Menu>
</Grid>
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.ReactiveUI;
using InkForge.Desktop.ViewModels;
namespace InkForge.Desktop.Views;
public partial class LandingView : ReactiveUserControl<LandingViewModel>
{
public LandingView()
{
InitializeComponent();
}
}

View file

@ -3,22 +3,16 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:reactiveui="http://reactiveui.net"
xmlns:ifcvm="using:InkForge.Common.ViewModels"
xmlns:vm="using:InkForge.Desktop.ViewModels"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.MainWindow"
x:DataType="ifcvm:AppViewModel"
Title="InkForge">
<NativeMenu.Menu>
<NativeMenu>
<NativeMenuItem Header="Test" />
</NativeMenu>
</NativeMenu.Menu>
x:DataType="vm:AppViewModel"
Title="MainWindow">
<DockPanel>
<NativeMenuBar />
<DockPanel>
<NativeMenuBar />
<reactiveui:ViewModelViewHost ViewModel="{CompiledBinding View}" />
</DockPanel>
<reactiveui:ViewModelViewHost ViewModel="{CompiledBinding View}" />
</DockPanel>
</Window>

View file

@ -1,6 +1,6 @@
using Avalonia.ReactiveUI;
using InkForge.Common.ViewModels;
using InkForge.Desktop.ViewModels;
namespace InkForge.Desktop.Views;

View file

@ -0,0 +1,13 @@
<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:reactiveui="http://reactiveui.net"
xmlns:vm="using:InkForge.Desktop.ViewModels"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.WorkspaceView"
x:DataType="vm:WorkspaceViewModel">
Welcome to Avalonia!
</UserControl>

View file

@ -0,0 +1,13 @@
using Avalonia.ReactiveUI;
using InkForge.Desktop.ViewModels;
namespace InkForge.Desktop.Views;
public partial class WorkspaceView : ReactiveUserControl<WorkspaceViewModel>
{
public WorkspaceView()
{
InitializeComponent();
}
}