postit: reverse geocode rdv map selections
This commit is contained in:
parent
9806bc0e0a
commit
947c09da70
6 changed files with 247 additions and 1 deletions
|
|
@ -0,0 +1,72 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class NominatimReverseGeocodingServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryResolveAddressAsync_formats_compact_street_address_from_nominatim_payload()
|
||||
{
|
||||
var handler = new StubHandler("""
|
||||
{
|
||||
"display_name": "6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, Paris, 75004, France",
|
||||
"address": {
|
||||
"house_number": "6",
|
||||
"road": "Place de l'Hôtel-de-Ville - Esplanade de la Libération",
|
||||
"postcode": "75004",
|
||||
"city": "Paris"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var service = new NominatimReverseGeocodingService(new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://nominatim.openstreetmap.org/")
|
||||
});
|
||||
|
||||
var result = await service.TryResolveAddressAsync(48.8566, 2.3522);
|
||||
|
||||
Assert.Equal("6, Place de l'Hôtel-de-Ville - Esplanade de la Libération, 75004, Paris", result);
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Contains("reverse?format=jsonv2", handler.LastRequest!.RequestUri!.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryResolveAddressAsync_returns_null_on_unsuccessful_response()
|
||||
{
|
||||
var handler = new StubHandler("{}", HttpStatusCode.TooManyRequests);
|
||||
var service = new NominatimReverseGeocodingService(new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://nominatim.openstreetmap.org/")
|
||||
});
|
||||
|
||||
var result = await service.TryResolveAddressAsync(48.8566, 2.3522);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly string _payload;
|
||||
private readonly HttpStatusCode _statusCode;
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
|
||||
public StubHandler(string payload, HttpStatusCode statusCode = HttpStatusCode.OK)
|
||||
{
|
||||
_payload = payload;
|
||||
_statusCode = statusCode;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_statusCode)
|
||||
{
|
||||
Content = new StringContent(_payload, Encoding.UTF8, "application/json")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ public static class ServiceCollectionHelpers
|
|||
() => settings.Authentication?.Authority);
|
||||
var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
var reverseGeocoding = new NominatimReverseGeocodingService();
|
||||
|
||||
// Vues
|
||||
services.AddSingleton<MainView>();
|
||||
|
|
@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers
|
|||
services.AddSingleton(userSearchClient);
|
||||
services.AddSingleton(activityClient);
|
||||
services.AddSingleton(billingClient);
|
||||
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
|
||||
services.AddSingleton<IUserDirectory>(userDirectory);
|
||||
services.AddSingleton<HomePageViewModel>();
|
||||
services.AddSingleton<SignaturePageViewModel>();
|
||||
|
|
|
|||
9
src/PostIt/PostIt/Services/IReverseGeocodingService.cs
Normal file
9
src/PostIt/PostIt/Services/IReverseGeocodingService.cs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public interface IReverseGeocodingService
|
||||
{
|
||||
Task<string?> TryResolveAddressAsync(double latitude, double longitude, CancellationToken cancellationToken = default);
|
||||
}
|
||||
119
src/PostIt/PostIt/Services/NominatimReverseGeocodingService.cs
Normal file
119
src/PostIt/PostIt/Services/NominatimReverseGeocodingService.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PostIt.Services;
|
||||
|
||||
public sealed class NominatimReverseGeocodingService : IReverseGeocodingService
|
||||
{
|
||||
private static readonly Uri BaseUri = new("https://nominatim.openstreetmap.org/");
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public NominatimReverseGeocodingService(HttpClient? httpClient = null)
|
||||
{
|
||||
_httpClient = httpClient ?? CreateDefaultClient();
|
||||
}
|
||||
|
||||
public async Task<string?> TryResolveAddressAsync(double latitude, double longitude, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUri = BuildReverseUri(latitude, longitude);
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(requestUri, cancellationToken).ConfigureAwait(false);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
using var json = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return FormatAddress(json.RootElement);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpClient CreateDefaultClient()
|
||||
{
|
||||
var client = new HttpClient
|
||||
{
|
||||
BaseAddress = BaseUri,
|
||||
Timeout = TimeSpan.FromSeconds(10),
|
||||
};
|
||||
client.DefaultRequestHeaders.UserAgent.Clear();
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("PostIt", "1.1"));
|
||||
client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("fr-FR"));
|
||||
client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("fr", 0.9));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static Uri BuildReverseUri(double latitude, double longitude)
|
||||
{
|
||||
var lat = latitude.ToString("0.######", CultureInfo.InvariantCulture);
|
||||
var lon = longitude.ToString("0.######", CultureInfo.InvariantCulture);
|
||||
var path = $"reverse?format=jsonv2&addressdetails=1&accept-language=fr&zoom=18&lat={lat}&lon={lon}";
|
||||
return new Uri(path, UriKind.Relative);
|
||||
}
|
||||
|
||||
private static string? FormatAddress(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("address", out var address))
|
||||
{
|
||||
var street = JoinNonEmpty(
|
||||
TryGetString(address, "house_number"),
|
||||
TryGetString(address, "road"));
|
||||
|
||||
var locality = JoinNonEmpty(
|
||||
TryGetString(address, "postcode"),
|
||||
TryGetString(address, "city")
|
||||
?? TryGetString(address, "town")
|
||||
?? TryGetString(address, "village")
|
||||
?? TryGetString(address, "municipality"));
|
||||
|
||||
var formatted = JoinNonEmpty(street, locality);
|
||||
if (!string.IsNullOrWhiteSpace(formatted))
|
||||
return formatted;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("display_name", out var displayName))
|
||||
{
|
||||
var value = displayName.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out var property)
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? JoinNonEmpty(params string?[] values)
|
||||
{
|
||||
List<string>? parts = null;
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
continue;
|
||||
|
||||
parts ??= new List<string>();
|
||||
parts.Add(value.Trim());
|
||||
}
|
||||
|
||||
return parts is null || parts.Count == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,15 @@ public partial class RdvViewModel : BillingCommandPageViewModel
|
|||
this.SetInfoStatus("Position sélectionnée sur la carte.");
|
||||
}
|
||||
|
||||
public void ApplyResolvedAddress(string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
return;
|
||||
|
||||
Address = address.Trim();
|
||||
this.SetInfoStatus("Adresse mise à jour depuis la carte.");
|
||||
}
|
||||
|
||||
|
||||
protected override async Task SubmitAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
|
@ -9,6 +11,7 @@ using Mapsui.Projections;
|
|||
using Mapsui.Styles;
|
||||
using Mapsui.Tiling;
|
||||
using Mapsui.UI.Avalonia;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Commands;
|
||||
|
||||
namespace PostIt.Views.Commands;
|
||||
|
|
@ -23,9 +26,17 @@ public partial class RdvPage : ContentPage
|
|||
private MapControl? _locationMap;
|
||||
private MemoryLayer? _selectionLayer;
|
||||
private RdvViewModel? _currentViewModel;
|
||||
private readonly IReverseGeocodingService _reverseGeocodingService;
|
||||
private CancellationTokenSource? _reverseGeocodeCts;
|
||||
|
||||
public RdvPage()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public RdvPage(IReverseGeocodingService? reverseGeocodingService)
|
||||
{
|
||||
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
|
||||
InitializeComponent();
|
||||
InitializeMap();
|
||||
}
|
||||
|
|
@ -58,7 +69,7 @@ public partial class RdvPage : ContentPage
|
|||
CenterFromViewModel();
|
||||
}
|
||||
|
||||
private void OnMapTapped(object? sender, MapEventArgs e)
|
||||
private async void OnMapTapped(object? sender, MapEventArgs e)
|
||||
{
|
||||
if (DataContext is not RdvViewModel vm)
|
||||
return;
|
||||
|
|
@ -67,6 +78,7 @@ public partial class RdvPage : ContentPage
|
|||
vm.ApplyLocationFromMap(latitude, longitude);
|
||||
UpdateMarkerFromViewModel();
|
||||
CenterMap(latitude, longitude, zoomLevel: SelectedZoomLevel);
|
||||
await TryResolveAddressAsync(vm, latitude, longitude);
|
||||
}
|
||||
|
||||
private async void OnCenterCurrentLocationClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
|
|
@ -77,6 +89,9 @@ public partial class RdvPage : ContentPage
|
|||
await vm.UseCurrentLocationCommand.ExecuteAsync(null);
|
||||
UpdateMarkerFromViewModel();
|
||||
CenterFromViewModel();
|
||||
|
||||
if (vm.Latitude.HasValue && vm.Longitude.HasValue)
|
||||
await TryResolveAddressAsync(vm, vm.Latitude.Value, vm.Longitude.Value);
|
||||
}
|
||||
|
||||
private void AttachViewModel(RdvViewModel? vm)
|
||||
|
|
@ -161,4 +176,24 @@ public partial class RdvPage : ContentPage
|
|||
Features = Enumerable.Empty<IFeature>(),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task TryResolveAddressAsync(RdvViewModel vm, double latitude, double longitude)
|
||||
{
|
||||
_reverseGeocodeCts?.Cancel();
|
||||
_reverseGeocodeCts?.Dispose();
|
||||
_reverseGeocodeCts = new CancellationTokenSource();
|
||||
|
||||
try
|
||||
{
|
||||
var resolved = await _reverseGeocodingService
|
||||
.TryResolveAddressAsync(latitude, longitude, _reverseGeocodeCts.Token)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
vm.ApplyResolvedAddress(resolved);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue