Added AccountsDialog

This commit is contained in:
Michael Bucari-Tovo 2022-07-16 20:47:53 -06:00
parent ccdd1dc9f3
commit eff9c2b35d
14 changed files with 456 additions and 40 deletions

View file

@ -0,0 +1,132 @@
<Window 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"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="800" Height="450"
x:Class="LibationWinForms.AvaloniaUI.Views.Dialogs.AccountsDialog"
Title="Audible Accounts"
Icon="/AvaloniaUI/Assets/libation.ico">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="False"
CanUserResizeColumns="False"
CanUserSortColumns="False"
AutoGenerateColumns="False"
IsReadOnly="False"
Items="{Binding Accounts}"
GridLinesVisibility="All">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Delete">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Width="60"
Height="30"
Content="X"
Click="DeleteButton_Clicked" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Export">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button
Width="60"
Height="30"
Content="Export"
ToolTip.Tip="Export account authorization to audible-cli"
Click="ExportButton_Clicked" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridCheckBoxColumn
Binding="{Binding LibraryScan, Mode=TwoWay}"
Header="Include in&#xa;library scan?"/>
<DataGridTextColumn
Width="2*"
Binding="{Binding AccountId, Mode=TwoWay}"
Header="Autible&#xa;email/login"/>
<DataGridTemplateColumn Width="Auto" Header="Locale">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ComboBox
MinHeight="30"
HorizontalAlignment="Center"
SelectedItem="{Binding SelectedLocale, Mode=TwoWay}"
Items="{Binding Locales}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock ZIndex="2"
FontSize="12"
Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
Width="3*"
Binding="{Binding AccountName, Mode=TwoWay}"
Header="Account Nickname&#xa;(optional)"/>
</DataGrid.Columns>
</DataGrid>
<Grid
Grid.Row="1"
Margin="10"
ColumnDefinitions="*,Auto" >
<StackPanel
Grid.Column="0"
Orientation="Horizontal">
<Button
Grid.Column="0"
Height="30"
Content="Add an Account"
Name="AddAccountButton"
Click="AddAccountButton_Clicked" />
<Button
Grid.Column="0"
Height="30"
Margin="20,0,0,0"
Content="Import from audible-cli"
Click="ImportButton_Clicked" />
</StackPanel>
<Button
Grid.Column="1"
Height="30"
Padding="30,3,30,3"
Content="Save"
Click="SaveButton_Clicked" />
</Grid>
</Grid>
</Window>

View file

@ -0,0 +1,270 @@
using AudibleUtilities;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace LibationWinForms.AvaloniaUI.Views.Dialogs
{
public partial class AccountsDialog : DialogWindow
{
public ObservableCollection<AccountDto> Accounts { get; } = new();
public class AccountDto
{
public IList<AudibleApi.Locale> Locales { get; init; }
public bool LibraryScan { get; set; } = true;
public string AccountId { get; set; }
public AudibleApi.Locale SelectedLocale { get; set; }
public string AccountName { get; set; }
public bool IsDefault => AccountId is null && SelectedLocale is null && AccountName is null;
}
private static string GetAudibleCliAppDataPath()
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible");
private List<AudibleApi.Locale> Locales => AudibleApi.Localization.Locales.OrderBy(l => l.Name).ToList();
public AccountsDialog()
{
InitializeComponent();
// WARNING: accounts persister will write ANY EDIT to object immediately to file
// here: copy strings and dispose of persister
// only persist in 'save' step
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.Accounts;
if (!accounts.Any())
return;
ControlToFocusOnShow = this.FindControl<Button>(nameof(AddAccountButton));
DataContext = this;
foreach (var account in accounts)
AddAccountToGrid(account);
}
private void AddAccountToGrid(Account account)
{
//ObservableCollection doesn't fire CollectionChanged on Add, so use Insert instead
Accounts.Insert(Accounts.Count, new()
{
LibraryScan = account.LibraryScan,
AccountId = account.AccountId,
SelectedLocale = Locales.Single(l => l.Name == account.Locale.Name),
AccountName = account.AccountName,
Locales = Locales
});
}
public async void ImportButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
OpenFileDialog ofd = new();
ofd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } });
ofd.Directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
ofd.AllowMultiple = false;
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
ofd.Directory = audibleAppDataDir;
var filePath = await ofd.ShowAsync(this);
if (filePath is null || filePath.Length == 0) return;
try
{
var jsonText = File.ReadAllText(filePath[0]);
var mkbAuth = Mkb79Auth.FromJson(jsonText);
var account = await mkbAuth.ToAccountAsync();
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name))
{
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account");
return;
}
persister.AccountsSettings.Add(account);
AddAccountToGrid(account);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
null,
$"An error occurred while importing an account from:\r\n{filePath[0]}\r\n\r\nIs the file encrypted?",
"Error Importing Account",
ex);
}
}
public void AddAccountButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (Accounts.Any(a => a.IsDefault))
return;
//ObservableCollection doesn't fire CollectionChanged on Add, so use Insert instead
Accounts.Insert(Accounts.Count, new() { Locales = Locales });
}
public void DeleteButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is Button expBtn && expBtn.DataContext is AccountDto acc)
Accounts.Remove(acc);
}
public void ExportButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is Button expBtn && expBtn.DataContext is AccountDto acc)
Export(acc);
}
protected override async Task SaveAndCloseAsync()
{
try
{
if (!await inputIsValid())
return;
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
persister.BeginTransation();
persist(persister.AccountsSettings);
persister.CommitTransation();
base.SaveAndClose();
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(null, "Error attempting to save accounts", "Error saving accounts", ex);
}
}
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void persist(AccountsSettings accountsSettings)
{
var existingAccounts = accountsSettings.Accounts;
// editing account id is a special case. an account is defined by its account id, therefore this is really a different account. the user won't care about this distinction though.
// these will be caught below by normal means and re-created minus the convenience of persisting identity tokens
// delete
for (var i = existingAccounts.Count - 1; i >= 0; i--)
{
var existing = existingAccounts[i];
if (!Accounts.Any(dto =>
dto.AccountId?.ToLower().Trim() == existing.AccountId.ToLower()
&& dto.SelectedLocale.Name == existing.Locale?.Name))
{
accountsSettings.Delete(existing);
}
}
// upsert each. validation occurs through Account and AccountsSettings
foreach (var dto in Accounts)
{
var acct = accountsSettings.Upsert(dto.AccountId, dto.SelectedLocale.Name);
acct.LibraryScan = dto.LibraryScan;
acct.AccountName
= string.IsNullOrWhiteSpace(dto.AccountName)
? $"{dto.AccountId} - {dto.SelectedLocale.Name}"
: dto.AccountName.Trim();
}
}
private async Task<bool> inputIsValid()
{
foreach (var dto in Accounts.ToList())
{
if (dto.IsDefault)
{
Accounts.Remove(dto);
continue;
}
if (string.IsNullOrWhiteSpace(dto.AccountId))
{
await MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
if (string.IsNullOrWhiteSpace(dto.SelectedLocale.Name))
{
await MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
}
return true;
}
private async void Export(AccountDto acc)
{
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == acc.AccountId && a.Locale.Name == acc.SelectedLocale.Name);
if (account is null)
return;
if (account.IdentityTokens?.IsValid != true)
{
await MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated");
return;
}
SaveFileDialog sfd = new();
sfd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } });
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
sfd.Directory = audibleAppDataDir;
string fileName = await sfd.ShowAsync(this);
if (fileName is null)
return;
try
{
var mkbAuth = Mkb79Auth.FromAccount(account);
var jsonText = mkbAuth.ToJson();
File.WriteAllText(fileName, jsonText);
await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{fileName}", "Success!");
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
null,
$"An error occurred while exporting account:\r\n{account.AccountName}",
"Error Exporting Account",
ex);
}
}
}
}

View file

@ -5,7 +5,7 @@
mc:Ignorable="d" d:DesignWidth="550" d:DesignHeight="450"
MinWidth="550" MinHeight="450"
Width="650" Height="500"
x:Class="LibationWinForms.AvaloniaUI.Views.Dialogs.BookDetailsDialog2"
x:Class="LibationWinForms.AvaloniaUI.Views.Dialogs.BookDetailsDialog"
xmlns:controls="clr-namespace:LibationWinForms.AvaloniaUI.Controls"
Title="Book Details" Name="BookDetails"
Icon="/AvaloniaUI/Assets/libation.ico">

View file

@ -12,7 +12,7 @@ using System.Linq;
namespace LibationWinForms.AvaloniaUI.Views.Dialogs
{
public partial class BookDetailsDialog2 : DialogWindow
public partial class BookDetailsDialog : DialogWindow
{
private LibraryBook _libraryBook;
private BookDetailsDialogViewModel _viewModel;
@ -31,7 +31,7 @@ namespace LibationWinForms.AvaloniaUI.Views.Dialogs
public LiberatedStatus BookLiberatedStatus => _viewModel.BookLiberatedSelectedItem.Status;
public LiberatedStatus? PdfLiberatedStatus => _viewModel.PdfLiberatedSelectedItem?.Status;
public BookDetailsDialog2()
public BookDetailsDialog()
{
InitializeComponent();
@ -41,7 +41,7 @@ namespace LibationWinForms.AvaloniaUI.Views.Dialogs
LibraryBook = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G");
}
}
public BookDetailsDialog2(LibraryBook libraryBook) :this()
public BookDetailsDialog(LibraryBook libraryBook) :this()
{
LibraryBook = libraryBook;
}

View file

@ -1,6 +1,8 @@
using Avalonia;
using Avalonia.Controls;
using LibationFileManager;
using System;
using System.Threading.Tasks;
namespace LibationWinForms.AvaloniaUI.Views.Dialogs
{
@ -11,27 +13,42 @@ namespace LibationWinForms.AvaloniaUI.Views.Dialogs
{
this.HideMinMaxBtns();
this.KeyDown += DialogWindow_KeyDown;
this.Initialized += DialogWindow_Initialized;
this.Opened += DialogWindow_Opened;
this.Closing += DialogWindow_Closing;
#if DEBUG
this.AttachDevTools();
#endif
}
private void DialogWindow_Initialized(object sender, EventArgs e)
{
this.WindowStartupLocation = WindowStartupLocation.CenterOwner;
this.RestoreSizeAndLocation(Configuration.Instance);
}
private void DialogWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
this.SaveSizeAndLocation(Configuration.Instance);
}
private void DialogWindow_Opened(object sender, EventArgs e)
{
ControlToFocusOnShow?.Focus();
}
protected virtual void SaveAndClose() => Close(DialogResult.OK);
protected virtual Task SaveAndCloseAsync() => Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(SaveAndClose);
protected virtual void CancelAndClose() => Close(DialogResult.Cancel);
protected virtual Task CancelAndCloseAsync() => Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(CancelAndClose);
private void DialogWindow_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
private async void DialogWindow_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
{
if (e.Key == Avalonia.Input.Key.Escape)
CancelAndClose();
await CancelAndCloseAsync();
else if (e.Key == Avalonia.Input.Key.Return)
SaveAndClose();
await SaveAndCloseAsync();
}
}
}

View file

@ -6,6 +6,7 @@ using Avalonia.Markup.Xaml;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationWinForms.AvaloniaUI.Views.Dialogs
{
@ -56,9 +57,9 @@ namespace LibationWinForms.AvaloniaUI.Views.Dialogs
this.FindControl<Button>(nameof(ImportButton)).Focus();
}
public void EditAccountsButton_Clicked(object sender, RoutedEventArgs e)
public async void EditAccountsButton_Clicked(object sender, RoutedEventArgs e)
{
if (new LibationWinForms.Dialogs.AccountsDialog().ShowDialog() == System.Windows.Forms.DialogResult.OK)
if (await new AccountsDialog().ShowDialog<DialogResult>(this) == DialogResult.OK)
{
// reload grid and default checkboxes
LoadAccounts();