using System; using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using LANCommander.Launcher.Services; using LANCommander.Launcher.ViewModels.Components; using LANCommander.Launcher.Data.Models; using LANCommander.Launcher.Services; using LANCommander.Launcher.Services.Import; using LANCommander.Launcher.Settings.Enums; using LANCommander.SDK.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace LANCommander.Launcher.ViewModels; public partial class ShellViewModel : ViewModelBase { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly INavigationService _navigationService; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ContentViewTitle))] [NotifyPropertyChangedFor(nameof(IsRefreshVisible))] [NotifyPropertyChangedFor(nameof(IsTitlebarTinted))] private ViewModelBase? _contentView; [ObservableProperty] private bool _isLoading; [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanGoOnline))] [NotifyPropertyChangedFor(nameof(IsRefreshVisible))] private bool _isOfflineMode; public string ContentViewTitle => ContentView switch { DepotViewModel _ => "Depot", DepotBrowseViewModel db => db.BrowseTitle, GamesListViewModel _ => "Depot", LibraryViewModel _ => "My Library", DepotGameDetailViewModel gd => !string.IsNullOrEmpty(gd.Title) ? gd.Title : string.Empty, GameDetailViewModel gd => !string.IsNullOrEmpty(gd.Title) ? gd.Title : string.Empty, SettingsViewModel _ => "Settings", DownloadQueueViewModel _ => "Downloads", _ => string.Empty }; public bool IsRefreshVisible => (ContentView is GamesCollectionViewModel || ContentView is DepotViewModel) && !IsOfflineMode && !IsSyncing; public bool IsTitlebarTinted => true; partial void OnContentViewChanged(ViewModelBase? oldValue, ViewModelBase? newValue) { if (oldValue is GameDetailViewModel oldDetail) oldDetail.PropertyChanged -= OnGameDetailPropertyChanged; if (newValue is GameDetailViewModel newDetail) newDetail.PropertyChanged += OnGameDetailPropertyChanged; if (oldValue is DepotBrowseViewModel oldBrowse) oldBrowse.PropertyChanged -= OnDepotBrowsePropertyChanged; if (newValue is DepotBrowseViewModel newBrowse) newBrowse.PropertyChanged += OnDepotBrowsePropertyChanged; } private void OnGameDetailPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(GameDetailViewModel.Title)) OnPropertyChanged(nameof(ContentViewTitle)); } private void OnDepotBrowsePropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(DepotBrowseViewModel.BrowseTitle)) OnPropertyChanged(nameof(ContentViewTitle)); } [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSyncing))] [NotifyPropertyChangedFor(nameof(IsImportIndeterminate))] [NotifyPropertyChangedFor(nameof(ImportProgress))] [NotifyPropertyChangedFor(nameof(IsRefreshVisible))] private bool _isImportRunning; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsImportIndeterminate))] [NotifyPropertyChangedFor(nameof(ImportProgress))] private int _importIndex; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsImportIndeterminate))] [NotifyPropertyChangedFor(nameof(ImportProgress))] private int _importTotal; public bool IsSyncing => IsImportRunning; public bool IsImportIndeterminate => IsImportRunning && ImportTotal == 0; public double ImportProgress => ImportTotal > 0 ? (double)ImportIndex / ImportTotal : 0; [ObservableProperty] private bool _isCheckingConnection; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsLibraryActive))] private bool _isDepotActive = true; public bool IsLibraryActive => !IsDepotActive; public bool CanGoOnline => IsOfflineMode && !IsCheckingConnection; // Child view models public DepotViewModel DepotViewModel { get; private set; } = null!; public DepotBrowseViewModel DepotBrowseViewModel { get; private set; } = null!; public DepotGameDetailViewModel DepotGameDetailViewModel { get; private set; } = null!; public GamesListViewModel GamesListViewModel { get; private set; } = null!; public LibraryViewModel LibraryViewModel { get; private set; } = null!; public GameDetailViewModel GameDetailViewModel { get; private set; } = null!; public DownloadQueueViewModel DownloadQueue { get; private set; } = null!; public SettingsViewModel SettingsViewModel { get; private set; } = null!; public ProfileViewModel Profile { get; private set; } = null!; public ChatWindowViewModel Chat { get; private set; } = null!; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasUnreadChat))] private int _chatUnreadCount; public bool HasUnreadChat => ChatUnreadCount > 0; [ObservableProperty] private bool _isNavigating; // Tracks the most recent depot browse filter so the view can be refreshed after library changes private (string? Genre, string? Tag, string? Collection, string? Search) _lastDepotBrowseFilter; public event EventHandler? LogoutRequested; public ShellViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; _logger = serviceProvider.GetRequiredService>(); _navigationService = serviceProvider.GetRequiredService(); _navigationService.PropertyChanged += OnNavigationPropertyChanged; // Initialize Profile early so titlebar bindings never hit null Profile = new ProfileViewModel(serviceProvider); } private void OnNavigationPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(INavigationService.CurrentView)) ContentView = _navigationService.CurrentView; else if (e.PropertyName == nameof(INavigationService.IsNavigating)) IsNavigating = _navigationService.IsNavigating; } public void SetOfflineMode(bool offline) { IsOfflineMode = offline; _logger.LogInformation("Offline mode set to: {IsOffline}", offline); } public async Task InitializeAsync() { _logger.LogInformation("ShellViewModel initializing... (Offline: {IsOffline})", IsOfflineMode); DepotViewModel = new DepotViewModel(_serviceProvider); DepotBrowseViewModel = new DepotBrowseViewModel(_serviceProvider); DepotGameDetailViewModel = new DepotGameDetailViewModel(_serviceProvider); GamesListViewModel = new GamesListViewModel(_serviceProvider); LibraryViewModel = new LibraryViewModel(_serviceProvider); GameDetailViewModel = new GameDetailViewModel(_serviceProvider); DownloadQueue = new DownloadQueueViewModel(_serviceProvider); SettingsViewModel = new SettingsViewModel(_serviceProvider); // Profile is already created in the constructor; reuse it here Chat = new ChatWindowViewModel(_serviceProvider); await Chat.InitializeAsync(); Chat.PropertyChanged += (_, e) => { if (e.PropertyName == nameof(ChatWindowViewModel.TotalUnreadCount)) ChatUnreadCount = Chat.TotalUnreadCount; }; DepotViewModel.IsOfflineMode = IsOfflineMode; DepotBrowseViewModel.IsOfflineMode = IsOfflineMode; DepotGameDetailViewModel.IsOfflineMode = IsOfflineMode; GamesListViewModel.IsOfflineMode = IsOfflineMode; LibraryViewModel.IsOfflineMode = IsOfflineMode; GameDetailViewModel.IsOfflineMode = IsOfflineMode; DepotViewModel.GameSelected += OnDepotGameSelected; DepotViewModel.SearchRequested += OnSearchRequested; DepotViewModel.BrowseByGenreRequested += OnDepotBrowseByGenre; DepotViewModel.BrowseByTagRequested += OnDepotBrowseByTag; DepotViewModel.BrowseByCollectionRequested += OnDepotBrowseByCollection; DepotViewModel.BrowseAllRequested += OnDepotBrowseAll; DepotBrowseViewModel.GameSelected += OnDepotGameSelected; GamesListViewModel.GameSelected += OnGameSelected; LibraryViewModel.GameSelected += OnGameSelected; DepotGameDetailViewModel.LibraryChanged += OnLibraryChanged; DepotGameDetailViewModel.InstallRequested += OnInstallRequested; DepotGameDetailViewModel.SearchRequested += OnSearchRequested; GameDetailViewModel.LibraryChanged += OnLibraryChanged; GameDetailViewModel.InstallRequested += OnInstallRequested; GameDetailViewModel.SearchRequested += OnSearchRequested; DownloadQueue.InstallCompleted += OnInstallCompleted; DownloadQueue.Initialize(); await ImportAndLoadAsync(); _ = Profile.LoadAsync(IsOfflineMode); // Open to library if the user has games, otherwise show the depot if (LibraryViewModel.Games.Count > 0) ShowLibrary(); else ShowDepot(); _logger.LogInformation("ShellViewModel initialization complete"); } private async Task ImportAndLoadAsync() { IsLoading = true; // Load cached data from local DB first so the user sees games immediately try { await LibraryViewModel.LoadGamesAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to load cached library data"); } finally { IsLoading = false; } // Load depot data in the background so the UI is not blocked by network calls _ = LoadDepotInBackgroundAsync(); // Import in the background, then refresh if (!IsOfflineMode) _ = ImportInBackgroundAsync(); } private async Task LoadDepotInBackgroundAsync() { try { // Load sequentially to avoid file access conflicts when downloading shared media await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to load depot data"); } } private async Task ImportInBackgroundAsync() { IsImportRunning = true; try { await Task.Run(async () => { using var scope = _serviceProvider.CreateScope(); var importService = scope.ServiceProvider.GetRequiredService(); // Track progress on the UI thread importService.OnImportStarted.EventRaised += OnImportStarted; importService.OnImportStatusUpdate.EventRaised += OnImportStatusUpdated; importService.OnImportComplete.EventRaised += OnImportCompleted; await importService.ImportLibraryAsync(); }); } catch (Exception ex) { _logger.LogError(ex, "Background import failed"); } finally { await Dispatcher.UIThread.InvokeAsync(() => { ImportIndex = 0; ImportTotal = 0; IsImportRunning = false; }); } } private async Task OnImportStarted(ImportStatusUpdate update) { await Dispatcher.UIThread.InvokeAsync(() => { ImportIndex = 0; ImportTotal = update.Total; }); } private async Task OnImportStatusUpdated(ImportStatusUpdate update) { await Dispatcher.UIThread.InvokeAsync(() => { ImportIndex = update.Index; }); } private async Task OnImportCompleted(ImportStatusUpdate update) { // Run the heavy data loading off the UI thread try { await LoadViewModelsAsync(); } catch (Exception ex) { _logger.LogError(ex, "Failed to reload view models after import"); } await Dispatcher.UIThread.InvokeAsync(() => { ImportIndex = 0; ImportTotal = 0; }); } private async Task LoadViewModelsAsync() { await GamesListViewModel.LoadGamesAsync(); await LibraryViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); } [RelayCommand] private async Task RefreshAsync() { if (IsOfflineMode || IsSyncing) return; _ = ImportInBackgroundAsync(); } [RelayCommand] private async Task TryGoOnlineAsync() { if (!IsOfflineMode) return; IsCheckingConnection = true; try { using var scope = _serviceProvider.CreateScope(); var connectionClient = scope.ServiceProvider.GetRequiredService(); var authService = scope.ServiceProvider.GetRequiredService(); if (await connectionClient.PingAsync()) { await authService.Login(); if (connectionClient.IsConnected()) { IsOfflineMode = false; DepotViewModel.IsOfflineMode = false; DepotBrowseViewModel.IsOfflineMode = false; DepotGameDetailViewModel.IsOfflineMode = false; GamesListViewModel.IsOfflineMode = false; LibraryViewModel.IsOfflineMode = false; GameDetailViewModel.IsOfflineMode = false; await ImportAndLoadAsync(); return; } } _logger.LogWarning("Server still unreachable"); } catch (Exception ex) { _logger.LogError(ex, "Failed to go online"); } finally { IsCheckingConnection = false; } } [RelayCommand] private void GoOffline() { if (IsOfflineMode) return; IsOfflineMode = true; DepotViewModel.IsOfflineMode = true; DepotBrowseViewModel.IsOfflineMode = true; DepotGameDetailViewModel.IsOfflineMode = true; GamesListViewModel.IsOfflineMode = true; LibraryViewModel.IsOfflineMode = true; GameDetailViewModel.IsOfflineMode = true; } [RelayCommand] private async Task LogoutAsync() { using var scope = _serviceProvider.CreateScope(); var authService = scope.ServiceProvider.GetRequiredService(); await authService.Logout(); LogoutRequested?.Invoke(this, EventArgs.Empty); } [RelayCommand] private void ShowDownloadQueue() => _navigationService.NavigateTo(DownloadQueue); [RelayCommand] private void ShowDepot() { IsDepotActive = true; _navigationService.NavigateTo(DepotViewModel, clearHistory: true); } [RelayCommand] private void ShowLibrary() { IsDepotActive = false; _navigationService.NavigateTo(LibraryViewModel, clearHistory: true); } /// Game selected from the depot context (DepotView or DepotBrowseView). private void OnDepotGameSelected(object? sender, SDK.Models.Game game) { _navigationService.NavigateTo(DepotGameDetailViewModel, initializeAsync: () => DepotGameDetailViewModel.LoadGameAsync(game)); } /// Game selected from the library context. private void OnGameSelected(object? sender, SDK.Models.Game game) { GameDetailViewModel.FromLibrary = !IsDepotActive; _navigationService.NavigateTo(GameDetailViewModel, initializeAsync: () => GameDetailViewModel.LoadGameAsync(game)); } public async Task NavigateToGameByIdAsync(Guid gameId) { try { using var scope = _serviceProvider.CreateScope(); if (!IsOfflineMode) { var client = scope.ServiceProvider.GetRequiredService(); var game = await client.GetAsync(gameId); if (game != null) { OnGameSelected(this, game); return; } } // Fallback: load from local database var gameService = scope.ServiceProvider.GetRequiredService(); var localGame = await gameService.GetAsync(gameId); if (localGame != null) { var sdkGame = new SDK.Models.Game { Id = localGame.Id, Title = localGame.Title ?? "Unknown", SortTitle = localGame.SortTitle, Description = localGame.Description, ReleasedOn = localGame.ReleasedOn ?? DateTime.MinValue, }; OnGameSelected(this, sdkGame); } } catch (Exception ex) { _logger.LogError(ex, "Failed to navigate to game {GameId}", gameId); } } private void OnSearchRequested(object? sender, string term) { if (IsDepotActive) { var matchedGenre = GamesListViewModel.AvailableGenres .FirstOrDefault(g => string.Equals(g.Name, term, StringComparison.OrdinalIgnoreCase)); if (matchedGenre != null) NavigateToDepotBrowse(genre: matchedGenre.Name); else NavigateToDepotBrowse(search: term); } else { var matchedGenre = LibraryViewModel.AvailableGenres .FirstOrDefault(g => string.Equals(g.Name, term, StringComparison.OrdinalIgnoreCase)); if (matchedGenre != null) { LibraryViewModel.SearchText = string.Empty; LibraryViewModel.SelectedGenre = matchedGenre; } else { LibraryViewModel.SelectedGenre = null; LibraryViewModel.SearchText = term; } ShowLibrary(); } } private void OnDepotBrowseByGenre(object? sender, string genreName) { NavigateToDepotBrowse(genre: genreName); } private void OnDepotBrowseByTag(object? sender, string tagName) { NavigateToDepotBrowse(tag: tagName); } private void OnDepotBrowseByCollection(object? sender, string collectionName) { NavigateToDepotBrowse(collection: collectionName); } private void OnDepotBrowseAll(object? sender, EventArgs e) { NavigateToDepotBrowse(); } /// Initialize and navigate to the depot-only browse grid with an optional pre-filter. private void NavigateToDepotBrowse(string? genre = null, string? tag = null, string? collection = null, string? search = null) { _lastDepotBrowseFilter = (genre, tag, collection, search); DepotBrowseViewModel.Initialize(GamesListViewModel.GetAllGames(), genre, tag, collection, search); IsDepotActive = true; _navigationService.NavigateTo(DepotBrowseViewModel); } private async void OnLibraryChanged(object? sender, EventArgs e) { await LibraryViewModel.LoadGamesAsync(); await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); // Re-initialize the browse view with fresh data so "in library" badges update if (_navigationService.CurrentView == DepotBrowseViewModel) DepotBrowseViewModel.Initialize( GamesListViewModel.GetAllGames(), _lastDepotBrowseFilter.Genre, _lastDepotBrowseFilter.Tag, _lastDepotBrowseFilter.Collection, _lastDepotBrowseFilter.Search); } private void OnInstallRequested(object? sender, EventArgs e) => DownloadQueue.Show(); private async void OnInstallCompleted(object? sender, Guid gameId) { await LibraryViewModel.LoadGamesAsync(); await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); // Re-initialize the browse view with fresh data so install status updates if (_navigationService.CurrentView == DepotBrowseViewModel) DepotBrowseViewModel.Initialize( GamesListViewModel.GetAllGames(), _lastDepotBrowseFilter.Genre, _lastDepotBrowseFilter.Tag, _lastDepotBrowseFilter.Collection, _lastDepotBrowseFilter.Search); if (DepotGameDetailViewModel.Id == gameId) await DepotGameDetailViewModel.RefreshInstallStatusAsync(); if (GameDetailViewModel.Id == gameId) await GameDetailViewModel.RefreshInstallStatusAsync(); } [RelayCommand] private void ShowSettings() { SettingsViewModel.Load(); _navigationService.NavigateTo(SettingsViewModel); } [RelayCommand] private async Task OpenChatAsync() { if (Chat == null) return; // Lazy-load threads on first open await Chat.LoadThreadsAsync(); // Raise event so the view layer can show the window OpenChatRequested?.Invoke(this, EventArgs.Empty); } public event EventHandler? OpenChatRequested; }