/* * Copyright (c) 2026 Proton AG * * This file is part of ProtonVPN. * * ProtonVPN is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * ProtonVPN is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with ProtonVPN. If not, see . */ using System.Collections.Specialized; using System.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ProtonVPN.Client.Common.Collections; using ProtonVPN.Client.Logic.Searches.Contracts; using ProtonVPN.Client.Models.Features.LocationExclusion; using ProtonVPN.Client.Settings.Contracts.Models; namespace ProtonVPN.Client.UI.Main.Settings.Pages.ConnectionPreferences; public partial class ConnectionPreferencesSettingsPageViewModel { private string _normalizedSearchInput = string.Empty; [ObservableProperty] private bool _isLocationSelectorOpen; [ObservableProperty] private string _searchInput = string.Empty; public bool IsInSearchMode => !string.IsNullOrWhiteSpace(_normalizedSearchInput); protected readonly SmartNotifyObservableCollection Locations = []; protected IEnumerable AvailableLocations => Locations.Where(IsAvailable); public bool HasAvailableLocations => AvailableLocations.Any(); protected IOrderedEnumerable AvailableCountries => AvailableLocations.Where(l => l is ExcludableCountryItem || l is IExcludableChildItem child && child.ParentCountry.HasMultipleAvailableChildren) .OrderBy(l => l.AbsoluteSortOrder); protected IOrderedEnumerable AvailableSearchResults => AvailableLocations.Where(Matches) .OrderBy(l => l.Type) .ThenBy(l => l.RelativeSortOrder); public IOrderedEnumerable ExcludableLocations => IsInSearchMode ? AvailableSearchResults : AvailableCountries; public bool HasExcludableLocations => ExcludableLocations.Any(); public IOrderedEnumerable ExcludedLocations => Locations.Where(IsExcluded) .OrderBy(l => l.Type) .ThenBy(l => l.RelativeSortOrder); public bool HasExcludedLocations => ExcludedLocations.Any(); public string ExcludableLocationSelectorText => HasAvailableLocations ? Localizer.Get("Settings_Connection_ExcludedLocations_Search") : Localizer.Get("Settings_Connection_ExcludedLocations_NoLocations"); private void InvalidateExcludableLocations() { Locations.Reset( _excludeLocationsManager.GetExcludableLocations()); } private bool IsAvailable(IExcludableLocation location) { return !location.IsLocationExcluded; } private bool IsExcluded(IExcludableLocation location) { if (location is ExcludableLocationGroup group) { return group.Locations.Any(IsExcluded); } return location.IsExcluded; } private List GetExcludedLocations() { // Ordering the excluded locations by country code, then state, then city // to ensure a consistent order in the settings. return ExcludedLocations .Where(l => l is not ExcludableLocationGroup) .Select(l => l.ToExcludedLocation()) .OrderBy(l => l.CountryCode) .ThenBy(l => l.StateName) .ThenBy(l => l.CityName) .ToList(); } private void OnLocationItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { OnPropertyChanged(nameof(AvailableLocations)); OnPropertyChanged(nameof(HasAvailableLocations)); OnPropertyChanged(nameof(ExcludableLocations)); OnPropertyChanged(nameof(HasExcludableLocations)); OnPropertyChanged(nameof(ExcludedLocations)); OnPropertyChanged(nameof(HasExcludedLocations)); OnPropertyChanged(nameof(ExcludableLocationSelectorText)); CloseLocationSelector(); } private void OnLocationsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { OnPropertyChanged(nameof(AvailableLocations)); OnPropertyChanged(nameof(HasAvailableLocations)); OnPropertyChanged(nameof(ExcludableLocations)); OnPropertyChanged(nameof(HasExcludableLocations)); OnPropertyChanged(nameof(ExcludedLocations)); OnPropertyChanged(nameof(HasExcludedLocations)); OnPropertyChanged(nameof(ExcludableLocationSelectorText)); } private bool Matches(IExcludableLocation location) { return location switch { ExcludableLocationGroup group => group.Locations.Any(l => IsAvailable(l) && Matches(l)), ExcludableCountryItem country => SearchMatcher.MatchesCountry(country.Location, country.DisplayName, _normalizedSearchInput), ExcludableStateItem state => SearchMatcher.MatchesState(state.DisplayName, _normalizedSearchInput), ExcludableCityItem city => SearchMatcher.MatchesCity(city.DisplayName, _normalizedSearchInput), _ => false }; } [RelayCommand] private void OpenLocationSelector() { SearchInput = string.Empty; IsLocationSelectorOpen = true; } [RelayCommand] private void CloseLocationSelector() { IsLocationSelectorOpen = false; SearchInput = string.Empty; } partial void OnSearchInputChanged(string value) { _normalizedSearchInput = value.NormalizeInput() ?? string.Empty; OnPropertyChanged(nameof(IsInSearchMode)); OnPropertyChanged(nameof(ExcludableLocations)); OnPropertyChanged(nameof(HasExcludableLocations)); } }