using Xamarin.Forms; using TINK.View; using TINK.Model.Stations; 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; 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; using TINK.Model.Stations.StationNS; using TINK.Model.Bikes.BikeInfoNS.BC; namespace TINK.ViewModel.Contact { public class SelectStationPageViewModel : INotifyPropertyChanged { /// Holds the count of custom icons centered. 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; 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; IGeolocationService 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, IGeolocationService 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."); IsConnected = TinkApp.GetIsConnected(); } 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 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 already occurred in past. Log.ForContext().Error("Position 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); } } /// 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++) { 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 = GetResourceNameColorPart(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 GetResourceNameColorPart(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 { IsProcessWithRunningProcessView = 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.MessageHintTitle, AppResources.ErrorMapCenterNoLocationPermissionOpenDialog, AppResources.MessageAnswerYes, AppResources.MessageAnswerNo); if (dialogResult) { // User decided to give access to locations permissions. PermissionsService.OpenAppSettings(); ActionText = string.Empty; IsProcessWithRunningProcessView = false; IsMapPageEnabled = true; return; } } if (status == Status.Granted) { ActionText = AppResources.ActivityTextCenterMap; IGeolocation 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 already any pins to the map // i.e detects 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.ErrorMapPageAuthcookieUndefined, 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.StationsAll, resultStationsAndBikes.Response.BikesOccupied); // Update pins color form count of bikes located at station. UpdatePinsColor(colors); Log.ForContext().Verbose("Update pins color done."); Exception = resultStationsAndBikes.Exception; ActionText = string.Empty; IsProcessWithRunningProcessView = false; IsMapPageEnabled = true; } catch (Exception l_oException) { Log.ForContext().Error($"An error occurred opening select station page.\r\n{l_oException.Message}"); IsProcessWithRunningProcessView = false; await ViewService.DisplayAlert( AppResources.ErrorPageNotLoadedTitle, $"{AppResources.ErrorPageNotLoaded}\r\n{l_oException.Message}", AppResources.MessageAnswerOk); IsMapPageEnabled = true; } } /// Moves map and scales visible region depending on active filter. public static void MoveAndScale( Action moveToRegionDelegate, Uri activeUri, IGeolocation 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) { 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 updated in background task. await ViewService.ShowPage("//ContactPage"); IsMapPageEnabled = true; ActionText = string.Empty; } catch (Exception exception) { IsMapPageEnabled = true; ActionText = string.Empty; Log.ForContext().Error("Fehler beim Öffnen der Ansicht \"Fahrräder an Station\" aufgetreten. {Exception}", exception); await ViewService.DisplayAlert( AppResources.ErrorPageNotLoadedTitle, $"{AppResources.ErrorPageNotLoaded}\r\n{exception.Message}", AppResources.MessageAnswerOk); } } /// /// Gets the list of station color for all stations. /// /// Station id list to get color for. /// Station object dictionary to get count of available bike from for each station. /// Bike collection to get count of reserved/ rented bikes from for each station. /// internal static IList GetStationColors( IEnumerable stationsId, IEnumerable stations, IEnumerable bikesReserved) { if (stationsId == null) { Log.ForContext().Debug("No stations available to update color for."); return new List(); } if (stations == null) { Log.ForContext().Error("No stations info available to get count of bikes available to determine whether a pin is green or not."); } if (bikesReserved == null) { Log.ForContext().Error("No bikes info available to determine whether a pins is light blue or not."); } // Get state for each station. var colors = new List(); foreach (var stationId in stationsId) { // Get color of given station. if (bikesReserved?.Where(x => x.StationId == stationId).Count() > 0) { // There is at least one requested or booked bike colors.Add(Color.LightBlue); continue; } if (stations?.FirstOrDefault(x => x.Id == stationId)?.AvailableBikesCount > 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 isProcessWithRunningProcessView = false; /// /// True if any action can be performed (request and cancel request) /// public bool IsProcessWithRunningProcessView { get => isProcessWithRunningProcessView; set { if (value == isProcessWithRunningProcessView) return; Log.ForContext().Debug($"Switch value of {nameof(isProcessWithRunningProcessView)} to {value}."); isProcessWithRunningProcessView = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsProcessWithRunningProcessView))); } } /// 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); //} return ActionText ?? string.Empty; } } } }