diff --git a/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs b/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs new file mode 100644 index 000000000..4eceb7a2f --- /dev/null +++ b/src/PostIt/PostIt.Tests/NominatimReverseGeocodingServiceTests.cs @@ -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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_payload, Encoding.UTF8, "application/json") + }); + } + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs index 69f0a2dcd..00a8bde04 100644 --- a/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs +++ b/src/PostIt/PostIt/Helpers/ServiceCollectionHelpers.cs @@ -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(); @@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers services.AddSingleton(userSearchClient); services.AddSingleton(activityClient); services.AddSingleton(billingClient); + services.AddSingleton(reverseGeocoding); services.AddSingleton(userDirectory); services.AddSingleton(); services.AddSingleton(); diff --git a/src/PostIt/PostIt/Services/IReverseGeocodingService.cs b/src/PostIt/PostIt/Services/IReverseGeocodingService.cs new file mode 100644 index 000000000..0e62fe9dc --- /dev/null +++ b/src/PostIt/PostIt/Services/IReverseGeocodingService.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace PostIt.Services; + +public interface IReverseGeocodingService +{ + Task TryResolveAddressAsync(double latitude, double longitude, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/PostIt/PostIt/Services/NominatimReverseGeocodingService.cs b/src/PostIt/PostIt/Services/NominatimReverseGeocodingService.cs new file mode 100644 index 000000000..f9ddb2f6a --- /dev/null +++ b/src/PostIt/PostIt/Services/NominatimReverseGeocodingService.cs @@ -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 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? parts = null; + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + continue; + + parts ??= new List(); + parts.Add(value.Trim()); + } + + return parts is null || parts.Count == 0 ? null : string.Join(", ", parts); + } +} \ No newline at end of file diff --git a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs index 44293f03e..d60aedcc8 100644 --- a/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Commands/RdvViewModel.cs @@ -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() { diff --git a/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs index ea813c857..7952d7dc4 100644 --- a/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs +++ b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs @@ -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(), }; } + + 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) + { + } + } }