using Xamarin.Forms; using TINK.View; using TINK.Model.Station; using System; using System.Linq; using TINK.Model.Bikes; using TINK.Repository.Exception; using TINK.Model; using Serilog; using System.Collections.Generic; using System.Threading.Tasks; using System.ComponentModel; using Xamarin.Forms.GoogleMaps; using System.Collections.ObjectModel; #if USEFLYOUT using TINK.View.MasterDetail; #endif using TINK.Services.Permissions; using Xamarin.Essentials; using System.Threading; using TINK.MultilingualResources; using TINK.Repository; using TINK.Services.Geolocation; using TINK.Model.State; using TINK.ViewModel.Map; namespace TINK.ViewModel.Contact { public class SelectStationPageViewModel : INotifyPropertyChanged { /// Holds the count of custom icons availalbe. private const int CUSTOM_ICONS_COUNT = 30; /// Reference on view service to show modal notifications and to perform navigation. private IViewService ViewService { get; } /// /// Holds the exception which occurred getting bikes occupied information. /// private Exception m_oException; /// /// Service to query/ manage permissions (location) of the app. /// private ILocationPermission PermissionsService { get; } /// /// Service to manage bluetooth stack. /// private Plugin.BLE.Abstractions.Contracts.IBluetoothLE BluetoothService { get; set; } /// Notifies view about changes. public event PropertyChangedEventHandler PropertyChanged; /// Reference on the tink app instance. private ITinkApp TinkApp { get; } /// Delegate to perform navigation. private INavigation m_oNavigation; #if USEFLYOUT /// Delegate to perform navigation. private INavigationMasterDetail m_oNavigationMasterDetail; #endif private ObservableCollection pins; public ObservableCollection Pins { get { if (pins == null) pins = new ObservableCollection(); // If view model is not binding context pins collection must be set programmatically. return pins; } set => pins = value; } /// Delegate to move map to region. private Action m_oMoveToRegionDelegate; /// False if user tabed on station marker to show bikes at a given station. private bool isMapPageEnabled = false; IGeolocation GeolocationService { get; } /// False if user tabed on station marker to show bikes at a given station. public bool IsMapPageEnabled { get => isMapPageEnabled; private set { if (isMapPageEnabled == value) return; isMapPageEnabled = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMapPageEnabled))); } } /// Prevents an invalid instane to be created. /// Reference to tink app model. /// Delegate to center map and set zoom level. /// View service to notify user. /// Interface to navigate. public SelectStationPageViewModel( ITinkApp tinkApp, ILocationPermission permissionsService, Plugin.BLE.Abstractions.Contracts.IBluetoothLE bluetoothService, IGeolocation geolocationService, Action moveToRegionDelegate, IViewService viewService, INavigation navigation) { TinkApp = tinkApp ?? throw new ArgumentException("Can not instantiate map page view model- object. No tink app object available."); PermissionsService = permissionsService ?? throw new ArgumentException($"Can not instantiate {nameof(SelectStationPageViewModel)}. Permissions service object must never be null."); BluetoothService = bluetoothService ?? throw new ArgumentException($"Can not instantiate {nameof(SelectStationPageViewModel)}. Bluetooth service object must never be null."); GeolocationService = geolocationService ?? throw new ArgumentException($"Can not instantiate {nameof(SelectStationPageViewModel)}. Geolocation service object must never be null."); m_oMoveToRegionDelegate = moveToRegionDelegate ?? throw new ArgumentException("Can not instantiate map page view model- object. No move delegate available."); ViewService = viewService ?? throw new ArgumentException("Can not instantiate map page view model- object. No view available."); m_oNavigation = navigation ?? throw new ArgumentException("Can not instantiate map page view model- object. No navigation service available."); #if USEFLYOUT m_oNavigationMasterDetail = new EmptyNavigationMasterDetail(); #endif IsConnected = TinkApp.GetIsConnected(); } #if USEFLYOUT /// Delegate to perform navigation. public INavigationMasterDetail NavigationMasterDetail { set { m_oNavigationMasterDetail = value; } } #endif public Command PinClickedCommand => new Command( args => { OnStationClicked(args.Pin.Tag.ToString()); args.Handled = true; // Prevents map to be centered to selected pin. }); /// /// One time setup: Sets pins into map and connects to events. /// private async void InitializePins(StationDictionary stations) { // Add pins to stations. Log.ForContext().Debug($"Request to draw {stations.Count} pins."); foreach (var station in stations) { if (station.Position == null) { // There should be no reason for a position object to be null but this alreay occurred in past. Log.ForContext().Error("Postion object of station {@l_oStation} is null.", station); continue; } var l_oPin = new Pin { Position = new Xamarin.Forms.GoogleMaps.Position(station.Position.Latitude, station.Position.Longitude), Label = long.TryParse(station.Id, out long stationId) && stationId > CUSTOM_ICONS_COUNT ? station.GetStationName() : string.Empty, // Stations with custom icons have already a id marker. No need for a label. Tag = station.Id, IsVisible = false, // Set to false to prevent showing default icons (flickering). }; Pins.Add(l_oPin); } //Add blue dot for showing current location of user var status = await PermissionsService.CheckStatusAsync(); if (status == Status.Granted) { Location currentLocation = null; try { currentLocation = await GeolocationService.GetAsync(); } catch (Exception ex) { Log.ForContext().Error("Getting location failed. {Exception}", ex); } if (currentLocation != null) { var currentLocationPin = new Pin() { Position = new Xamarin.Forms.GoogleMaps.Position(currentLocation.Latitude, currentLocation.Longitude), Label = "currentLocationPin", Type = PinType.Place, Tag = "NotClickable", Icon = BitmapDescriptorFactory.FromBundle(currentLocationPinName) }; Pins.Add(currentLocationPin); } } } public string currentLocationPinName = $"Location_Pin{(DeviceInfo.Platform == DevicePlatform.Android ? ".png" : string.Empty)}"; /// Update all stations from TINK. /// List of colors to apply. private void UpdatePinsColor(IList stationsColorList) { Log.ForContext().Debug($"Starting update of stations pins color for {stationsColorList.Count} stations..."); // Update colors of pins. for (int pinIndex = 0; pinIndex < stationsColorList.Count; pinIndex++) { if (Pins[pinIndex].Tag.ToString() == "NotClickable") { Pins[pinIndex].Icon = BitmapDescriptorFactory.FromBundle(currentLocationPinName); } else { var indexPartPrefix = int.TryParse(Pins[pinIndex].Tag.ToString(), out int stationId) && stationId <= CUSTOM_ICONS_COUNT ? $"{stationId}" // there is a station marker with index letter for given station id : "Open"; // there is no station marker. Use open marker. var colorPartPrefix = GetRessourceNameColorPart(stationsColorList[pinIndex]); var l_iName = $"{indexPartPrefix.ToString().PadLeft(2, '0')}_{colorPartPrefix}{(DeviceInfo.Platform == DevicePlatform.Android ? ".png" : string.Empty)}"; try { Pins[pinIndex].Icon = BitmapDescriptorFactory.FromBundle(l_iName); } catch (Exception l_oException) { Log.ForContext().Error("Station icon {l_strName} can not be loaded. {@l_oException}.", l_oException); Pins[pinIndex].Label = stationId.ToString(); Pins[pinIndex].Icon = BitmapDescriptorFactory.DefaultMarker(stationsColorList[pinIndex]); } } Pins[pinIndex].IsVisible = true; } var pinsCount = Pins.Count; for (int pinIndex = stationsColorList.Count; pinIndex < pinsCount; pinIndex++) { Log.ForContext().Error($"Unexpected count of pins detected. Expected {stationsColorList.Count} but is {pinsCount}."); Pins[pinIndex].IsVisible = false; } Log.ForContext().Debug("Update of stations pins color done."); } /// Gets the color related part of the ressrouce name. /// Color to get name for. /// Resource name. private static string GetRessourceNameColorPart(Color color) { if (color == Color.Blue) { return "Blue"; } if (color == Color.Green) { return "Green"; } if (color == Color.LightBlue) { return "LightBlue"; } if (color == Color.Red) { return "Red"; } return color.ToString(); } /// /// Invoked when page is shown. /// Starts update process. /// /// Holds map page filter settings. /// Holds polling management object. /// If true whats new page will be shown. public async Task OnAppearing() { try { IsRunning = true; // Process map page. Log.ForContext().Information( $"Current UI language is {Thread.CurrentThread.CurrentUICulture.Name}."); if (Pins.Count <= 0) { // Move and scale before getting stations and bikes which takes some time. if (TinkApp.CenterMapToCurrentLocation) { ActionText = AppResources.ActivityTextRequestingLocationPermissions; // Check location permission var status = await PermissionsService.CheckStatusAsync(); if (!GeolocationService.IsSimulation && status != Status.Granted) { var dialogResult = await ViewService.DisplayAlert( AppResources.MessageTitleHint, AppResources.MessageCenterMapLocationPermissionOpenDialog, AppResources.MessageAnswerYes, AppResources.MessageAnswerNo); if (dialogResult) { // User decided to give access to locations permissions. PermissionsService.OpenAppSettings(); ActionText = ""; IsRunning = false; IsMapPageEnabled = true; return; } } if (status == Status.Granted) { ActionText = AppResources.ActivityTextCenterMap; Location currentLocation = null; try { currentLocation = TinkApp.CenterMapToCurrentLocation ? await GeolocationService.GetAsync() : null; } catch (Exception ex) { Log.ForContext().Error("Getting location failed. {Exception}", ex); } MoveAndScale(m_oMoveToRegionDelegate, TinkApp.Uris.ActiveUri, currentLocation); } } } ActionText = AppResources.ActivityTextMapLoadingStationsAndBikes; IsConnected = TinkApp.GetIsConnected(); var resultStationsAndBikes = await TinkApp.GetConnector(IsConnected).Query.GetBikesAndStationsAsync(); TinkApp.Stations = resultStationsAndBikes.Response.StationsAll; TinkApp.ResourceUrls = resultStationsAndBikes.GeneralData.ResourceUrls; if (Pins.Count > 0 && Pins.Count != resultStationsAndBikes.Response.StationsAll.Count) { // Either // - user logged in/ logged out which might lead to more/ less stations beeing available // - new stations were added/ existing ones remove Pins.Clear(); } // Check if there are alreay any pins to the map // i.e detecte first call of member OnAppearing after construction if (Pins.Count <= 0) { // Map was not yet initialized. // Get stations from Copri Log.ForContext().Verbose("No pins detected on page."); if (resultStationsAndBikes.Response.StationsAll.CopriVersion < CopriCallsStatic.UnsupportedVersionLower) { await ViewService.DisplayAlert( AppResources.MessageWaring, string.Format(AppResources.MessageCopriVersionIsOutdated, TinkApp.Flavor.GetDisplayName()), AppResources.MessageAnswerOk); Log.ForContext().Error($"Outdated version of app detected. Version expected is {resultStationsAndBikes.Response.StationsAll.CopriVersion}."); } if (resultStationsAndBikes.Response.StationsAll.CopriVersion >= CopriCallsStatic.UnsupportedVersionUpper) { await ViewService.DisplayAlert( AppResources.MessageWaring, string.Format(AppResources.MessageAppVersionIsOutdated, TinkApp.Flavor.GetDisplayName()), AppResources.MessageAnswerOk); Log.ForContext().Error($"Outdated version of app detected. Version expected is {resultStationsAndBikes.Response.StationsAll.CopriVersion}."); } // Set pins to their positions on map. InitializePins(resultStationsAndBikes.Response.StationsAll); Log.ForContext().Verbose("Update of pins done."); } if (resultStationsAndBikes.Exception?.GetType() == typeof(AuthcookieNotDefinedException)) { Log.ForContext().Error("Map page is shown (probable for the first time after startup of app) and COPRI auth cookie is not defined. {@l_oException}", resultStationsAndBikes.Exception); // COPRI reports an auth cookie error. await ViewService.DisplayAlert( AppResources.MessageWaring, AppResources.MessageMapPageErrorAuthcookieUndefined, AppResources.MessageAnswerOk); IsConnected = TinkApp.GetIsConnected(); await TinkApp.GetConnector(IsConnected).Command.DoLogout(); TinkApp.ActiveUser.Logout(); } // Update pin colors. Log.ForContext().Verbose("Starting update pins color..."); var colors = GetStationColors( Pins.Select(x => x.Tag.ToString()).ToList(), resultStationsAndBikes.Response.Bikes); // Update pins color form count of bikes located at station. UpdatePinsColor(colors); Log.ForContext().Verbose("Update pins color done."); Exception = resultStationsAndBikes.Exception; ActionText = ""; IsRunning = false; IsMapPageEnabled = true; } catch (Exception l_oException) { Log.ForContext().Error($"An error occurred opening select station page.\r\n{l_oException.Message}"); IsRunning = false; await ViewService.DisplayAlert( "Fehler", $"Beim Anzeigen der Fahrradstandorte- Seite ist ein Fehler aufgetreten.\r\n{l_oException.Message}", "OK"); IsMapPageEnabled = true; } } /// Moves map and scales visible region depending on active filter. public static void MoveAndScale( Action moveToRegionDelegate, Uri activeUri, Location currentLocation = null) { if (currentLocation != null) { // Move to current location. moveToRegionDelegate(MapSpan.FromCenterAndRadius( new Xamarin.Forms.GoogleMaps.Position(currentLocation.Latitude, currentLocation.Longitude), Distance.FromKilometers(1.0))); return; } // Center map to Freiburg moveToRegionDelegate(MapSpan.FromCenterAndRadius( new Xamarin.Forms.GoogleMaps.Position(47.995865, 7.815086), Distance.FromKilometers(2.9))); } /// User clicked on a bike. /// Id of station user clicked on. public async void OnStationClicked(string selectedStationId) { //Make shure currentLocationPin can not be clicked if (selectedStationId != "NotClickable") { try { Log.ForContext().Information($"User taped station {selectedStationId}."); // Lock action to prevent multiple instances of "BikeAtStation" being opened. IsMapPageEnabled = false; TinkApp.SelectedStation = TinkApp.Stations.FirstOrDefault(x => x.Id == selectedStationId) ?? new Station(selectedStationId, new List(), null); // Station might not be in list StationDictinaly because this list is not updatd in background task. #if TRYNOTBACKSTYLE m_oNavigation.ShowPage( typeof(BikesAtStationPage), p_strStationName); #else #if USEFLYOUT // Show page. ViewService.ShowPage(ViewTypes.ContactPage, AppResources.MarkingContactPageTitle); #else await ViewService.ShowPage("//ContactPage"); #endif IsMapPageEnabled = true; ActionText = ""; } catch (Exception exception) { IsMapPageEnabled = true; ActionText = ""; Log.ForContext().Error("Fehler beim Öffnen der Ansicht \"Fahrräder an Station\" aufgetreten. {Exception}", exception); await ViewService.DisplayAlert( "Fehler", $"Fehler beim Öffnen der Ansicht \"Fahrräder an Station\" aufgetreten. {exception.Message}", "OK"); } #endif } else { return; } } /// /// Gets the list of station color for all stations. /// /// Station id list to get color for. /// private static IList GetStationColors( IEnumerable stationsId, BikeCollection bikesAll) { if (stationsId == null) { Log.ForContext().Debug("No stations available to update color for."); return new List(); } if (bikesAll == null) { // If object is null an error occurred querrying bikes availalbe or bikes occpied which results in an unknown state. Log.ForContext().Error("No bikes available to determine pins color."); return new List(stationsId.Select(x => Color.Blue)); } // Get state for each station. var colors = new List(); foreach (var stationId in stationsId) { if (stationId != "NotClickable") { // Get color of given station. var bikesAtStation = bikesAll.Where(x => x.StationId == stationId).ToList(); if (bikesAtStation.FirstOrDefault(x => x.State.Value.IsOccupied()) != null) { // There is at least one requested or booked bike colors.Add(Color.LightBlue); continue; } if (bikesAtStation.ToList().Count > 0) { // There is at least one bike available colors.Add(Color.Green); continue; } colors.Add(Color.Red); } } return colors; } /// /// Exception which occurred getting bike information. /// public Exception Exception { get { return m_oException; } private set { var statusInfoText = StatusInfoText; m_oException = value; if (statusInfoText == StatusInfoText) { // Nothing to do because value did not change. return; } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusInfoText))); } } /// Holds info about current action. private string actionText; /// Holds info about current action. private string ActionText { get => actionText; set { var statusInfoText = StatusInfoText; actionText = value; if (statusInfoText == StatusInfoText) { // Nothing to do because value did not change. return; } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusInfoText))); } } /// Used to block more than on copri requests at a given time. private bool isRunning = false; /// /// True if any action can be performed (request and cancel request) /// public bool IsRunning { get => isRunning; set { if (value == isRunning) return; Log.ForContext().Debug($"Switch value of {nameof(isRunning)} to {value}."); isRunning = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRunning))); } } /// Holds information whether app is connected to web or not. private bool? isConnected = null; /// Exposes the is connected state. private bool IsConnected { get => isConnected ?? false; set { var statusInfoText = StatusInfoText; isConnected = value; if (statusInfoText == StatusInfoText) { // Nothing to do. return; } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(StatusInfoText))); } } /// Holds the status information text. public string StatusInfoText { get { if (Exception != null) { // An error occurred getting data from copri. return Exception.GetShortErrorInfoText(TinkApp.IsReportLevelVerbose); } if (!IsConnected) { return AppResources.ActivityTextConnectionStateOffline; } return ActionText ?? string.Empty; } } } }