postit: reverse geocode rdv map selections

This commit is contained in:
Paul Schneider 2026-09-06 17:13:31 +01:00
commit 4010defde7
6 changed files with 247 additions and 1 deletions

View file

@ -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")
});
}
}
}

View file

@ -30,6 +30,7 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService();
// Vues // Vues
services.AddSingleton<MainView>(); services.AddSingleton<MainView>();
@ -65,6 +66,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();

View 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);
}

View 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);
}
}

View file

@ -127,6 +127,15 @@ public partial class RdvViewModel : BillingCommandPageViewModel
this.SetInfoStatus("Position sélectionnée sur la carte."); 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() protected override async Task SubmitAsync()
{ {

View file

@ -1,5 +1,7 @@
using System;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@ -9,6 +11,7 @@ using Mapsui.Projections;
using Mapsui.Styles; using Mapsui.Styles;
using Mapsui.Tiling; using Mapsui.Tiling;
using Mapsui.UI.Avalonia; using Mapsui.UI.Avalonia;
using PostIt.Services;
using PostIt.ViewModels.Commands; using PostIt.ViewModels.Commands;
namespace PostIt.Views.Commands; namespace PostIt.Views.Commands;
@ -23,9 +26,17 @@ public partial class RdvPage : ContentPage
private MapControl? _locationMap; private MapControl? _locationMap;
private MemoryLayer? _selectionLayer; private MemoryLayer? _selectionLayer;
private RdvViewModel? _currentViewModel; private RdvViewModel? _currentViewModel;
private readonly IReverseGeocodingService _reverseGeocodingService;
private CancellationTokenSource? _reverseGeocodeCts;
public RdvPage() public RdvPage()
: this(null)
{ {
}
public RdvPage(IReverseGeocodingService? reverseGeocodingService)
{
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
InitializeComponent(); InitializeComponent();
InitializeMap(); InitializeMap();
} }
@ -58,7 +69,7 @@ public partial class RdvPage : ContentPage
CenterFromViewModel(); CenterFromViewModel();
} }
private void OnMapTapped(object? sender, MapEventArgs e) private async void OnMapTapped(object? sender, MapEventArgs e)
{ {
if (DataContext is not RdvViewModel vm) if (DataContext is not RdvViewModel vm)
return; return;
@ -67,6 +78,7 @@ public partial class RdvPage : ContentPage
vm.ApplyLocationFromMap(latitude, longitude); vm.ApplyLocationFromMap(latitude, longitude);
UpdateMarkerFromViewModel(); UpdateMarkerFromViewModel();
CenterMap(latitude, longitude, zoomLevel: SelectedZoomLevel); CenterMap(latitude, longitude, zoomLevel: SelectedZoomLevel);
await TryResolveAddressAsync(vm, latitude, longitude);
} }
private async void OnCenterCurrentLocationClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e) private async void OnCenterCurrentLocationClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
@ -77,6 +89,9 @@ public partial class RdvPage : ContentPage
await vm.UseCurrentLocationCommand.ExecuteAsync(null); await vm.UseCurrentLocationCommand.ExecuteAsync(null);
UpdateMarkerFromViewModel(); UpdateMarkerFromViewModel();
CenterFromViewModel(); CenterFromViewModel();
if (vm.Latitude.HasValue && vm.Longitude.HasValue)
await TryResolveAddressAsync(vm, vm.Latitude.Value, vm.Longitude.Value);
} }
private void AttachViewModel(RdvViewModel? vm) private void AttachViewModel(RdvViewModel? vm)
@ -161,4 +176,24 @@ public partial class RdvPage : ContentPage
Features = Enumerable.Empty<IFeature>(), 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)
{
}
}
} }