-
Notifications
You must be signed in to change notification settings - Fork 41
Add Create/Import Wallet Flow V2 #665
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> |
There was a problem hiding this comment.
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?