From 8cd6219bd98bc0038b0514a296f002a8c17b3cae Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 15 Jul 2022 15:16:27 -0600 Subject: [PATCH] Performance improvements and better mvvp pattern following --- .../AvaloniaUI/Assets/libation.ico | Bin 0 -> 102551 bytes .../ViewModels/GridEntryBindingList2.cs | 52 ++-- .../ViewModels/MainWindowViewModel.cs | 1 + .../ViewModels/ProductsDisplayViewModel.cs | 294 ++++++++++++++++-- .../MainWindow/MainWindow.Filter.axaml.cs | 2 +- .../MainWindow.QuickFilters.axaml.cs | 2 +- .../MainWindow.RemoveBooks.axaml.cs | 10 +- .../MainWindow.VisibleBooks.axaml.cs | 13 +- .../Views/MainWindow/MainWindow.axaml | 6 +- .../Views/MainWindow/MainWindow.axaml.cs | 19 +- .../ProductsDisplay2.Buttons.xaml.cs | 11 +- ...oductsDisplay2.ColumnCustomization.xaml.cs | 6 +- .../ProductsDisplay2.Display.xaml.cs | 81 ----- .../ProductsDisplay2.Filtering.xaml.cs | 27 -- .../ProductsDisplay2.ScanAndRemove.xaml.cs | 137 -------- .../ProductsDisplay2.Sorting.xaml.cs | 41 --- .../Views/ProductsGrid/ProductsDisplay2.axaml | 12 +- .../ProductsGrid/ProductsDisplay2.axaml.cs | 57 +--- .../LibationWinForms/LibationWinForms.csproj | 1 + 19 files changed, 358 insertions(+), 414 deletions(-) create mode 100644 Source/LibationWinForms/AvaloniaUI/Assets/libation.ico delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs delete mode 100644 Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico b/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico new file mode 100644 index 0000000000000000000000000000000000000000..d3e0044392a5ccbebff66f3775ee6c32511c0d58 GIT binary patch literal 102551 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 literal 0 HcmV?d00001 diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs index 3448e88b..0a42cb3c 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryBindingList2.cs @@ -9,16 +9,19 @@ using System.Linq; namespace LibationWinForms.AvaloniaUI.ViewModels { /* - * Allows filtering and sorting of the underlying BindingList - * by implementing IBindingListView and using SearchEngineCommands + * Allows filtering of the underlying ObservableCollection * * When filtering is applied, the filtered-out items are removed * from the base list and added to the private FilterRemoved list. * When filtering is removed, items in the FilterRemoved list are * added back to the base list. * - * Remove is overridden to ensure that removed items are removed from - * the base list (visible items) as well as the FilterRemoved list. + * Items are added and removed to/from the ObservableCollection's + * internal list instead of the ObservableCollection itself to + * avoid ObservableCollection firing CollectionChanged for every + * item. Editing the list this way improve's display performance, + * but requires ResetCollection() to be called after all changes + * have been made. */ public class GridEntryBindingList2 : ObservableCollection { @@ -42,31 +45,19 @@ namespace LibationWinForms.AvaloniaUI.ViewModels #region Items Management - public new void Remove(GridEntry2 entry) - { - FilterRemoved.Add(entry); - base.Remove(entry); - } - public void ReplaceList(IEnumerable newItems) { Items.Clear(); ((List)Items).AddRange(newItems); ResetCollection(); } - - protected override void InsertItem(int index, GridEntry2 item) - { - FilterRemoved.Remove(item); - base.InsertItem(index, item); - } + public void ResetCollection() + => OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); #endregion #region Filtering - public void ResetCollection() - => OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); private void ApplyFilter(string filterString) { @@ -85,8 +76,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels foreach (var item in filteredOut) { - Remove(item); + FilterRemoved.Add(item); + Items.Remove(item); } + ResetCollection(); } public void RemoveFilter() @@ -99,12 +92,15 @@ namespace LibationWinForms.AvaloniaUI.ViewModels { if (item is SeriesEntrys2 || item is LibraryBookEntry2 lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)) { - InsertItem(visibleCount++, item); + + FilterRemoved.Remove(item); + Items.Insert(visibleCount++, item); } } FilterString = null; SearchResults = null; + ResetCollection(); } #endregion @@ -128,10 +124,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) { /* - * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't - * fired. When adding many items at once, Avalonia's CollectionChanged event handler - * causes serious performance problems. And unfotrunately, Avalonia doesn't respect - * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't + * fired. When adding or removing many items at once, Avalonia's CollectionChanged + * event handler causes serious performance problems. And unfotrunately, Avalonia + * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) * overload that would fire only once for all changed items. * * Doing this requires resetting the list so the view knows it needs to rebuild its display. @@ -154,10 +150,10 @@ namespace LibationWinForms.AvaloniaUI.ViewModels if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) { /* - * Bypass ObservationCollection's InsertItem methos so that CollectionChanged isn't - * fired. When adding many items at once, Avalonia's CollectionChanged event handler - * causes serious performance problems. And unfotrunately, Avalonia doesn't respect - * the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't + * fired. When adding or removing many items at once, Avalonia's CollectionChanged + * event handler causes serious performance problems. And unfotrunately, Avalonia + * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) * overload that would fire only once for all changed items. * * Doing this requires resetting the list so the view knows it needs to rebuild its display. diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs index 918a9019..b4bfe0b9 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs @@ -22,6 +22,7 @@ namespace LibationWinForms.AvaloniaUI.ViewModels /// The Process Queue's viewmodel public ProcessQueueViewModel ProcessQueueViewModel { get; } = new ProcessQueueViewModel(); + public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel(); /// Library filterting query diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs index 0eb04002..5e5f6b5d 100644 --- a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs @@ -1,31 +1,128 @@ using Avalonia.Collections; +using Avalonia.Controls; using DataLayer; using System; using System.Collections.Generic; +using System.ComponentModel; using System.Globalization; using System.Linq; using System.Text; +using System.Threading.Tasks; +using ReactiveUI; +using System.Reflection; +using System.Collections; +using Avalonia.Threading; +using ApplicationServices; +using AudibleUtilities; namespace LibationWinForms.AvaloniaUI.ViewModels { public class ProductsDisplayViewModel : ViewModelBase { - public GridEntryBindingList2 GridEntries { get; set; } - public DataGridCollectionView GridCollectionView { get; set; } - public ProductsDisplayViewModel(IEnumerable dbBooks) - { - GridEntries = new GridEntryBindingList2(CreateGridEntries(dbBooks)); - GridEntries.CollapseAll(); - /* - * Would be nice to use built-in groups, but Avalonia doesn't yet let you customize the row group header. - * - GridCollectionView = new DataGridCollectionView(GridEntries); - GridCollectionView.GroupDescriptions.Add(new CustonGroupDescription()); - */ + /// Number of visible rows has changed + public event EventHandler VisibleCountChanged; + public event EventHandler RemovableCountChanged; + public event EventHandler InitialLoaded; + + private DataGridColumn _currentSortColumn; + + private GridEntryBindingList2 _gridEntries; + private bool _removeColumnVisivle; + public GridEntryBindingList2 GridEntries { get => _gridEntries; private set => this.RaiseAndSetIfChanged(ref _gridEntries, value); } + public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } + + + public List GetVisibleBookEntries() + => GridEntries + .BookEntries() + .Select(lbe => lbe.LibraryBook) + .ToList(); + public IEnumerable GetAllBookEntries() + => GridEntries + .AllItems() + .BookEntries(); + + public ProductsDisplayViewModel() + { + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); + _gridEntries = new GridEntryBindingList2(CreateGridEntries(new List { book })); + return; + } } - public static IEnumerable CreateGridEntries(IEnumerable dbBooks) + public void InitialDisplay(List dbBooks, Views.ProductsGrid.ProductsDisplay2 productsGrid) + { + + try + { + GridEntries = new GridEntryBindingList2(CreateGridEntries(dbBooks)); + GridEntries.CollapseAll(); + + int bookEntryCount = GridEntries.BookEntries().Count(); + + InitialLoaded?.Invoke(this, EventArgs.Empty); + VisibleCountChanged?.Invoke(this, bookEntryCount); + + //Avalonia displays items in the DataConncetion from an internal copy of + //the bound list, not the actual bound list. So we need to reflect to get + //the current display order and set each GridEntry.ListIndex correctly. + var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); + var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); + + GridEntries.CollectionChanged += (s, e) => + { + var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsGrid.productsGrid))).Cast(); + int index = 0; + foreach (var di in displayListGE) + { + di.ListIndex = index++; + } + }; + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); + } + } + + public async Task DisplayBooks(List dbBooks) + { + + try + { + //List is already displayed. Replace all items with new ones, refilter, and re-sort + string existingFilter = GridEntries?.Filter; + var newEntries = CreateGridEntries(dbBooks); + + var existingSeriesEntries = GridEntries.AllItems().SeriesEntries().ToList(); + + await Dispatcher.UIThread.InvokeAsync(() => GridEntries.ReplaceList(newEntries)); + + //We're replacing the list, so preserve usere's existing collapse/expand + //state. When resetting a list, default state is open. + foreach (var series in existingSeriesEntries) + { + var sEntry = GridEntries.InternalList.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); + if (sEntry is SeriesEntrys2 se && !series.Liberate.Expanded) + await Dispatcher.UIThread.InvokeAsync(() => GridEntries.CollapseItem(se)); + } + await Dispatcher.UIThread.InvokeAsync(() => + { + GridEntries.Filter = existingFilter; + ReSort(); + }); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); + } + } + + private static IEnumerable CreateGridEntries(IEnumerable dbBooks) { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) @@ -50,20 +147,169 @@ namespace LibationWinForms.AvaloniaUI.ViewModels } return geList.OrderByDescending(e => e.DateAdded); } - } - class CustonGroupDescription : DataGridGroupDescription - { - public override object GroupKeyFromItem(object item, int level, CultureInfo culture) + + + public async Task Filter(string searchString) { - if (item is SeriesEntrys2 sEntry) - return sEntry; - else if (item is LibraryBookEntry2 lbEntry && lbEntry.Parent is SeriesEntrys2 sEntry2) - return sEntry2; - else return null; + await Dispatcher.UIThread.InvokeAsync(() => + { + int visibleCount = GridEntries.Count; + + if (string.IsNullOrEmpty(searchString)) + GridEntries.RemoveFilter(); + else + GridEntries.Filter = searchString; + + if (visibleCount != GridEntries.Count) + VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + + //Re-sort after filtering + ReSort(); + }); } - public override bool KeysMatch(object groupKey, object itemKey) + + public void ToggleSeriesExpanded(SeriesEntrys2 seriesEntry) { - return base.KeysMatch(groupKey, itemKey); + if (seriesEntry.Liberate.Expanded) + GridEntries.CollapseItem(seriesEntry); + else + GridEntries.ExpandItem(seriesEntry); + + VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + } + + + public void Sort(DataGridColumn sortColumn) + { + //Force the comparer to get the current sort order. We can't + //retrieve it from inside this event handler because Avalonia + //doesn't set the property until after this event. + var comparer = sortColumn.CustomSortComparer as RowComparer; + comparer.SortDirection = null; + + _currentSortColumn = sortColumn; + } + + //Must be invoked on UI thread + private void ReSort() + { + if (_currentSortColumn is null) + { + //Sort ascending and reverse. That's how the comparer is designed to work to be compatible with Avalonia. + var defaultComparer = new RowComparer(ListSortDirection.Descending, nameof(GridEntry2.DateAdded)); + GridEntries.InternalList.Sort(defaultComparer); + GridEntries.InternalList.Reverse(); + GridEntries.ResetCollection(); + } + else + { + _currentSortColumn.Sort(((RowComparer)_currentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); + } + } + + public void DoneRemovingBooks() + { + foreach (var item in GridEntries.AllItems()) + item.PropertyChanged -= Item_PropertyChanged; + RemoveColumnVisivle = false; + } + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); + + if (selectedBooks.Count == 0) + return; + + var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = await MessageBox.ShowConfirmationDialog( + null, + libraryBooks, + $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", + "Remove books from Libation?"); + + if (result != DialogResult.Yes) + return; + + foreach (var book in selectedBooks) + book.PropertyChanged -= Item_PropertyChanged; + + var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + GridEntries.CollectionChanged += BindingList_CollectionChanged; + + //The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(), + //so there's no need to remove books from the grid display here. + var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + + foreach (var b in GetAllBookEntries()) + b.Remove = false; + + RemovableCountChanged?.Invoke(this, 0); + } + + void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + return; + + //After ProductsDisplay2.Display() re-creates the list, + //re-subscribe to all items' PropertyChanged events. + + foreach (var b in GetAllBookEntries()) + b.PropertyChanged += Item_PropertyChanged; + + GridEntries.CollectionChanged -= BindingList_CollectionChanged; + } + + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + foreach (var item in GridEntries.AllItems()) + { + item.Remove = false; + item.PropertyChanged += Item_PropertyChanged; + } + + RemoveColumnVisivle = true; + RemovableCountChanged?.Invoke(this, 0); + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = GetAllBookEntries(); + + foreach (var b in allBooks) + b.Remove = false; + + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); + + var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); + + foreach (var r in removable) + r.Remove = true; + } + catch (Exception ex) + { + MessageBoxLib.ShowAdminAlert( + null, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(GridEntry2.Remove) && sender is LibraryBookEntry2 lbEntry) + { + int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true); + RemovableCountChanged?.Invoke(this, removeCount); + } } } } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs index 594724dc..176d7aea 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.Filter.axaml.cs @@ -35,7 +35,7 @@ namespace LibationWinForms.AvaloniaUI.Views try { - productsDisplay.Filter(filterString); + await _viewModel.ProductsDisplay.Filter(filterString); lastGoodFilter = filterString; } catch (Exception ex) diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs index 159320c2..58185f05 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.QuickFilters.axaml.cs @@ -61,7 +61,7 @@ namespace LibationWinForms.AvaloniaUI.Views public void editQuickFiltersToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => new EditQuickFilters().ShowDialog(); - public async void productsDisplay_Initialized(object sender, EventArgs e) + public async void ProductsDisplay_Initialized(object sender, EventArgs e) { if (QuickFilters.UseDefault) await performFilter(QuickFilters.Filters.FirstOrDefault()); diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs index 8100245d..36c6d298 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.RemoveBooks.axaml.cs @@ -59,18 +59,18 @@ namespace LibationWinForms.AvaloniaUI.Views //For removing books within a filter set, use //Visible Books > Remove from library - productsDisplay.Filter(null); + await _viewModel.ProductsDisplay.Filter(null); _viewModel.RemoveBooksButtonEnabled = true; _viewModel.RemoveButtonsVisible = true; - await productsDisplay.ScanAndRemoveBooksAsync(accounts); + await _viewModel.ProductsDisplay.ScanAndRemoveBooksAsync(accounts); } public async void removeBooksBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { _viewModel.RemoveBooksButtonEnabled = false; - await productsDisplay.RemoveCheckedBooksAsync(); + await _viewModel.ProductsDisplay.RemoveCheckedBooksAsync(); _viewModel.RemoveBooksButtonEnabled = true; } @@ -78,13 +78,13 @@ namespace LibationWinForms.AvaloniaUI.Views { _viewModel.RemoveButtonsVisible = false; - productsDisplay.CloseRemoveBooksColumn(); + _viewModel.ProductsDisplay.DoneRemovingBooks(); //Restore the filter await performFilter(lastGoodFilter); } - public void productsDisplay_RemovableCountChanged(object sender, int removeCount) + public void ProductsDisplay_RemovableCountChanged(object sender, int removeCount) { _viewModel.RemoveBooksButtonText = removeCount switch { diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs index fc1bd580..7241b0de 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.VisibleBooks.axaml.cs @@ -28,7 +28,8 @@ namespace LibationWinForms.AvaloniaUI.Views Serilog.Log.Logger.Information("Begin backing up visible library books"); _viewModel.ProcessQueueViewModel.AddDownloadDecrypt( - productsDisplay + _viewModel + .ProductsDisplay .GetVisibleBookEntries() .UnLiberated() ); @@ -45,7 +46,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); + var visibleLibraryBooks = _viewModel.ProductsDisplay.GetVisibleBookEntries(); var confirmationResult = await MessageBox.ShowConfirmationDialog( this, @@ -68,7 +69,7 @@ namespace LibationWinForms.AvaloniaUI.Views if (result != System.Windows.Forms.DialogResult.OK) return; - var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); + var visibleLibraryBooks = _viewModel.ProductsDisplay.GetVisibleBookEntries(); var confirmationResult = await MessageBox.ShowConfirmationDialog( this, @@ -86,7 +87,7 @@ namespace LibationWinForms.AvaloniaUI.Views public async void removeToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) { - var visibleLibraryBooks = productsDisplay.GetVisibleBookEntries(); + var visibleLibraryBooks = _viewModel.ProductsDisplay.GetVisibleBookEntries(); var confirmationResult = await MessageBox.ShowConfirmationDialog( this, @@ -100,7 +101,7 @@ namespace LibationWinForms.AvaloniaUI.Views var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); await LibraryCommands.RemoveBooksAsync(visibleIds); } - public async void productsDisplay_VisibleCountChanged(object sender, int qty) + public async void ProductsDisplay_VisibleCountChanged(object sender, int qty) { _viewModel.VisibleCount = qty; @@ -108,7 +109,7 @@ namespace LibationWinForms.AvaloniaUI.Views } void setLiberatedVisibleMenuItem() => _viewModel.VisibleNotLiberated - = productsDisplay + = _viewModel.ProductsDisplay .GetVisibleBookEntries() .Count(lb => lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.NotLiberated); } diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml index cef046e9..35a73ae3 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml @@ -11,7 +11,7 @@ x:Class="LibationWinForms.AvaloniaUI.Views.MainWindow" Title="Libation" Name="Form1" - Icon="/AvaloniaUI/Assets/glass-with-glow_16.png"> + Icon="/AvaloniaUI/Assets/libation.ico"> @@ -173,10 +173,8 @@ diff --git a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs index a594eae4..1901d33a 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/MainWindow/MainWindow.axaml.cs @@ -2,7 +2,6 @@ using ApplicationServices; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; -using LibationWinForms.AvaloniaUI.Controls; using System; using LibationWinForms.AvaloniaUI.Views.ProductsGrid; using Avalonia.ReactiveUI; @@ -10,6 +9,7 @@ using LibationWinForms.AvaloniaUI.ViewModels; using LibationFileManager; using DataLayer; using System.Collections.Generic; +using System.Linq; namespace LibationWinForms.AvaloniaUI.Views { @@ -45,13 +45,26 @@ namespace LibationWinForms.AvaloniaUI.Views // misc which belongs in winforms app but doesn't have a UI element Configure_NonUI(); + _viewModel.ProductsDisplay.InitialLoaded += ProductsDisplay_Initialized; + _viewModel.ProductsDisplay.RemovableCountChanged += ProductsDisplay_RemovableCountChanged; + _viewModel.ProductsDisplay.VisibleCountChanged += ProductsDisplay_VisibleCountChanged; + { - this.LibraryLoaded += async (_, dbBooks) => await productsDisplay.Display(dbBooks); - LibraryCommands.LibrarySizeChanged += async (_, _) => await productsDisplay.Display(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + this.LibraryLoaded += MainWindow_LibraryLoaded; + + LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooks(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance); } } + private void MainWindow_LibraryLoaded(object sender, List dbBooks) + { + if (Design.IsDesignMode) + return; + + _viewModel.ProductsDisplay.InitialDisplay(dbBooks, productsDisplay); + } + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs index 99d5242b..237e39f0 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Buttons.xaml.cs @@ -20,16 +20,7 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid if (button.DataContext is SeriesEntrys2 sEntry) { - if (sEntry.Liberate.Expanded) - { - bindingList.CollapseItem(sEntry); - } - else - { - bindingList.ExpandItem(sEntry); - } - - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); + _viewModel.ToggleSeriesExpanded(sEntry); //Expanding and collapsing reset the list, which will cause focus to shift //to the topright cell. Reset focus onto the clicked button's cell. diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs index ddade650..c26bf2bf 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ColumnCustomization.xaml.cs @@ -8,7 +8,7 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid { public partial class ProductsDisplay2 { - ContextMenu contextMenuStrip1 = new ContextMenu(); + private ContextMenu contextMenuStrip1 = new ContextMenu(); private void Configure_ColumnCustomization() { if (Design.IsDesignMode) return; @@ -68,10 +68,6 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, productsGrid.Columns.IndexOf(column)); } - - //Remove column is always first; - removeGVColumn.DisplayIndex = 0; - removeGVColumn.CanUserReorder = false; } private void ContextMenu_ContextMenuOpening(object sender, System.ComponentModel.CancelEventArgs e) diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs deleted file mode 100644 index 80f59ac0..00000000 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Display.xaml.cs +++ /dev/null @@ -1,81 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Threading; -using DataLayer; -using LibationWinForms.AvaloniaUI.ViewModels; -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; - -namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid -{ - public partial class ProductsDisplay2 - { - private void Configure_Display() { } - - public async Task Display(List dbBooks) - { - try - { - if (_viewModel is null) - { - _viewModel = new ProductsDisplayViewModel(dbBooks); - InitialLoaded?.Invoke(this, EventArgs.Empty); - - int bookEntryCount = bindingList.BookEntries().Count(); - VisibleCountChanged?.Invoke(this, bookEntryCount); - - //Avalonia displays items in the DataConncetion from an internal copy of - //the bound list, not the actual bound list. So we need to reflect to get - //the current display order and set each GridEntry.ListIndex correctly. - var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); - var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); - - bindingList.CollectionChanged += (s, e) => - { - var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsGrid))).Cast(); - int index = 0; - foreach (var di in displayListGE) - { - di.ListIndex = index++; - } - }; - - //Assign the viewmodel after we subscribe to CollectionChanged - //so that out handler executes first. - productsGrid.DataContext = _viewModel; - } - else - { - //List is already displayed. Replace all items with new ones, refilter, and re-sort - string existingFilter = _viewModel?.GridEntries?.Filter; - var newEntries = ProductsDisplayViewModel.CreateGridEntries(dbBooks); - - var existingSeriesEntries = bindingList.AllItems().SeriesEntries().ToList(); - - await Dispatcher.UIThread.InvokeAsync(() => bindingList.ReplaceList(newEntries)); - - //We're replacing the list, so preserve usere's existing collapse/expand - //state. When resetting a list, default state is open. - foreach (var series in existingSeriesEntries) - { - var sEntry = bindingList.InternalList.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); - if (sEntry is SeriesEntrys2 se && !series.Liberate.Expanded) - await Dispatcher.UIThread.InvokeAsync(() => bindingList.CollapseItem(se)); - } - await Dispatcher.UIThread.InvokeAsync(() => - { - bindingList.Filter = existingFilter; - ReSort(); - }); - } - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay2)); - } - } - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs deleted file mode 100644 index 17388697..00000000 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Filtering.xaml.cs +++ /dev/null @@ -1,27 +0,0 @@ -using LibationWinForms.AvaloniaUI.ViewModels; -using System; -using System.Linq; - -namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid -{ - public partial class ProductsDisplay2 - { - private void Configure_Filtering() { } - - public void Filter(string searchString) - { - int visibleCount = bindingList.Count; - - if (string.IsNullOrEmpty(searchString)) - bindingList.RemoveFilter(); - else - bindingList.Filter = searchString; - - if (visibleCount != bindingList.Count) - VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count()); - - //Re-sort after filtering - ReSort(); - } - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs deleted file mode 100644 index 1783a360..00000000 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.ScanAndRemove.xaml.cs +++ /dev/null @@ -1,137 +0,0 @@ -using ApplicationServices; -using AudibleUtilities; -using DataLayer; -using LibationWinForms.AvaloniaUI.ViewModels; -using ReactiveUI; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid -{ - public partial class ProductsDisplay2 - { - private void Configure_ScanAndRemove() { } - - private bool RemoveColumnVisible - { - get => removeGVColumn.IsVisible; - set - { - if (value) - { - foreach (var book in bindingList.AllItems()) - book.Remove = false; - } - - removeGVColumn.DisplayIndex = 0; - removeGVColumn.CanUserReorder = value; - removeGVColumn.IsVisible = value; - } - } - - public void CloseRemoveBooksColumn() - { - RemoveColumnVisible = false; - - foreach (var item in bindingList.AllItems()) - item.PropertyChanged -= Item_PropertyChanged; - } - - public async Task RemoveCheckedBooksAsync() - { - var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); - - if (selectedBooks.Count == 0) - return; - - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); - var result = await MessageBox.ShowConfirmationDialog( - null, - libraryBooks, - $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", - "Remove books from Libation?"); - - if (result != DialogResult.Yes) - return; - - foreach (var book in selectedBooks) - book.PropertyChanged -= Item_PropertyChanged; - - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - bindingList.CollectionChanged += BindingList_CollectionChanged; - - //The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(), - //so there's no need to remove books from the grid display here. - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); - - foreach (var b in GetAllBookEntries()) - b.Remove = false; - - RemovableCountChanged?.Invoke(this, 0); - } - - void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) - { - if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) - return; - - //After ProductsDisplay2.Display() re-creates the list, - //re-subscribe to all items' PropertyChanged events. - - foreach (var b in GetAllBookEntries()) - b.PropertyChanged += Item_PropertyChanged; - - bindingList.CollectionChanged -= BindingList_CollectionChanged; - } - - public async Task ScanAndRemoveBooksAsync(params Account[] accounts) - { - RemovableCountChanged?.Invoke(this, 0); - removeGVColumn.IsVisible = true; - - foreach (var item in bindingList.AllItems()) - item.PropertyChanged += Item_PropertyChanged; - - try - { - if (accounts is null || accounts.Length == 0) - return; - - var allBooks = GetAllBookEntries(); - - foreach (var b in allBooks) - b.Remove = false; - - var lib = allBooks - .Select(lbe => lbe.LibraryBook) - .Where(lb => !lb.Book.HasLiberated()); - - var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); - - var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); - - foreach (var r in removable) - r.Remove = true; - } - catch (Exception ex) - { - MessageBoxLib.ShowAdminAlert( - null, - "Error scanning library. You may still manually select books to remove from Libation's library.", - "Error scanning library", - ex); - } - } - - private void Item_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e) - { - if (e.PropertyName == nameof(GridEntry2.Remove) && sender is LibraryBookEntry2 lbEntry) - { - int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true); - RemovableCountChanged?.Invoke(this, removeCount); - } - } - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs deleted file mode 100644 index 08ecddb7..00000000 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.Sorting.xaml.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Avalonia.Controls; -using LibationWinForms.AvaloniaUI.ViewModels; -using System; -using System.ComponentModel; -using System.Linq; - -namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid -{ - public partial class ProductsDisplay2 - { - private DataGridColumn CurrentSortColumn; - private void Configure_Sorting() { } - - private void ReSort() - { - if (CurrentSortColumn is null) - { - //Sort ascending and reverse. That's how the comparer is designed to work to be compatible with Avalonia. - var defaultComparer = new RowComparer(ListSortDirection.Descending, nameof(GridEntry2.DateAdded)); - bindingList.InternalList.Sort(defaultComparer); - bindingList.InternalList.Reverse(); - bindingList.ResetCollection(); - } - else - { - CurrentSortColumn.Sort(((RowComparer)CurrentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); - } - } - - private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) - { - //Force the comparer to get the current sort order. We can't - //retrieve it from inside this event handler because Avalonia - //doesn't set the property until after this event. - var comparer = e.Column.CustomSortComparer as RowComparer; - comparer.SortDirection = null; - - CurrentSortColumn = e.Column; - } - } -} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml index 2753d657..f72822cc 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml @@ -13,11 +13,21 @@ Name="productsGrid" AutoGenerateColumns="False" Items="{Binding GridEntries}" + Sorting="ProductsGrid_Sorting" + CanUserSortColumns="True" CanUserReorderColumns="True"> - + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs index 90402d54..0129b876 100644 --- a/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsGrid/ProductsDisplay2.axaml.cs @@ -1,38 +1,17 @@ -using ApplicationServices; using Avalonia.Controls; using Avalonia.Markup.Xaml; -using Avalonia.Media; using DataLayer; using LibationWinForms.AvaloniaUI.ViewModels; using System; -using System.Collections.Generic; using System.Linq; namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid { public partial class ProductsDisplay2 : UserControl { - /// Number of visible rows has changed - public event EventHandler VisibleCountChanged; - public event EventHandler RemovableCountChanged; public event EventHandler LiberateClicked; - public event EventHandler InitialLoaded; - - public List GetVisibleBookEntries() - => bindingList - .BookEntries() - .Select(lbe => lbe.LibraryBook) - .ToList(); - private IEnumerable GetAllBookEntries() - => bindingList - .AllItems() - .BookEntries(); - - private ProductsDisplayViewModel _viewModel; - private GridEntryBindingList2 bindingList => _viewModel.GridEntries; - - DataGridColumn removeGVColumn; + private ProductsDisplayViewModel _viewModel => DataContext as ProductsDisplayViewModel; public ProductsDisplay2() { @@ -40,34 +19,32 @@ namespace LibationWinForms.AvaloniaUI.Views.ProductsGrid Configure_Buttons(); Configure_ColumnCustomization(); - Configure_Display(); - Configure_Filtering(); - Configure_ScanAndRemove(); - Configure_Sorting(); - foreach ( var column in productsGrid.Columns) + foreach (var column in productsGrid.Columns) { column.CustomSortComparer = new RowComparer(column); } - - if (Design.IsDesignMode) - { - using var context = DbContexts.GetContext(); - var book = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); - productsGrid.DataContext = _viewModel = new ProductsDisplayViewModel(new List { book }); - return; - } - } + + private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) + { + _viewModel.Sort(e.Column); + } + + private void RemoveColumn_PropertyChanged(object sender, Avalonia.AvaloniaPropertyChangedEventArgs e) + { + if (sender is DataGridColumn col && e.Property.Name == nameof(DataGridColumn.IsVisible)) + { + col.DisplayIndex = 0; + col.CanUserReorder = false; + } + } + private void InitializeComponent() { AvaloniaXamlLoader.Load(this); productsGrid = this.FindControl(nameof(productsGrid)); - productsGrid.Sorting += ProductsGrid_Sorting; - productsGrid.CanUserSortColumns = true; - - removeGVColumn = productsGrid.Columns[0]; } } } diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index 33519ff5..659fcd6f 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -52,6 +52,7 @@ +