diff --git a/src/Artemis.UI.Shared/Styles/Artemis.axaml b/src/Artemis.UI.Shared/Styles/Artemis.axaml index 69759ddc7..d32872413 100644 --- a/src/Artemis.UI.Shared/Styles/Artemis.axaml +++ b/src/Artemis.UI.Shared/Styles/Artemis.axaml @@ -32,6 +32,7 @@ + diff --git a/src/Artemis.UI.Shared/Styles/Control.axaml b/src/Artemis.UI.Shared/Styles/Control.axaml new file mode 100644 index 000000000..b20c87ff5 --- /dev/null +++ b/src/Artemis.UI.Shared/Styles/Control.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml index 628846513..3dce3c328 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListView.axaml @@ -14,7 +14,8 @@ CornerRadius="8" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" - Command="{CompiledBinding NavigateToEntry}"> + Command="{CompiledBinding NavigateToEntry}" + IsVisible="{CompiledBinding Entry, Converter={x:Static ObjectConverters.IsNotNull}}"> - + diff --git a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs index 22e378a1d..c38c9aef3 100644 --- a/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Entries/EntryListViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Reactive; +using System.Reactive.Disposables; using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,19 +16,26 @@ namespace Artemis.UI.Screens.Workshop.Entries; public class EntryListViewModel : ActivatableViewModelBase { private readonly IRouter _router; - private readonly ObservableAsPropertyHelper _entryIcon; + private readonly IWorkshopService _workshopService; + private ObservableAsPropertyHelper? _entryIcon; public EntryListViewModel(IGetEntries_Entries_Items entry, IRouter router, IWorkshopService workshopService) { _router = router; + _workshopService = workshopService; Entry = entry; - EntryIcon = workshopService.GetEntryIcon(entry.Id, CancellationToken.None); NavigateToEntry = ReactiveCommand.CreateFromTask(ExecuteNavigateToEntry); + + this.WhenActivated(d => + { + _entryIcon = Observable.FromAsync(GetIcon).ToProperty(this, vm => vm.EntryIcon); + _entryIcon.DisposeWith(d); + }); } public IGetEntries_Entries_Items Entry { get; } - public Task EntryIcon { get; } + public Bitmap? EntryIcon => _entryIcon?.Value; public ReactiveCommand NavigateToEntry { get; } private async Task ExecuteNavigateToEntry() @@ -46,4 +54,12 @@ public class EntryListViewModel : ActivatableViewModelBase throw new ArgumentOutOfRangeException(); } } + + private async Task GetIcon(CancellationToken cancellationToken) + { + // Take at least 100ms to allow the UI to load and make the whole thing smooth + Task iconTask = _workshopService.GetEntryIcon(Entry.Id, cancellationToken); + await Task.Delay(100, cancellationToken); + return await iconTask; + } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs index 340c84ee4..23ddddfa2 100644 --- a/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Profile/ProfileListViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Reactive.Disposables; using System.Reactive.Linq; @@ -13,7 +14,9 @@ using Artemis.UI.Shared.Routing; using Artemis.UI.Shared.Services; using Artemis.UI.Shared.Services.Builders; using Artemis.WebClient.Workshop; +using Avalonia.Threading; using DryIoc.ImTools; +using DynamicData; using ReactiveUI; using StrawberryShake; @@ -26,7 +29,7 @@ public class ProfileListViewModel : RoutableScreen _showPagination; private readonly ObservableAsPropertyHelper _isLoading; - private List? _entries; + private SourceList _entries = new(); private int _page; private int _loadedPage = -1; private int _totalPages = 1; @@ -45,9 +48,17 @@ public class ProfileListViewModel : RoutableScreen vm.Page, vm => vm.LoadedPage, (p, c) => p != c).ToProperty(this, vm => vm.IsLoading); CategoriesViewModel = categoriesViewModel; - + + _entries.Connect() + .ObserveOn(new AvaloniaSynchronizationContext(DispatcherPriority.SystemIdle)) + .Transform(getEntryListViewModel) + .Bind(out ReadOnlyObservableCollection entries) + .Subscribe(); + Entries = entries; + // Respond to page changes this.WhenAnyValue(vm => vm.Page).Skip(1).Subscribe(p => Task.Run(() => router.Navigate($"workshop/profiles/{p}"))); + // Respond to filter changes this.WhenActivated(d => CategoriesViewModel.WhenAnyValue(vm => vm.CategoryFilters).Skip(1).Subscribe(_ => { @@ -65,11 +76,7 @@ public class ProfileListViewModel : RoutableScreen? Entries - { - get => _entries; - set => RaiseAndSetIfChanged(ref _entries, value); - } + public ReadOnlyObservableCollection Entries { get; } public int Page { @@ -99,8 +106,11 @@ public class ProfileListViewModel : RoutableScreen _getEntryListViewModel(n)).ToList(); TotalPages = (int) Math.Ceiling(entries.Data.Entries.TotalCount / (double) EntriesPerPage); + _entries.Edit(e => + { + e.Clear(); + e.AddRange(entries.Data.Entries.Items); + }); } else TotalPages = 1; diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml new file mode 100644 index 000000000..2c9ed6559 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml @@ -0,0 +1,60 @@ + + + + + + + + + + + + by + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs new file mode 100644 index 000000000..8490b69b2 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultView.axaml.cs @@ -0,0 +1,14 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Search; + +public partial class SearchResultView : ReactiveUserControl +{ + public SearchResultView() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs new file mode 100644 index 000000000..8136ce350 --- /dev/null +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchResultViewModel.cs @@ -0,0 +1,32 @@ +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Artemis.UI.Shared; +using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; +using Avalonia.Media.Imaging; +using ReactiveUI; + +namespace Artemis.UI.Screens.Workshop.Search; + +public class SearchResultViewModel : ActivatableViewModelBase +{ + private readonly IWorkshopService _workshopService; + private ObservableAsPropertyHelper? _entryIcon; + + public SearchResultViewModel(ISearchEntries_SearchEntries entry, IWorkshopService workshopService) + { + _workshopService = workshopService; + + Entry = entry; + this.WhenActivated(d => + { + _entryIcon = Observable.FromAsync(c => _workshopService.GetEntryIcon(Entry.Id, c)).ToProperty(this, vm => vm.EntryIcon); + _entryIcon.DisposeWith(d); + }); + } + + public ISearchEntries_SearchEntries Entry { get; } + public Bitmap? EntryIcon => _entryIcon?.Value; +} \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml index 171d4c400..8ac1d728f 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchView.axaml @@ -11,47 +11,31 @@ - + - - - - - - - - - - - - - - - - - - - - - - + + - + windowing:AppWindow.AllowInteractionInTitleBar="True" /> + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs index b3370ee8b..e27504efc 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewModel.cs @@ -7,32 +7,40 @@ using Artemis.UI.Screens.Workshop.CurrentUser; using Artemis.UI.Shared; using Artemis.UI.Shared.Routing; using Artemis.WebClient.Workshop; +using Artemis.WebClient.Workshop.Services; using ReactiveUI; +using Serilog; using StrawberryShake; namespace Artemis.UI.Screens.Workshop.Search; public class SearchViewModel : ViewModelBase { - public CurrentUserViewModel CurrentUserViewModel { get; } + private readonly ILogger _logger; private readonly IRouter _router; private readonly IWorkshopClient _workshopClient; + private readonly IWorkshopService _workshopService; private EntryType? _entryType; - private ISearchEntries_SearchEntries? _selectedEntry; + private bool _isLoading; + private SearchResultViewModel? _selectedEntry; - public SearchViewModel(IWorkshopClient workshopClient, IRouter router, CurrentUserViewModel currentUserViewModel) + public SearchViewModel(ILogger logger, IWorkshopClient workshopClient, IWorkshopService workshopService, IRouter router, CurrentUserViewModel currentUserViewModel) { - CurrentUserViewModel = currentUserViewModel; + _logger = logger; _workshopClient = workshopClient; + _workshopService = workshopService; _router = router; + CurrentUserViewModel = currentUserViewModel; SearchAsync = ExecuteSearchAsync; this.WhenAnyValue(vm => vm.SelectedEntry).WhereNotNull().Subscribe(NavigateToEntry); } + public CurrentUserViewModel CurrentUserViewModel { get; } + public Func>> SearchAsync { get; } - public ISearchEntries_SearchEntries? SelectedEntry + public SearchResultViewModel? SelectedEntry { get => _selectedEntry; set => RaiseAndSetIfChanged(ref _selectedEntry, value); @@ -44,13 +52,19 @@ public class SearchViewModel : ViewModelBase set => RaiseAndSetIfChanged(ref _entryType, value); } - private void NavigateToEntry(ISearchEntries_SearchEntries entry) + public bool IsLoading + { + get => _isLoading; + set => RaiseAndSetIfChanged(ref _isLoading, value); + } + + private void NavigateToEntry(SearchResultViewModel searchResult) { string? url = null; - if (entry.EntryType == WebClient.Workshop.EntryType.Profile) - url = $"workshop/profiles/{entry.Id}"; - if (entry.EntryType == WebClient.Workshop.EntryType.Layout) - url = $"workshop/layouts/{entry.Id}"; + if (searchResult.Entry.EntryType == WebClient.Workshop.EntryType.Profile) + url = $"workshop/profiles/{searchResult.Entry.Id}"; + if (searchResult.Entry.EntryType == WebClient.Workshop.EntryType.Layout) + url = $"workshop/layouts/{searchResult.Entry.Id}"; if (url != null) Task.Run(() => _router.Navigate(url)); @@ -58,10 +72,25 @@ public class SearchViewModel : ViewModelBase private async Task> ExecuteSearchAsync(string? input, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(input)) - return new List(); - - IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); - return results.Data?.SearchEntries.Cast() ?? new List(); + try + { + if (string.IsNullOrWhiteSpace(input) || input.Length < 2) + return new List(); + + IsLoading = true; + IOperationResult results = await _workshopClient.SearchEntries.ExecuteAsync(input, EntryType, cancellationToken); + return results.Data?.SearchEntries.Select(e => new SearchResultViewModel(e, _workshopService) as object) ?? new List(); + } + catch (Exception e) + { + if (e is not TaskCanceledException) + _logger.Error(e, "Failed to execute search"); + } + finally + { + IsLoading = false; + } + + return new List(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml index 584a83924..c489f0406 100644 --- a/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml +++ b/src/Artemis.UI/Screens/Workshop/Search/SearchViewStyles.axaml @@ -19,7 +19,7 @@ -