using Xamarin.Forms;
using TINK.View;
using TINK.Model.Station;
using System;
using System.Linq;
using TINK.Model.Bike;
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.ViewModel.Info;
using TINK.Repository;
using TINK.Model.Services.Geolocation;
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;
Model.Services.Geolocation.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 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);
}
}
/// 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 = 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)
{
ActionText = AppResources.ActivityTextRequestingLocationPermissions;
// Check location permission
var status = await PermissionsService.CheckStatusAsync();
if (TinkApp.CenterMapToCurrentLocation
&& !GeolocationService.IsSimulation
&& status != Status.Granted)
{
var permissionResult = await PermissionsService.RequestAsync();
if (permissionResult != 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;
}
}
}
// Move and scale before getting stations and bikes which takes some time.
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;
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, ContactPageViewModel.GetAppName(TinkApp.Uris.ActiveUri)),
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, ContactPageViewModel.GetAppName(TinkApp.Uris.ActiveUri)),
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);
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 switching view TINK/ Konrad.\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)
{
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
// Show page.
ViewService.ShowPage(ViewTypes.ContactPage, AppResources.MarkingContactPageTitle);
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
}
///
/// 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)
{
// Get color of given station.
var bikesAtStation = bikesAll.Where(x => x.CurrentStation == stationId).ToList();
if (bikesAtStation.FirstOrDefault(x => x.State.Value != Model.State.InUseStateEnum.Disposable) != 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 TinkApp.IsReportLevelVerbose
? Exception.GetShortErrorInfoText()
: AppResources.ActivityTextException;
}
if (!IsConnected)
{
return AppResources.ActivityTextConnectionStateOffline;
}
return ActionText ?? string.Empty;
}
}
}
}