Migrate to Avalonia (#1220)

This commit is contained in:
Oleksii Holub
2024-04-27 04:17:46 +03:00
committed by GitHub
parent 74f99b4e59
commit b9c1c47474
89 changed files with 2467 additions and 2810 deletions

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Avalonia;
using Avalonia.Platform.Storage;
using DialogHostAvalonia;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Framework;
public class DialogManager : IDisposable
{
private readonly AsyncNonKeyedLocker _dialogLock = new();
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
{
using (await _dialogLock.LockAsync())
{
await DialogHost.Show(
dialog,
// It's fine to await in a void method here because it's an event handler
// ReSharper disable once AsyncVoidLambda
async (object _, DialogOpenedEventArgs args) =>
{
await dialog.WaitForCloseAsync();
try
{
args.Session.Close();
}
catch (InvalidOperationException)
{
// Dialog host is already processing a close operation
}
}
);
return dialog.DialogResult;
}
}
public async Task<string?> PromptSaveFilePathAsync(
IReadOnlyList<FilePickerFileType>? fileTypes = null,
string defaultFilePath = ""
)
{
var topLevel =
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
?? throw new ApplicationException("Could not find the top-level visual element.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(
new FilePickerSaveOptions
{
FileTypeChoices = fileTypes,
SuggestedFileName = defaultFilePath,
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.')
}
);
return file?.Path.LocalPath;
}
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
{
var topLevel =
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
?? throw new ApplicationException("Could not find the top-level visual element.");
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
defaultDirPath
);
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions
{
AllowMultiple = false,
SuggestedStartLocation = startLocation
}
);
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
}
public void Dispose() => _dialogLock.Dispose();
}

View File

@@ -0,0 +1,25 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace DiscordChatExporter.Gui.Framework;
public abstract partial class DialogViewModelBase<T> : ViewModelBase
{
private readonly TaskCompletionSource<T> _closeTcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
[ObservableProperty]
private T? _dialogResult;
[RelayCommand]
protected void Close(T dialogResult)
{
DialogResult = dialogResult;
_closeTcs.TrySetResult(dialogResult);
}
public async Task<T> WaitForCloseAsync() => await _closeTcs.Task;
}
public abstract class DialogViewModelBase : DialogViewModelBase<bool?>;

View File

@@ -0,0 +1,34 @@
using System;
using Avalonia.Threading;
using Material.Styles.Controls;
using Material.Styles.Models;
namespace DiscordChatExporter.Gui.Framework;
public class SnackbarManager
{
private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5);
public void Notify(string message, TimeSpan? duration = null) =>
SnackbarHost.Post(
new SnackbarModel(message, duration ?? _defaultDuration),
null,
DispatcherPriority.Normal
);
public void Notify(
string message,
string actionText,
Action actionHandler,
TimeSpan? duration = null
) =>
SnackbarHost.Post(
new SnackbarModel(
message,
duration ?? _defaultDuration,
new SnackbarButtonModel { Text = actionText, Action = actionHandler }
),
null,
DispatcherPriority.Normal
);
}

View File

@@ -0,0 +1,18 @@
using System;
using Avalonia.Controls;
namespace DiscordChatExporter.Gui.Framework;
public class UserControl<TDataContext> : UserControl
{
public new TDataContext DataContext
{
get =>
base.DataContext is TDataContext dataContext
? dataContext
: throw new InvalidCastException(
$"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'."
);
set => base.DataContext = value;
}
}

View File

@@ -0,0 +1,37 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
namespace DiscordChatExporter.Gui.Framework;
public partial class ViewManager
{
public Control? TryBindView(ViewModelBase viewModel)
{
var name = viewModel
.GetType()
.FullName?.Replace("ViewModel", "View", StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(name))
return null;
var type = Type.GetType(name);
if (type is null)
return null;
if (Activator.CreateInstance(type) is not Control view)
return null;
view.DataContext ??= viewModel;
return view;
}
}
public partial class ViewManager : IDataTemplate
{
bool IDataTemplate.Match(object? data) => data is ViewModelBase;
Control? ITemplate<object?, Control?>.Build(object? data) =>
data is ViewModelBase viewModel ? TryBindView(viewModel) : null;
}

View File

@@ -0,0 +1,19 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DiscordChatExporter.Gui.Framework;
public abstract class ViewModelBase : ObservableObject, IDisposable
{
~ViewModelBase() => Dispose(false);
protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty);
protected virtual void Dispose(bool disposing) { }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using Microsoft.Extensions.DependencyInjection;
namespace DiscordChatExporter.Gui.Framework;
public class ViewModelManager(IServiceProvider services)
{
public MainViewModel CreateMainViewModel() => services.GetRequiredService<MainViewModel>();
public DashboardViewModel CreateDashboardViewModel() =>
services.GetRequiredService<DashboardViewModel>();
public ExportSetupViewModel CreateExportSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
{
var viewModel = services.GetRequiredService<ExportSetupViewModel>();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(
string title,
string message,
string? okButtonText,
string? cancelButtonText
)
{
var viewModel = services.GetRequiredService<MessageBoxViewModel>();
viewModel.Title = title;
viewModel.Message = message;
viewModel.DefaultButtonText = okButtonText;
viewModel.CancelButtonText = cancelButtonText;
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) =>
CreateMessageBoxViewModel(title, message, "CLOSE", null);
public SettingsViewModel CreateSettingsViewModel() =>
services.GetRequiredService<SettingsViewModel>();
}

View File

@@ -0,0 +1,18 @@
using System;
using Avalonia.Controls;
namespace DiscordChatExporter.Gui.Framework;
public class Window<TDataContext> : Window
{
public new TDataContext DataContext
{
get =>
base.DataContext is TDataContext dataContext
? dataContext
: throw new InvalidCastException(
$"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'."
);
set => base.DataContext = value;
}
}