diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico b/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico new file mode 100644 index 00000000..d3e00443 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico differ 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 @@ +