Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
369a8eb3c8 |
|||
|
5b244e357f |
|||
|
7a4cd9e1cc |
|||
|
247c100344 |
|||
|
bc162b8a05 |
|||
|
82c479434e |
|||
|
5d502fd6a7 |
|||
|
947c09da70 |
|||
|
9806bc0e0a |
|||
|
91e78a1270 |
|||
|
7dd3821175 |
16 changed files with 949 additions and 17 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -1,5 +1,33 @@
|
|||
# Changelog
|
||||
|
||||
|
||||
## [1.0.8-rc13] - unstable
|
||||
|
||||
### Added
|
||||
|
||||
* [PostIt] Integration d'un selecteur de lieu RDV base sur Mapsui (carte interactive dans le formulaire `Rdv`).
|
||||
* [PostIt] Ajout d'un marqueur de position et d'une action de recentrage sur la carte RDV.
|
||||
* [PostIt] Ajout d'un service de reverse geocoding pour suggerer une adresse a partir des coordonnees carte.
|
||||
* [PostIt] Cache et debounce des resolutions d'adresse RDV pour limiter les appels reseau et lisser l'UX.
|
||||
* [PostIt.Tests] Nouvelles non-regressions sur le panneau d'adresse suggeree RDV et le comportement de la carte.
|
||||
* [Yavsc.Abstract] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
|
||||
* [Yavsc.Server] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
|
||||
|
||||
### Changed
|
||||
|
||||
* [PostIt] Generalisation de la barre de statut d'action (severite explicite) sur pages principales, dialogues et formulaires billing.
|
||||
* [PostIt] Harmonisation des messages de statut utilisateur en francais.
|
||||
* [PostIt] Renforcement des gardes de navigation dans les flux de gestion des membres de cercle.
|
||||
* [PostIt] Le flux RDV conserve l'adresse saisie manuellement et propose l'adresse resolue comme suggestion explicite.
|
||||
* [PostIt] Le flux de geolocalisation RDV tolere les positions proches dans le cache de suggestion d'adresse.
|
||||
|
||||
### Fixed
|
||||
|
||||
* [PostIt.Desktop] Correction d'un crash au demarrage OIDC (`No authority specified`) via durcissement des valeurs par defaut de configuration d'authentification.
|
||||
* [PostIt] Correction de la persistance des settings: l'etat runtime de statut n'est plus serialize dans le JSON utilisateur.
|
||||
* [PostIt.Tests] Ajout d'un verrou de non-regression sur le premier chargement des settings.
|
||||
* [PostIt] Correction du binding de la date RDV: `DatePicker.SelectedDate` est aligne sur un proxy `DateTimeOffset?` (`EventDateSelection`).
|
||||
|
||||
## [1.0.8-rc12] - unstable
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
|
||||
<PackageVersion Include="Mapsui" Version="5.1.0" />
|
||||
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
|
||||
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.19.0" />
|
||||
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
|
||||
|
|
|
|||
|
|
@ -115,6 +115,89 @@ public class BillingCommandPageViewModelTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLocationFromMap_sets_coordinates_and_updates_status()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm =
|
||||
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
|
||||
.CreateCommandPageViewModel(
|
||||
new ActivityInfo { Code = "dev", Name = "Développement" },
|
||||
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
|
||||
client) as RdvViewModel;
|
||||
|
||||
vm!.Address = string.Empty;
|
||||
vm.ApplyLocationFromMap(48.85661234, 2.35224567);
|
||||
|
||||
Assert.Equal(48.856612, vm.Latitude);
|
||||
Assert.Equal(2.352246, vm.Longitude);
|
||||
Assert.Contains("Position sélectionnée", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventDateSelection_round_trips_with_EventDate_for_DatePicker_binding()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm =
|
||||
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
|
||||
.CreateCommandPageViewModel(
|
||||
new ActivityInfo { Code = "dev", Name = "Développement" },
|
||||
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
|
||||
client) as RdvViewModel;
|
||||
|
||||
var selected = new DateTimeOffset(2026, 9, 7, 14, 30, 0, TimeSpan.FromHours(2));
|
||||
vm!.EventDateSelection = selected;
|
||||
|
||||
Assert.Equal(selected.LocalDateTime, vm.EventDate);
|
||||
Assert.Equal(vm.EventDate, vm.EventDateSelection!.Value.LocalDateTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyResolvedAddress_populates_empty_address_directly()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm =
|
||||
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
|
||||
.CreateCommandPageViewModel(
|
||||
new ActivityInfo { Code = "dev", Name = "Développement" },
|
||||
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
|
||||
client) as RdvViewModel;
|
||||
|
||||
vm!.Address = string.Empty;
|
||||
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
|
||||
|
||||
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address);
|
||||
Assert.False(vm.HasSuggestedAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyResolvedAddress_preserves_manual_address_and_exposes_suggestion()
|
||||
{
|
||||
var api = new RecordingApi();
|
||||
var client = new BillingApiClient(api, "https://business.example/api/v1/");
|
||||
var vm =
|
||||
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
|
||||
.CreateCommandPageViewModel(
|
||||
new ActivityInfo { Code = "dev", Name = "Développement" },
|
||||
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
|
||||
client) as RdvViewModel;
|
||||
|
||||
vm!.Address = "Saisie manuelle";
|
||||
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
|
||||
|
||||
Assert.Equal("Saisie manuelle", vm.Address);
|
||||
Assert.True(vm.HasSuggestedAddress);
|
||||
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.SuggestedAddress);
|
||||
|
||||
vm.ApplySuggestedAddressCommand.Execute(null);
|
||||
|
||||
Assert.Equal("10 rue de Rivoli, 75001 Paris", vm.Address);
|
||||
Assert.False(vm.HasSuggestedAddress);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs
Normal file
79
src/PostIt/PostIt.Tests/RdvPageHeadlessTests.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using Avalonia.Controls;
|
||||
using Avalonia.Headless.XUnit;
|
||||
using PostIt.ViewModels;
|
||||
using PostIt.ViewModels.Commands;
|
||||
using PostIt.Views.Commands;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class RdvPageHeadlessTests
|
||||
{
|
||||
[AvaloniaFact]
|
||||
public void Suggested_address_panel_is_hidden_by_default()
|
||||
{
|
||||
var page = CreatePage(out _);
|
||||
|
||||
var panel = page.FindControl<Grid>("SuggestedAddressPanel");
|
||||
var progress = page.FindControl<ProgressBar>("SuggestedAddressProgress");
|
||||
|
||||
Assert.NotNull(panel);
|
||||
Assert.NotNull(progress);
|
||||
Assert.False(panel!.IsVisible);
|
||||
Assert.False(progress!.IsVisible);
|
||||
}
|
||||
|
||||
[AvaloniaFact]
|
||||
public async Task Suggested_address_panel_and_spinner_follow_viewmodel_state()
|
||||
{
|
||||
var page = CreatePage(out var vm);
|
||||
var panel = page.FindControl<Grid>("SuggestedAddressPanel")!;
|
||||
var progress = page.FindControl<ProgressBar>("SuggestedAddressProgress")!;
|
||||
var applyButton = page.FindControl<Button>("ApplySuggestedAddressButton")!;
|
||||
|
||||
vm.Address = "Saisie manuelle";
|
||||
vm.NotifyReverseGeocodingStarted();
|
||||
await Task.Delay(50);
|
||||
|
||||
Assert.True(panel.IsVisible);
|
||||
Assert.True(progress.IsVisible);
|
||||
Assert.False(applyButton.IsVisible);
|
||||
|
||||
vm.ApplyResolvedAddress("10 rue de Rivoli, 75001 Paris");
|
||||
await Task.Delay(50);
|
||||
|
||||
Assert.True(panel.IsVisible);
|
||||
Assert.False(progress.IsVisible);
|
||||
Assert.True(applyButton.IsVisible);
|
||||
}
|
||||
|
||||
private static RdvPage CreatePage(out RdvViewModel vm)
|
||||
{
|
||||
vm = new RdvViewModel(
|
||||
new ActivityInfo { Code = "dev", Name = "Développement" },
|
||||
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
|
||||
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" },
|
||||
new BillingApiClient(new RecordingApi(), "https://business.example/api/v1/"));
|
||||
|
||||
var page = new RdvPage { DataContext = vm };
|
||||
var nav = new NavigationPage();
|
||||
_ = nav.PushAsync(page);
|
||||
var window = new Window { Content = nav };
|
||||
window.Show();
|
||||
return page;
|
||||
}
|
||||
|
||||
private sealed class RecordingApi : IYavscApiClient
|
||||
{
|
||||
public HttpClient Http { get; } = new();
|
||||
|
||||
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> Task.FromResult(default(T)!);
|
||||
|
||||
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
30
src/PostIt/PostIt.Tests/RdvPageTests.cs
Normal file
30
src/PostIt/PostIt.Tests/RdvPageTests.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using PostIt.Views.Commands;
|
||||
|
||||
namespace PostIt.Tests;
|
||||
|
||||
public class RdvPageTests
|
||||
{
|
||||
[Fact]
|
||||
public void AreCoordinatesClose_returns_true_for_nearby_points_within_cache_tolerance()
|
||||
{
|
||||
var result = RdvPage.AreCoordinatesClose(
|
||||
48.8566,
|
||||
2.3522,
|
||||
48.85665,
|
||||
2.35225);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AreCoordinatesClose_returns_false_for_points_outside_cache_tolerance()
|
||||
{
|
||||
var result = RdvPage.AreCoordinatesClose(
|
||||
48.8566,
|
||||
2.3522,
|
||||
48.85685,
|
||||
2.3522);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -173,4 +173,51 @@ public class SettingsLoadTests
|
|||
Assert.NotNull(roundTrip);
|
||||
Assert.Equal("bonjour", roundTrip.SearchText);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// First-start regression guard: older settings payloads can
|
||||
/// still contain <c>ActionStatus</c> from a previous write. This
|
||||
/// runtime-only UI state must not be persisted anymore and must
|
||||
/// not break deserialization when present.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ActionStatus_is_not_persisted_and_legacy_payload_with_it_still_deserializes()
|
||||
{
|
||||
var settings = new PostIt.ViewModels.Settings
|
||||
{
|
||||
Authentication = new AuthenticationSettings
|
||||
{
|
||||
Authority = "https://example.test/",
|
||||
ClientId = "postit-tests",
|
||||
Scopes = new[] { "openid" }
|
||||
}
|
||||
};
|
||||
|
||||
var serialized = JsonSerializer.Serialize(settings);
|
||||
Assert.DoesNotContain("\"ActionStatus\"", serialized, StringComparison.Ordinal);
|
||||
|
||||
const string legacyPayload = """
|
||||
{
|
||||
"Authentication": {
|
||||
"Authority": "https://example.test/",
|
||||
"ClientId": "postit-tests",
|
||||
"Scopes": ["openid"],
|
||||
"RedirectUri": "postit://callback"
|
||||
},
|
||||
"DarkMode": false,
|
||||
"BlogsApiUrl": "https://blogs.example.test/api/v1/",
|
||||
"ApiUrl": "https://api.example.test/api/v1/",
|
||||
"SearchText": "hello",
|
||||
"ActionStatus": {
|
||||
"Message": "runtime only",
|
||||
"Severity": "Error"
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
var roundTrip = JsonSerializer.Deserialize<PostIt.ViewModels.Settings>(legacyPayload);
|
||||
|
||||
Assert.NotNull(roundTrip);
|
||||
Assert.Equal("hello", roundTrip.SearchText);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@
|
|||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Mapsui" />
|
||||
<PackageReference Include="Mapsui.Avalonia12" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="IdentityModel.OidcClient" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,12 @@ public partial class RdvViewModel : BillingCommandPageViewModel
|
|||
[ObservableProperty]
|
||||
public partial string Address { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SuggestedAddress { get; set; } = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsResolvingAddress { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial double? Latitude { get; set; }
|
||||
|
||||
|
|
@ -27,12 +33,27 @@ public partial class RdvViewModel : BillingCommandPageViewModel
|
|||
[ObservableProperty]
|
||||
public partial DateTime EventDate { get; set; }
|
||||
|
||||
public DateTimeOffset? EventDateSelection
|
||||
{
|
||||
get => new(EventDate);
|
||||
set
|
||||
{
|
||||
if (!value.HasValue)
|
||||
return;
|
||||
|
||||
EventDate = value.Value.LocalDateTime;
|
||||
}
|
||||
}
|
||||
|
||||
public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
|
||||
: base(activity, performer, form, billingClient)
|
||||
{
|
||||
EventDate = DateTime.Now.AddDays(1);
|
||||
}
|
||||
|
||||
public bool HasSuggestedAddress => !string.IsNullOrWhiteSpace(SuggestedAddress);
|
||||
public bool HasSuggestedAddressPanel => HasSuggestedAddress || IsResolvingAddress;
|
||||
|
||||
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
|
||||
{
|
||||
ExistingQueryId = existingQuery.Id;
|
||||
|
|
@ -50,6 +71,7 @@ public partial class RdvViewModel : BillingCommandPageViewModel
|
|||
if (existingQuery.Location is not null)
|
||||
{
|
||||
Address = existingQuery.Location.Address ?? string.Empty;
|
||||
SuggestedAddress = string.Empty;
|
||||
Latitude = existingQuery.Location.Latitude;
|
||||
Longitude = existingQuery.Location.Longitude;
|
||||
}
|
||||
|
|
@ -113,6 +135,94 @@ public partial class RdvViewModel : BillingCommandPageViewModel
|
|||
};
|
||||
}
|
||||
|
||||
public void ApplyLocationFromMap(double latitude, double longitude)
|
||||
{
|
||||
Latitude = Math.Round(latitude, 6);
|
||||
Longitude = Math.Round(longitude, 6);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Address))
|
||||
{
|
||||
this.SetInfoStatus("Position sélectionnée sur la carte. Complétez l'adresse puis envoyez la commande.");
|
||||
return;
|
||||
}
|
||||
|
||||
this.SetInfoStatus("Position sélectionnée sur la carte.");
|
||||
}
|
||||
|
||||
public void NotifyReverseGeocodingStarted()
|
||||
{
|
||||
IsResolvingAddress = true;
|
||||
this.SetInfoStatus(string.IsNullOrWhiteSpace(Address)
|
||||
? "Recherche de l'adresse depuis la carte..."
|
||||
: "Recherche d'une adresse suggérée..."
|
||||
);
|
||||
}
|
||||
|
||||
public void NotifyReverseGeocodingUnavailable()
|
||||
{
|
||||
IsResolvingAddress = false;
|
||||
if (HasSuggestedAddress || !string.IsNullOrWhiteSpace(Address))
|
||||
return;
|
||||
|
||||
this.SetInfoStatus("Position sélectionnée sur la carte. Complétez l'adresse puis envoyez la commande.");
|
||||
}
|
||||
|
||||
public void ApplyResolvedAddress(string address)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
return;
|
||||
|
||||
var trimmedAddress = address.Trim();
|
||||
if (string.IsNullOrWhiteSpace(Address))
|
||||
{
|
||||
Address = trimmedAddress;
|
||||
SuggestedAddress = string.Empty;
|
||||
IsResolvingAddress = false;
|
||||
this.SetInfoStatus("Adresse mise à jour depuis la carte.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(Address.Trim(), trimmedAddress, StringComparison.Ordinal))
|
||||
{
|
||||
SuggestedAddress = string.Empty;
|
||||
IsResolvingAddress = false;
|
||||
return;
|
||||
}
|
||||
|
||||
SuggestedAddress = trimmedAddress;
|
||||
IsResolvingAddress = false;
|
||||
this.SetInfoStatus("Adresse suggérée depuis la carte. Appliquez-la si besoin.");
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasSuggestedAddress))]
|
||||
private void ApplySuggestedAddress()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SuggestedAddress))
|
||||
return;
|
||||
|
||||
Address = SuggestedAddress.Trim();
|
||||
SuggestedAddress = string.Empty;
|
||||
IsResolvingAddress = false;
|
||||
this.SetInfoStatus("Adresse suggérée appliquée.");
|
||||
}
|
||||
|
||||
partial void OnSuggestedAddressChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSuggestedAddress));
|
||||
OnPropertyChanged(nameof(HasSuggestedAddressPanel));
|
||||
ApplySuggestedAddressCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnIsResolvingAddressChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSuggestedAddressPanel));
|
||||
}
|
||||
|
||||
partial void OnEventDateChanged(DateTime value)
|
||||
{
|
||||
OnPropertyChanged(nameof(EventDateSelection));
|
||||
}
|
||||
|
||||
|
||||
protected override async Task SubmitAsync()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
<ContentPage xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:cvm="using:PostIt.ViewModels.Commands"
|
||||
xmlns:mapsui="using:Mapsui.UI.Avalonia"
|
||||
xmlns:postitControls="using:PostIt.Controls"
|
||||
x:Class="PostIt.Views.Commands.RdvPage"
|
||||
x:DataType="cvm:RdvViewModel"
|
||||
Header="Commande billing">
|
||||
<ScrollViewer>
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
|
||||
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
|
||||
Text="{Binding Title}"
|
||||
FontSize="18"
|
||||
|
|
@ -25,7 +26,7 @@
|
|||
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDateSelection, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="5"
|
||||
Text="Motif"
|
||||
|
|
@ -39,35 +40,75 @@
|
|||
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
|
||||
|
||||
<Button Grid.Row="7"
|
||||
<Grid Grid.Row="7"
|
||||
x:Name="SuggestedAddressPanel"
|
||||
Grid.ColumnSpan="2"
|
||||
HorizontalAlignment="Left"
|
||||
ColumnDefinitions="*,12,Auto,12,Auto"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding HasSuggestedAddressPanel}">
|
||||
<TextBlock Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding SuggestedAddress, StringFormat='Adresse suggérée: {0}'}"
|
||||
TextWrapping="Wrap"
|
||||
Opacity="0.8" />
|
||||
<Button Grid.Column="2"
|
||||
x:Name="ApplySuggestedAddressButton"
|
||||
Content="Utiliser"
|
||||
IsVisible="{Binding HasSuggestedAddress}"
|
||||
Command="{Binding ApplySuggestedAddressCommand}" />
|
||||
<ProgressBar Grid.Column="4"
|
||||
x:Name="SuggestedAddressProgress"
|
||||
Width="80"
|
||||
IsIndeterminate="True"
|
||||
IsVisible="{Binding IsResolvingAddress}" />
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="8"
|
||||
Grid.ColumnSpan="2"
|
||||
ColumnDefinitions="Auto,12,Auto"
|
||||
Margin="0,0,0,8">
|
||||
<Button Grid.Column="0"
|
||||
HorizontalAlignment="Left"
|
||||
Content="Utiliser ma position"
|
||||
Command="{Binding UseCurrentLocationCommand}" />
|
||||
<Button Grid.Column="2"
|
||||
HorizontalAlignment="Left"
|
||||
Content="Me localiser sur la carte"
|
||||
Click="OnCenterCurrentLocationClicked"
|
||||
IsEnabled="{Binding CanUseCurrentLocation}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
|
||||
<TextBlock Grid.Row="9" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
|
||||
<TextBlock Grid.Row="10" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="10" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="11" Grid.ColumnSpan="2"
|
||||
Text="Carte: cliquez pour positionner le rendez-vous"
|
||||
Margin="0,0,0,8"
|
||||
Opacity="0.85" />
|
||||
<mapsui:MapControl Grid.Row="12"
|
||||
Grid.ColumnSpan="2"
|
||||
x:Name="LocationMap"
|
||||
Height="260"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="12"
|
||||
<TextBlock Grid.Row="13"
|
||||
Text="Informations"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="12"
|
||||
<TextBox Grid.Row="13"
|
||||
Grid.Column="1"
|
||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||
<CheckBox Grid.Row="14" Grid.ColumnSpan="2"
|
||||
Content="Je consens à la création de cette commande"
|
||||
IsChecked="{Binding Consent, Mode=TwoWay}"
|
||||
Margin="0,4,0,12" />
|
||||
|
||||
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
|
||||
<Grid Grid.Row="15" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
|
||||
<Button Grid.Column="0"
|
||||
Content="{Binding SubmitLabel}"
|
||||
Command="{Binding SubmitCommand}"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,236 @@
|
|||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Mapsui;
|
||||
using Mapsui.Layers;
|
||||
using Mapsui.Projections;
|
||||
using Mapsui.Styles;
|
||||
using Mapsui.Tiling;
|
||||
using Mapsui.UI.Avalonia;
|
||||
using PostIt.Services;
|
||||
using PostIt.ViewModels.Commands;
|
||||
|
||||
namespace PostIt.Views.Commands;
|
||||
|
||||
public partial class RdvPage : ContentPage
|
||||
{
|
||||
private const double DefaultLatitude = 48.8566;
|
||||
private const double DefaultLongitude = 2.3522;
|
||||
private const int DefaultZoomLevel = 4;
|
||||
private const int SelectedZoomLevel = 13;
|
||||
internal const double ReverseGeocodingCacheToleranceDegrees = 0.0001;
|
||||
private static readonly TimeSpan ReverseGeocodingDebounce = TimeSpan.FromMilliseconds(350);
|
||||
|
||||
private MapControl? _locationMap;
|
||||
private MemoryLayer? _selectionLayer;
|
||||
private RdvViewModel? _currentViewModel;
|
||||
private readonly IReverseGeocodingService _reverseGeocodingService;
|
||||
private CancellationTokenSource? _reverseGeocodeCts;
|
||||
private double? _lastResolvedLatitude;
|
||||
private double? _lastResolvedLongitude;
|
||||
private string? _lastResolvedAddress;
|
||||
|
||||
public RdvPage()
|
||||
: this(null)
|
||||
{
|
||||
}
|
||||
|
||||
public RdvPage(IReverseGeocodingService? reverseGeocodingService)
|
||||
{
|
||||
_reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
|
||||
InitializeComponent();
|
||||
InitializeMap();
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
private void InitializeMap()
|
||||
{
|
||||
_locationMap = this.FindControl<MapControl>("LocationMap");
|
||||
if (_locationMap is null)
|
||||
return;
|
||||
|
||||
var map = new Map();
|
||||
map.Layers.Add(OpenStreetMap.CreateTileLayer());
|
||||
_selectionLayer = CreateSelectionLayer();
|
||||
map.Layers.Add(_selectionLayer);
|
||||
_locationMap.Map = map;
|
||||
_locationMap.MapTapped += OnMapTapped;
|
||||
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
AttachViewModel(DataContext as RdvViewModel);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, System.EventArgs e)
|
||||
{
|
||||
AttachViewModel(DataContext as RdvViewModel);
|
||||
CenterFromViewModel();
|
||||
}
|
||||
|
||||
private async void OnMapTapped(object? sender, MapEventArgs e)
|
||||
{
|
||||
if (DataContext is not RdvViewModel vm)
|
||||
return;
|
||||
|
||||
var (longitude, latitude) = SphericalMercator.ToLonLat(e.WorldPosition.X, e.WorldPosition.Y);
|
||||
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)
|
||||
{
|
||||
if (DataContext is not RdvViewModel vm || !vm.CanUseCurrentLocation)
|
||||
return;
|
||||
|
||||
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)
|
||||
{
|
||||
if (_currentViewModel is not null)
|
||||
_currentViewModel.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
|
||||
_currentViewModel = vm;
|
||||
|
||||
if (_currentViewModel is not null)
|
||||
_currentViewModel.PropertyChanged += OnViewModelPropertyChanged;
|
||||
|
||||
UpdateMarkerFromViewModel();
|
||||
}
|
||||
|
||||
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(RdvViewModel.Latitude)
|
||||
|| e.PropertyName == nameof(RdvViewModel.Longitude))
|
||||
{
|
||||
UpdateMarkerFromViewModel();
|
||||
}
|
||||
}
|
||||
|
||||
private void CenterFromViewModel()
|
||||
{
|
||||
if (DataContext is not RdvViewModel vm)
|
||||
{
|
||||
CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (vm.Latitude.HasValue && vm.Longitude.HasValue)
|
||||
{
|
||||
CenterMap(vm.Latitude.Value, vm.Longitude.Value, zoomLevel: SelectedZoomLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
|
||||
}
|
||||
|
||||
private void CenterMap(double latitude, double longitude, int zoomLevel)
|
||||
{
|
||||
if (_locationMap?.Map is null)
|
||||
return;
|
||||
|
||||
var (x, y) = SphericalMercator.FromLonLat(longitude, latitude);
|
||||
_locationMap.Map.Navigator.CenterOn(x, y);
|
||||
_locationMap.Map.Navigator.ZoomToLevel(zoomLevel);
|
||||
}
|
||||
|
||||
private void UpdateMarkerFromViewModel()
|
||||
{
|
||||
if (_selectionLayer is null)
|
||||
return;
|
||||
|
||||
if (DataContext is not RdvViewModel vm
|
||||
|| !vm.Latitude.HasValue
|
||||
|| !vm.Longitude.HasValue)
|
||||
{
|
||||
_selectionLayer.Features = Enumerable.Empty<IFeature>();
|
||||
_locationMap?.RefreshData(ChangeType.Discrete);
|
||||
return;
|
||||
}
|
||||
|
||||
var (x, y) = SphericalMercator.FromLonLat(vm.Longitude.Value, vm.Latitude.Value);
|
||||
_selectionLayer.Features = new IFeature[] { new PointFeature(x, y) };
|
||||
_locationMap?.RefreshData(ChangeType.Discrete);
|
||||
}
|
||||
|
||||
private static MemoryLayer CreateSelectionLayer()
|
||||
{
|
||||
return new MemoryLayer("selected-location")
|
||||
{
|
||||
Style = new SymbolStyle
|
||||
{
|
||||
SymbolType = SymbolType.Ellipse,
|
||||
Fill = new Brush(Color.Crimson),
|
||||
Outline = new Pen(Color.White, 2),
|
||||
SymbolScale = 0.9,
|
||||
},
|
||||
Features = Enumerable.Empty<IFeature>(),
|
||||
};
|
||||
}
|
||||
|
||||
private async Task TryResolveAddressAsync(RdvViewModel vm, double latitude, double longitude)
|
||||
{
|
||||
if (_lastResolvedLatitude.HasValue
|
||||
&& _lastResolvedLongitude.HasValue
|
||||
&& AreCoordinatesClose(_lastResolvedLatitude.Value, _lastResolvedLongitude.Value, latitude, longitude)
|
||||
&& !string.IsNullOrWhiteSpace(_lastResolvedAddress))
|
||||
{
|
||||
vm.ApplyResolvedAddress(_lastResolvedAddress);
|
||||
return;
|
||||
}
|
||||
|
||||
_reverseGeocodeCts?.Cancel();
|
||||
_reverseGeocodeCts?.Dispose();
|
||||
_reverseGeocodeCts = new CancellationTokenSource();
|
||||
var cancellationToken = _reverseGeocodeCts.Token;
|
||||
|
||||
try
|
||||
{
|
||||
vm.NotifyReverseGeocodingStarted();
|
||||
await Task.Delay(ReverseGeocodingDebounce, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var resolved = await _reverseGeocodingService
|
||||
.TryResolveAddressAsync(latitude, longitude, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolved))
|
||||
{
|
||||
_lastResolvedLatitude = latitude;
|
||||
_lastResolvedLongitude = longitude;
|
||||
_lastResolvedAddress = resolved;
|
||||
vm.ApplyResolvedAddress(resolved);
|
||||
return;
|
||||
}
|
||||
|
||||
vm.NotifyReverseGeocodingUnavailable();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
vm.IsResolvingAddress = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool AreCoordinatesClose(
|
||||
double latitudeA,
|
||||
double longitudeA,
|
||||
double latitudeB,
|
||||
double longitudeB)
|
||||
{
|
||||
return Math.Abs(latitudeA - latitudeB) <= ReverseGeocodingCacheToleranceDegrees
|
||||
&& Math.Abs(longitudeA - longitudeB) <= ReverseGeocodingCacheToleranceDegrees;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
61
src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs
Normal file
61
src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Yavsc.Api.Test.Fixtures;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
using Yavsc.Tests.Shared;
|
||||
|
||||
namespace Yavsc.Api.Test;
|
||||
|
||||
[Collection("Yavsc Api")]
|
||||
public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFixture>
|
||||
{
|
||||
private readonly ApiWebServerFixture _fixture;
|
||||
|
||||
public FrontOfficeApiControllerTests(ApiWebServerFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private HttpClient NewClient(string subject = "alice", string scope = "api")
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
|
||||
};
|
||||
|
||||
var http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(_fixture.BaseAddress)
|
||||
};
|
||||
http.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
|
||||
return http;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Front_accept_query_updates_status_without_server_error()
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
_fixture.ResetAndSeedRdvQueryGraph();
|
||||
|
||||
long queryId;
|
||||
using (var scope = _fixture.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
queryId = db.RdvQueries.Select(q => q.Id).Single();
|
||||
}
|
||||
|
||||
using var http = NewClient();
|
||||
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
|
||||
using var assertScope = _fixture.Services.CreateScope();
|
||||
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
|
||||
|
||||
Assert.Equal(QueryStatus.Accepted, updated.Status);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
using System.Reflection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Helpers;
|
||||
using Yavsc.Models;
|
||||
|
||||
namespace Yavsc.Services
|
||||
|
|
@ -35,7 +36,34 @@ namespace Yavsc.Services
|
|||
|
||||
public static IQuery GetBillable(ApplicationDbContext context, string billingCode, long queryId)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (context is null) throw new ArgumentNullException(nameof(context));
|
||||
if (string.IsNullOrWhiteSpace(billingCode) || queryId <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Billing.Count == 0)
|
||||
{
|
||||
WorkflowHelpers.ConfigureBillingService();
|
||||
}
|
||||
|
||||
var getter = Billing
|
||||
.FirstOrDefault(kvp => string.Equals(kvp.Key, billingCode.Trim(), StringComparison.OrdinalIgnoreCase))
|
||||
.Value;
|
||||
|
||||
if (getter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return getter(context, queryId);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue