2021-05-13 20:03:07 +02:00
|
|
|
|
using Serilog;
|
|
|
|
|
using Serilog.Events;
|
|
|
|
|
using System;
|
|
|
|
|
using System.Collections.Generic;
|
|
|
|
|
using System.ComponentModel;
|
|
|
|
|
using System.Threading.Tasks;
|
|
|
|
|
using TINK.Model;
|
|
|
|
|
using TINK.Model.Connector;
|
2021-06-26 20:57:55 +02:00
|
|
|
|
using TINK.Repository.Exception;
|
2021-05-13 20:03:07 +02:00
|
|
|
|
using TINK.Model.Services.Geolocation;
|
|
|
|
|
using TINK.Settings;
|
|
|
|
|
using TINK.View;
|
|
|
|
|
using TINK.ViewModel.Map;
|
|
|
|
|
using TINK.ViewModel.Settings;
|
|
|
|
|
using System.Linq;
|
|
|
|
|
using TINK.Model.User.Account;
|
|
|
|
|
using TINK.Services.BluetoothLock;
|
|
|
|
|
using Xamarin.Forms;
|
2021-06-26 20:57:55 +02:00
|
|
|
|
using TINK.Services;
|
2021-05-13 20:03:07 +02:00
|
|
|
|
|
|
|
|
|
namespace TINK.ViewModel
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// View model for settings.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class SettingsPageViewModel : INotifyPropertyChanged
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
2021-08-01 17:24:15 +02:00
|
|
|
|
/// Reference on view service to show modal notifications and to perform navigation.
|
2021-05-13 20:03:07 +02:00
|
|
|
|
/// </summary>
|
|
|
|
|
private IViewService m_oViewService;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Fired if a property changes.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public event PropertyChangedEventHandler PropertyChanged;
|
|
|
|
|
|
|
|
|
|
/// <summary> Object to manage update of view model objects from Copri.</summary>
|
|
|
|
|
private IPollingUpdateTaskManager m_oViewUpdateManager;
|
|
|
|
|
|
|
|
|
|
/// <summary> List of copri server uris.</summary>
|
|
|
|
|
public CopriServerUriListViewModel CopriServerUriList { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Manages selection of locks services.</summary>
|
|
|
|
|
public LocksServicesViewModel LocksServices { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Manages selection of geolocation services.</summary>
|
|
|
|
|
public ServicesViewModel GeolocationServices { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Object to switch logging level.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private LogEventLevel m_oMinimumLogEventLevel;
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
/// <summary> Gets a value indicating whether reporting level is verbose or not.</summary>
|
|
|
|
|
public bool IsReportLevelVerbose { get; set; }
|
|
|
|
|
|
2021-05-13 20:03:07 +02:00
|
|
|
|
/// <summary> List of copri server uris.</summary>
|
|
|
|
|
public ServicesViewModel Themes { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Reference on the tink app instance. </summary>
|
|
|
|
|
private ITinkApp TinkApp { get; }
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
IServicesContainer<IGeolocation> GeoloctionServicesContainer { get; }
|
|
|
|
|
|
2021-05-13 20:03:07 +02:00
|
|
|
|
/// <summary> Constructs a settings page view model object.</summary>
|
|
|
|
|
/// <param name="tinkApp"> Reference to tink app model.</param>
|
|
|
|
|
/// <param name="p_oUser"></param>
|
|
|
|
|
/// <param name="p_oDevice"></param>
|
|
|
|
|
/// <param name="p_oFilterGroup">Filter to apply on stations and bikes.</param>
|
|
|
|
|
/// <param name="p_oUris">Available copri server host uris including uri to use for next start.</param>
|
|
|
|
|
/// <param name="p_oPolling"> Holds whether to poll or not and the periode leght is polling is on. </param>
|
|
|
|
|
/// <param name="p_oDefaultPollingPeriode">Default polling periode lenght.</param>
|
|
|
|
|
/// <param name="p_oMinimumLogEventLevel">Controls logging level.</param>
|
|
|
|
|
/// <param name="p_oViewService">Interface to view</param>
|
|
|
|
|
public SettingsPageViewModel(
|
|
|
|
|
ITinkApp tinkApp,
|
2021-06-26 20:57:55 +02:00
|
|
|
|
IServicesContainer<IGeolocation> geoloctionServicesContainer,
|
2021-05-13 20:03:07 +02:00
|
|
|
|
IViewService p_oViewService)
|
|
|
|
|
{
|
|
|
|
|
TinkApp = tinkApp
|
|
|
|
|
?? throw new ArgumentException("Can not instantiate settings page view model- object. No tink app object available.");
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
GeoloctionServicesContainer = geoloctionServicesContainer
|
|
|
|
|
?? throw new ArgumentException($"Can not instantiate {nameof(SettingsPageViewModel)}- object. Geolocation services container object must not be null.");
|
|
|
|
|
|
2021-05-13 20:03:07 +02:00
|
|
|
|
m_oViewService = p_oViewService
|
|
|
|
|
?? throw new ArgumentException("Can not instantiate settings page view model- object. No user view service available.");
|
|
|
|
|
|
|
|
|
|
m_oMinimumLogEventLevel = TinkApp.MinimumLogEventLevel;
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
IsReportLevelVerbose = TinkApp.IsReportLevelVerbose;
|
|
|
|
|
|
2021-05-13 20:03:07 +02:00
|
|
|
|
CenterMapToCurrentLocation = TinkApp.CenterMapToCurrentLocation;
|
|
|
|
|
|
|
|
|
|
ExternalFolder = TinkApp.ExternalFolder;
|
|
|
|
|
|
|
|
|
|
IsLogToExternalFolderVisible = !string.IsNullOrEmpty(ExternalFolder);
|
|
|
|
|
|
|
|
|
|
LogToExternalFolderDisplayValue = IsLogToExternalFolderVisible ? TinkApp.LogToExternalFolder : false;
|
|
|
|
|
|
|
|
|
|
IsSiteCachingOnDisplayValue = TinkApp.IsSiteCachingOn;
|
|
|
|
|
|
|
|
|
|
if (TinkApp.Uris == null
|
|
|
|
|
|| TinkApp.Uris.Uris.Count <= 0)
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("Can not instantiate settings page view model- object. No uri- list available.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrEmpty(TinkApp.NextActiveUri.AbsoluteUri))
|
|
|
|
|
{
|
|
|
|
|
throw new ArgumentException("Can not instantiate settings page view model- object. Next active uri must not be null or empty.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GroupFilter = new SettingsBikeFilterViewModel(
|
|
|
|
|
TinkApp.FilterGroupSetting,
|
|
|
|
|
TinkApp.ActiveUser.IsLoggedIn ? TinkApp.ActiveUser.Group : null);
|
|
|
|
|
|
|
|
|
|
m_oViewUpdateManager = new IdlePollingUpdateTaskManager();
|
|
|
|
|
|
|
|
|
|
Polling = new PollingViewModel(TinkApp.Polling);
|
|
|
|
|
|
|
|
|
|
ExpiresAfterTotalSeconds = Convert.ToInt32(TinkApp.ExpiresAfter.TotalSeconds);
|
|
|
|
|
|
|
|
|
|
CopriServerUriList = new CopriServerUriListViewModel(TinkApp.Uris);
|
|
|
|
|
|
|
|
|
|
Themes = new ServicesViewModel(
|
|
|
|
|
TinkApp.Themes.Select(x => x.GetType().FullName),
|
|
|
|
|
new Dictionary<string, string> {
|
2021-11-07 19:42:59 +01:00
|
|
|
|
{ typeof(Themes.Konrad).FullName, "Mein konrad" /* display name in picker */},
|
|
|
|
|
{ typeof(Themes.ShareeBike).FullName, "sharee.bike" /* display name in picker */},
|
|
|
|
|
{ typeof(Themes.LastenradBayern).FullName, "LastenradBayern" /* display name in picker */}
|
2021-05-13 20:03:07 +02:00
|
|
|
|
},
|
|
|
|
|
TinkApp.Themes.Active.GetType().FullName);
|
|
|
|
|
|
|
|
|
|
Themes.PropertyChanged += OnThemesChanged;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
LocksServices = new LocksServicesViewModel(
|
|
|
|
|
TinkApp.LocksServices.Active.TimeOut.MultiConnect,
|
|
|
|
|
new ServicesViewModel(
|
|
|
|
|
TinkApp.LocksServices,
|
|
|
|
|
new Dictionary<string, string> {
|
|
|
|
|
{ typeof(LocksServiceInReach).FullName, "Simulation - AllLocksInReach" },
|
|
|
|
|
{ typeof(LocksServiceOutOfReach).FullName, "Simulation - AllLocksOutOfReach" },
|
|
|
|
|
{ typeof(Services.BluetoothLock.BLE.LockItByScanServiceEventBased).FullName, "Live - Scan" },
|
|
|
|
|
{ typeof(Services.BluetoothLock.BLE.LockItByScanServicePolling).FullName, "Live - Scan (Polling)" },
|
|
|
|
|
{ typeof(Services.BluetoothLock.BLE.LockItByGuidService).FullName, "Live - Guid" },
|
|
|
|
|
/* { typeof(Services.BluetoothLock.Arendi.LockItByGuidService).FullName, "Live - Guid (Arendi)" },
|
|
|
|
|
{ typeof(Services.BluetoothLock.Arendi.LockItByScanService).FullName, "Live - Scan (Arendi)" },
|
|
|
|
|
{ typeof(Services.BluetoothLock.Bluetoothle.LockItByGuidService).FullName, "Live - Guid (Ritchie)" }, */
|
|
|
|
|
},
|
|
|
|
|
TinkApp.LocksServices.Active.GetType().FullName));
|
|
|
|
|
|
|
|
|
|
GeolocationServices = new ServicesViewModel(
|
2021-06-26 20:57:55 +02:00
|
|
|
|
GeoloctionServicesContainer.Select(x => x.GetType().FullName),
|
2021-05-13 20:03:07 +02:00
|
|
|
|
new Dictionary<string, string> {
|
|
|
|
|
{ typeof(LastKnownGeolocationService).FullName, "Smartdevice-LastKnowGeolocation" },
|
|
|
|
|
{ typeof(GeolocationService).FullName, "Smartdevice-MediumAccuracy" },
|
|
|
|
|
{ typeof(SimulatedGeolocationService).FullName, "Simulation-AlwaysSamePosition" } },
|
2021-06-26 20:57:55 +02:00
|
|
|
|
GeoloctionServicesContainer.Active.GetType().FullName);
|
2021-05-13 20:03:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// User switches scheme.
|
|
|
|
|
/// </summary>
|
|
|
|
|
private void OnThemesChanged(object sender, PropertyChangedEventArgs e)
|
|
|
|
|
{
|
|
|
|
|
// Set active theme (leads to switch of title)
|
|
|
|
|
TinkApp.Themes.SetActive(Themes.Active);
|
|
|
|
|
|
|
|
|
|
// Switch theme.
|
|
|
|
|
ICollection<ResourceDictionary> mergedDictionaries = Application.Current.Resources.MergedDictionaries;
|
|
|
|
|
if (mergedDictionaries == null)
|
|
|
|
|
{
|
|
|
|
|
Log.ForContext<SettingsPageViewModel>().Error("No merged dictionary available.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mergedDictionaries.Clear();
|
|
|
|
|
|
|
|
|
|
if (Themes.Active == typeof(Themes.Konrad).FullName)
|
|
|
|
|
{
|
|
|
|
|
mergedDictionaries.Add(new Themes.Konrad());
|
|
|
|
|
}
|
|
|
|
|
else if (Themes.Active == typeof(Themes.ShareeBike).FullName)
|
|
|
|
|
{
|
|
|
|
|
mergedDictionaries.Add(new Themes.ShareeBike());
|
2021-11-07 19:42:59 +01:00
|
|
|
|
}
|
|
|
|
|
else if (Themes.Active == typeof(Themes.LastenradBayern).FullName)
|
|
|
|
|
{
|
|
|
|
|
mergedDictionaries.Add(new Themes.LastenradBayern());
|
|
|
|
|
}
|
2021-05-13 20:03:07 +02:00
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
Log.ForContext<SettingsPageViewModel>().Debug($"No theme {Themes.Active} found.");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary> Holds information whether app is connected to web or not. </summary>
|
|
|
|
|
private bool? isConnected = null;
|
|
|
|
|
|
|
|
|
|
/// <summary>Exposes the is connected state. </summary>
|
|
|
|
|
private bool IsConnected
|
|
|
|
|
{
|
|
|
|
|
get => isConnected ?? false;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
isConnected = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary> Holds a value indicating whether group filters GUI are visible or not</summary>
|
|
|
|
|
public bool IsGroupFilterVisible => GroupFilter.Count > 0;
|
|
|
|
|
|
|
|
|
|
/// <summary> Holds the bike types to fade out or show</summary>
|
|
|
|
|
public SettingsBikeFilterViewModel GroupFilter { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Gets the value to path were copri mock files are located (for debugging purposes).</summary>
|
|
|
|
|
public string ExternalFolder { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the value to path were copri mock files are located (for debugging purposes).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string InternalPath => TinkApp.SettingsFileFolder;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Gets the value of device identifier (for debugging purposes).
|
|
|
|
|
/// </summary>
|
|
|
|
|
public string DeviceIdentifier
|
|
|
|
|
{
|
2021-06-26 20:57:55 +02:00
|
|
|
|
get { return TinkApp.SmartDevice.Identifier; }
|
2021-05-13 20:03:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Invoked when page is shutdown.
|
|
|
|
|
/// Currently invoked by code behind, would be nice if called by XAML in future versions.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public async Task OnDisappearing()
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
Log.ForContext<SettingsPageViewModel>().Information($"Entering {nameof(OnDisappearing)}...");
|
|
|
|
|
|
|
|
|
|
// Update model values.
|
|
|
|
|
TinkApp.NextActiveUri = CopriServerUriList.NextActiveUri;
|
|
|
|
|
|
|
|
|
|
TinkApp.Polling = new PollingParameters(
|
|
|
|
|
new TimeSpan(0, 0, Polling.PeriodeTotalSeconds),
|
|
|
|
|
Polling.IsActivated);
|
|
|
|
|
|
|
|
|
|
TinkApp.ExpiresAfter = TimeSpan.FromSeconds(ExpiresAfterTotalSeconds);
|
|
|
|
|
|
|
|
|
|
var filterGroup = GroupFilter.ToDictionary(x => x.Key, x => x.State);
|
|
|
|
|
TinkApp.FilterGroupSetting = new GroupFilterSettings(filterGroup.Count > 0 ? filterGroup : null);
|
|
|
|
|
|
|
|
|
|
// Update map page filter.
|
|
|
|
|
// Reasons for which map page filter has to be updated:
|
|
|
|
|
// - user activated/ deactivated a group (TINKCorpi/ TINKSms/ Konrad)
|
|
|
|
|
// - user logged off
|
|
|
|
|
TinkApp.GroupFilterMapPage =
|
|
|
|
|
GroupFilterMapPageHelper.CreateUpdated(
|
|
|
|
|
TinkApp.GroupFilterMapPage,
|
|
|
|
|
TinkApp.ActiveUser.DoFilter(TinkApp.FilterGroupSetting.DoFilter()));
|
|
|
|
|
|
|
|
|
|
TinkApp.CenterMapToCurrentLocation = CenterMapToCurrentLocation;
|
|
|
|
|
|
|
|
|
|
if (IsLogToExternalFolderVisible)
|
|
|
|
|
{
|
|
|
|
|
// If no external folder is available do not update model value.
|
|
|
|
|
TinkApp.LogToExternalFolder = LogToExternalFolderDisplayValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TinkApp.IsSiteCachingOn = IsSiteCachingOnDisplayValue;
|
|
|
|
|
|
|
|
|
|
TinkApp.MinimumLogEventLevel = m_oMinimumLogEventLevel; // Update value to be serialized.
|
|
|
|
|
TinkApp.UpdateLoggingLevel(m_oMinimumLogEventLevel); // Update logging server.
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
TinkApp.IsReportLevelVerbose = IsReportLevelVerbose;
|
|
|
|
|
|
2021-05-13 20:03:07 +02:00
|
|
|
|
TinkApp.LocksServices.SetActive(LocksServices.Services.Active);
|
|
|
|
|
|
2021-06-26 20:57:55 +02:00
|
|
|
|
GeoloctionServicesContainer.SetActive(GeolocationServices.Active);
|
2021-05-13 20:03:07 +02:00
|
|
|
|
|
|
|
|
|
TinkApp.LocksServices.SetTimeOut(TimeSpan.FromSeconds(LocksServices.ConnectTimeoutSec));
|
|
|
|
|
|
|
|
|
|
// Persist settings in case app is closed directly.
|
|
|
|
|
TinkApp.Save();
|
|
|
|
|
|
|
|
|
|
TinkApp.UpdateConnector();
|
|
|
|
|
|
|
|
|
|
await m_oViewUpdateManager.StopUpdatePeridically();
|
|
|
|
|
|
|
|
|
|
Log.ForContext<SettingsPageViewModel>().Information($"{nameof(OnDisappearing)} done.");
|
|
|
|
|
}
|
|
|
|
|
catch (Exception l_oException)
|
|
|
|
|
{
|
|
|
|
|
await m_oViewService.DisplayAlert(
|
|
|
|
|
"Fehler",
|
|
|
|
|
$"Ein unerwarteter Fehler ist aufgetreten. \r\n{l_oException.Message}",
|
|
|
|
|
"OK");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary> True if there is an error message to display.</summary>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Exception which occurred getting bike information.
|
|
|
|
|
/// </summary>
|
|
|
|
|
protected Exception Exception { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// If true debug controls are visible, false if not.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public Permissions DebugLevel
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
return (Exception == null || Exception is WebConnectFailureException)
|
|
|
|
|
? TinkApp.ActiveUser.DebugLevel
|
|
|
|
|
: Permissions.None;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Polling periode.</summary>
|
|
|
|
|
public PollingViewModel Polling { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Active logging level</summary>
|
|
|
|
|
public string SelectedLoggingLevel
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
return m_oMinimumLogEventLevel.ToString();
|
|
|
|
|
}
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
if (!Enum.TryParse(value, out LogEventLevel l_oNewLevel))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
m_oMinimumLogEventLevel = l_oNewLevel;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool CenterMapToCurrentLocation { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Holds either
|
|
|
|
|
/// - a value indicating whether to use external folder (e.g. SD card)/ or internal folder for storing log-files or
|
|
|
|
|
/// - is false if external folder is not available
|
|
|
|
|
/// </summary>
|
|
|
|
|
public bool LogToExternalFolderDisplayValue { get; set; }
|
|
|
|
|
|
|
|
|
|
public bool IsSiteCachingOnDisplayValue { get; set; }
|
|
|
|
|
|
|
|
|
|
/// <summary> Holds a value indicating whether user can use external folder (e.g. SD card) for storing log-files.</summary>
|
|
|
|
|
public bool IsLogToExternalFolderVisible { get; }
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Holds the logging level serilog provides.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public List<string> LoggingLevels
|
|
|
|
|
{
|
|
|
|
|
get
|
|
|
|
|
{
|
|
|
|
|
return new List<string>
|
|
|
|
|
{
|
|
|
|
|
LogEventLevel.Verbose.ToString(),
|
|
|
|
|
LogEventLevel.Debug.ToString(),
|
|
|
|
|
LogEventLevel.Information.ToString(),
|
|
|
|
|
LogEventLevel.Warning.ToString(),
|
|
|
|
|
LogEventLevel.Error.ToString(),
|
|
|
|
|
LogEventLevel.Fatal.ToString(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double expiresAfterTotalSeconds;
|
|
|
|
|
|
|
|
|
|
public double ExpiresAfterTotalSeconds
|
|
|
|
|
{
|
|
|
|
|
get => expiresAfterTotalSeconds;
|
|
|
|
|
set
|
|
|
|
|
{
|
|
|
|
|
if (value == expiresAfterTotalSeconds)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expiresAfterTotalSeconds = value;
|
|
|
|
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ExpiresAfterTotalSecondsText)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public string ExpiresAfterTotalSecondsText
|
|
|
|
|
{
|
|
|
|
|
get => expiresAfterTotalSeconds.ToString("0");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
}
|