Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.IO;
using Angor.Sdk.Common;
using AngorApp.Core;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using Zafiro.Avalonia.Dialogs;
using Zafiro.Avalonia.Dialogs.Implementations;
Expand Down Expand Up @@ -35,12 +37,26 @@ public static IServiceCollection AddUIServices(this IServiceCollection services,
.AddSingleton<IWalletContext, WalletContext>()
.AddSingleton<IValidations, Validations>()
.AddSingleton<SharedCommands>()
.AddSingleton<IStorageProvider>(_ => GetStorageProvider(mainView))
.AddSingleton<ILauncherService, LauncherService>()
.AddSingleton<INotificationService, NotificationService>()
.AddSingleton<IImageValidationService, ImageValidationService>()
.AddSingleton(sp => ActivatorUtilities.CreateInstance<UIServices>(sp, profileContext.ProfileName, mainView));
}

private static IStorageProvider GetStorageProvider(Control mainView)
{
var topLevel = TopLevel.GetTopLevel(mainView)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you explain this code? the storage provides lives in the main view?

?? Application.Current?.ApplicationLifetime switch
{
IClassicDesktopStyleApplicationLifetime desktop => desktop.MainWindow,
ISingleViewApplicationLifetime singleView => TopLevel.GetTopLevel(singleView.MainView),
_ => null
};

return topLevel?.StorageProvider ?? throw new InvalidOperationException("Storage provider is not available.");
}

private static string CreateSettingsFilePath(IApplicationStorage storage, ProfileContext profileContext)
{
try
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using ProjectId = Angor.Sdk.Funding.Shared.ProjectId;
using AngorApp.Core.Factories;
using AngorApp.UI.Sections.Browse;
using AngorApp.UI.Sections.Browse.Details;
using AngorApp.UI.Sections.Browse.ProjectLookup;
using AngorApp.UI.Sections.Founder;
using AngorApp.UI.Sections.Founder.ProjectDetails;
Expand All @@ -19,6 +18,8 @@
using Angor.Sdk.Funding.Projects.Dtos;
using AngorApp.UI.Flows.InvestV2;

using AngorApp.UI.Flows.AddWallet;
using AngorApp.UI.Flows.AddWallet.SeedBackup;
using AngorApp.UI.Flows.InvestV2.PaymentSelector;
using AngorApp.UI.Sections.Funds.Accounts;
using AngorApp.UI.Sections.Funds.Empty;
Expand All @@ -42,6 +43,8 @@ public static IServiceCollection AddViewModels(this IServiceCollection services)
.AddScoped<Func<ProjectDto, IFounderProjectViewModel>>(provider => dto => ActivatorUtilities.CreateInstance<FounderProjectViewModel>(provider, dto))
.AddTransient<IWalletSectionViewModel, WalletSectionViewModel>()
.AddTransient<IAccountsViewModel, AccountsViewModel>()
.AddTransient<ISeedBackupFileService, SeedBackupFileService>()
.AddTransient<IAddWalletFlow, AddWalletFlow>()
.AddTransient<IEmptyViewModel, EmptyViewModel>()
.AddTransient<IBrowseSectionViewModel, BrowseSectionViewModel>()
.AddTransient<IPortfolioSectionViewModel, PortfolioSectionViewModel>()
Expand Down
151 changes: 151 additions & 0 deletions src/Angor/Avalonia/AngorApp/UI/Flows/AddWallet/AddWalletFlow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
using System.Reactive.Subjects;
using Angor.Sdk.Wallet.Application;
using AngorApp.UI.Flows.AddWallet.Import;
using AngorApp.UI.Flows.AddWallet.SeedBackup;
using AngorApp.UI.Shared.OperationResult;
using Zafiro.Avalonia.Dialogs;
using Zafiro.Reactive;
using Option = Zafiro.Avalonia.Dialogs.Option;

namespace AngorApp.UI.Flows.AddWallet;

public class AddWalletFlow(UIServices uiServices, IWalletContext walletContext, IWalletAppService walletAppService, ISeedBackupFileService seedBackupFileService) : IAddWalletFlow
{
public Task Run() => uiServices.Dialog.Show("", "Add New Wallet", closeable => [ImportOption(closeable), GenerateNewOption(closeable)]);

private async Task Import()
{
SeedwordsEntryViewModel seedwordsEntryVm = new();
var viewModel = new OperationResultViewModel(
"Import Wallet",
"Enter your BIP-39 seed words separated by spaces to import an external wallet.",
additionalContent: seedwordsEntryVm);

await uiServices.Dialog.Show(
viewModel,
"",
closeable => ImportOptions(closeable, seedwordsEntryVm));
}

private async Task<Result> CreateWallet(string seedwords)
{
return await walletContext.ImportWallet(seedwords, Maybe.None);
}

private async Task CreateNew()
{
var seedwords = walletAppService.GenerateRandomSeedwords();
var viewModel = new OperationResultViewModel(
"Backup Your Wallet",
"Download your seed phrase and keep it safe. You'll need this to recover your wallet if you lose access.",
new Icon("fa-lock"),
new SeedWordsViewModel(seedwords));

await uiServices.Dialog.Show(
viewModel,
"",
closeable => CreateNewOptions(closeable, seedwords));
}

private async Task NotifyError(string message)
{
await ShowDoneDialog(new OperationResultViewModel(
"Could not create wallet",
"An error occurred while creating your wallet: " + message + ".",
new Icon("fa-xmark")) { Feeling = Feeling.Bad });
}

private async Task NotifySuccess()
{
await ShowDoneDialog(
new OperationResultViewModel(
"Wallet Created!",
"Your new wallet has been successfully created. You can now use it to fund or launch projects",
new Icon("fa-check")));
}

private IOption ImportOption(ICloseable closeable)
{
return new Option(
"Import",
EnhancedCommand.Create(async () =>
{
closeable.Close();
await Import();
}),
new Settings { Role = OptionRole.Secondary });
}

private IOption GenerateNewOption(ICloseable closeable)
{
return new Option(
"Generate New",
EnhancedCommand.Create(async () =>
{
closeable.Close();
await CreateNew();
}),
new Settings { Role = OptionRole.Primary, IsDefault = true });
}

private IOption[] ImportOptions(ICloseable closeable, SeedwordsEntryViewModel seedwordsEntryVm)
{
IEnhancedCommand<Unit> createWalletCommand = EnhancedCommand.Create(async () =>
{
await CreateWallet(seedwordsEntryVm.Seedwords!).Match(NotifySuccess, NotifyError);
closeable.Close();
}, seedwordsEntryVm.IsValid);

return
[
CancelOption(closeable, createWalletCommand.IsExecuting.Not()),
new Option("Continue", createWalletCommand, new Settings()),
];
}

private IOption[] CreateNewOptions(ICloseable closeable, string seedwords)
{
var downloadSeedExecuted = new BehaviorSubject<bool>(false);

IEnhancedCommand<Unit> createWalletCommand = EnhancedCommand.Create(async () =>
{
closeable.Close();
await CreateWallet(seedwords).Match(NotifySuccess, NotifyError);
}, downloadSeedExecuted);

return
[
DownloadSeedOption(seedwords, downloadSeedExecuted),
new Option("Continue", createWalletCommand, new Settings { Role = OptionRole.Primary }),
CancelOption(closeable, createWalletCommand.IsExecuting.Not()),
];
}

private Task ShowDoneDialog(OperationResultViewModel viewModel)
{
return uiServices.Dialog.Show(
viewModel,
"",
closeable =>
[
new Option(
"Done",
EnhancedCommand.Create(closeable.Close),
new Settings { IsDefault = true, Role = OptionRole.Primary })
]);
}

private IOption CancelOption(ICloseable closeable, IObservable<bool> canCancel)
{
return new Option("Cancel", EnhancedCommand.Create(closeable.Close, canCancel), new Settings { IsCancel = true, Role = OptionRole.Cancel });
}

private IOption DownloadSeedOption(string seedwords, IObserver<bool> downloadSeedExecuted)
{
return new Option("Download Seed", EnhancedCommand.Create(async () =>
{
downloadSeedExecuted.OnNext(true);
await seedBackupFileService.Save(seedwords);
}), new Settings { Role = OptionRole.Info });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AngorApp.UI.Flows.AddWallet;

public interface IAddWalletFlow
{
Task Run();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<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:addWallet="clr-namespace:AngorApp.UI.Flows.AddWallet.Import"
mc:Ignorable="d"
x:Class="AngorApp.UI.Flows.AddWallet.Import.SeedwordsEntryView" x:DataType="addWallet:SeedwordsEntryViewModel">
<TextBox Watermark="twelve or twenty-four seed words separated by spaces" MaxWidth="600" MinWidth="400" MaxHeight="300" Text="{Binding Seedwords, Mode=TwoWay}" />
</UserControl>
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;

namespace AngorApp.UI.Flows.AddWallet.Import
{
public partial class SeedwordsEntryView : UserControl
{
public SeedwordsEntryView()
{
InitializeComponent();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using ReactiveUI.Validation.Extensions;
using ReactiveUI.Validation.Helpers;
using Blockcore.NBitcoin.BIP39;

namespace AngorApp.UI.Flows.AddWallet.Import
{
public partial class SeedwordsEntryViewModel : ReactiveValidationObject, IValidatable
{
public SeedwordsEntryViewModel()
{
this.ValidationRule(x => x.Seedwords, x => x is null || HasValidSeedwordLength(x), "Please, enter 12 or 24 seed words separated by spaces");
this.ValidationRule(x => x.Seedwords, x => x is null || HasOnlyValidEnglishMnemonicWords(x), "Please, enter valid BIP-39 English seed words");
this.ValidationRule(this.WhenAnyValue(x => x.Seedwords), x => x is not null, _ => "Seed words cannot be empty");
}

[Reactive]
private string? seedwords;

public IObservable<bool> IsValid => this.IsValid();

private static bool HasValidSeedwordLength(string seedwords)
{
var words = seedwords.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).Length;
return words is 12 or 24;
}

private static bool HasOnlyValidEnglishMnemonicWords(string seedwords)
{
var words = seedwords.Split(" ", StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
return words.All(word => Wordlist.English.WordExists(word.ToLowerInvariant(), out _));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AngorApp.UI.Flows.AddWallet.SeedBackup;

public interface ISeedBackupFileService
{
Task Save(string seedwords);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System.IO;
using Avalonia.Platform.Storage;
using Zafiro.Avalonia.Dialogs;

namespace AngorApp.UI.Flows.AddWallet.SeedBackup;

public class SeedBackupFileService(UIServices uiServices, IStorageProvider storageProvider) : ISeedBackupFileService
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we decided to show a popup wit the seedwords finally for simplicity.
thought is this is already implemented

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's implemented, but we can remove it if you wish. It's almost zero work in there :)

{
public async Task Save(string seedwords)
{
if (!storageProvider.CanSave)
{
await ShowDownloadError("Could not access the file picker.");
return;
}

var file = await TryPickBackupFile();
if (file is null)
{
return;
}

await SaveBackupFile(file, seedwords);
}

private async Task<IStorageFile?> TryPickBackupFile()
{
try
{
return await storageProvider.SaveFilePickerAsync(SeedBackupSaveOptions());
}
catch (Exception e)
{
await ShowDownloadError($"Could not open the file picker: {e.Message}");
return null;
}
}

private static FilePickerSaveOptions SeedBackupSaveOptions()
{
return new FilePickerSaveOptions
{
Title = "Save seed words backup",
SuggestedFileName = "angor-seed-backup.txt",
DefaultExtension = "txt",
FileTypeChoices =
[
new FilePickerFileType("Text document")
{
Patterns = ["*.txt"],
MimeTypes = ["text/plain"]
}
]
};
}

private async Task SaveBackupFile(IStorageFile file, string seedwords)
{
try
{
await using var stream = await file.OpenWriteAsync();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync(BackupContent(seedwords));
await writer.FlushAsync();
await uiServices.NotificationService.Show("Seed backup saved.", "Wallet");
}
catch (Exception e)
{
await ShowDownloadError($"Could not save the seed backup: {e.Message}");
}
}

private Task ShowDownloadError(string message)
{
return uiServices.Dialog.ShowMessage("Download failed", message);
}

private static string BackupContent(string seedwords)
{
return $"Angor Wallet Seed Backup{Environment.NewLine}{Environment.NewLine}" +
$"Created (UTC): {DateTimeOffset.UtcNow:O}{Environment.NewLine}{Environment.NewLine}" +
$"Seed words:{Environment.NewLine}{seedwords}{Environment.NewLine}{Environment.NewLine}" +
"Keep this file offline and never share it.";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<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:l="clr-namespace:AngorApp.UI.Flows.AddWallet.SeedBackup"
mc:Ignorable="d"
x:Class="AngorApp.UI.Flows.AddWallet.SeedBackup.SeedWordsView" x:DataType="l:SeedWordsViewModel">
<Border Classes="ColorPanel White">
<SelectableTextBlock TextWrapping="Wrap" TextAlignment="Center" Text="{Binding SeedWords}" />
</Border>
</UserControl>
Loading
Loading