From 914e9486b5e605c9a426d516be599ee0b795675b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 2 Sep 2026 14:41:05 +0100 Subject: [PATCH] Platform.TryGetCurrentLocationAsync --- src/PostIt/PostIt.Android/Application.cs | 4 + src/PostIt/PostIt.Android/MainActivity.cs | 11 ++ .../PostIt.Android/PlatformBootstrap.cs | 3 +- .../AndroidCurrentLocationProvider.cs | 130 +++++++++++++++++ .../BillingCommandPageViewModelTests.cs | 58 +++++++- .../PostIt/Services/CurrentLocationResult.cs | 28 ++++ src/PostIt/PostIt/Services/Platform.cs | 11 ++ .../ViewModels/BillingCommandPageViewModel.cs | 137 +++++++++++++++--- .../PostIt/Views/BillingCommandPage.axaml | 33 +++-- src/Yavsc.Api.Client/BillingApiClient.cs | 20 ++- .../Dtos/BillingQueryDetailsDto.cs | 6 +- 11 files changed, 395 insertions(+), 46 deletions(-) create mode 100644 src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs create mode 100644 src/PostIt/PostIt/Services/CurrentLocationResult.cs diff --git a/src/PostIt/PostIt.Android/Application.cs b/src/PostIt/PostIt.Android/Application.cs index f5a7908d..040b01ca 100644 --- a/src/PostIt/PostIt.Android/Application.cs +++ b/src/PostIt/PostIt.Android/Application.cs @@ -1,4 +1,5 @@ using Android.App; +using Android; using Android.Runtime; using Avalonia; using Avalonia.Android; @@ -9,6 +10,9 @@ using Avalonia.Controls; using Avalonia.Styling; using Yavsc.Api.Client; +[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)] +[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)] + namespace PostIt.Android { [Application] diff --git a/src/PostIt/PostIt.Android/MainActivity.cs b/src/PostIt/PostIt.Android/MainActivity.cs index ad8455ef..54910080 100644 --- a/src/PostIt/PostIt.Android/MainActivity.cs +++ b/src/PostIt/PostIt.Android/MainActivity.cs @@ -57,6 +57,17 @@ public class MainActivity : AvaloniaMainActivity } + public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults) + { + if (PostIt.Android.Services.AndroidCurrentLocationProvider + .HandlePermissionResult(requestCode, grantResults)) + { + return; + } + + base.OnRequestPermissionsResult(requestCode, permissions, grantResults); + } + internal static class AndroidOidcCallbackSink { private static System.Threading.Tasks.TaskCompletionSource? _pending; diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index f208f9ce..5b90267f 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -14,11 +14,12 @@ internal static class PlatformBootstrap { internal static void InitPlatform() { - Platform.CreateBrowser = () => { var activity = MainActivity.Current; return activity is null ? null : new AndroidSystemBrowser(activity); }; + + Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync; } } diff --git a/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs new file mode 100644 index 00000000..f710cacc --- /dev/null +++ b/src/PostIt/PostIt.Android/Services/AndroidCurrentLocationProvider.cs @@ -0,0 +1,130 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Android; +using Android.App; +using Android.Content.PM; +using Android.Locations; +using AndroidX.Core.App; +using AndroidX.Core.Content; +using PostIt.Services; + +namespace PostIt.Android.Services; + +internal static class AndroidCurrentLocationProvider +{ + public static async Task TryGetCurrentLocationAsync(CancellationToken cancellationToken) + { + var activity = MainActivity.Current; + if (activity is null) + { + return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête."); + } + + var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false); + if (!permissionGranted) + { + return CurrentLocationResult.PermissionDenied(); + } + + var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager; + if (locationManager is null) + { + return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible."); + } + + var location = locationManager.GetProviders(enabledOnly: true)? + .Select(provider => locationManager.GetLastKnownLocation(provider)) + .Where(candidate => candidate is not null) + .OrderByDescending(candidate => candidate!.Time) + .ThenBy(candidate => candidate!.Accuracy) + .FirstOrDefault(); + + if (location is null) + { + return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez."); + } + + return CurrentLocationResult.Success(location.Latitude, location.Longitude); + } + + public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults) + => LocationPermissionBroker.HandleResult(requestCode, grantResults); + + private static class LocationPermissionBroker + { + private const int RequestCode = 4042; + private static readonly string[] RequestedPermissions = + { + Manifest.Permission.AccessFineLocation, + Manifest.Permission.AccessCoarseLocation, + }; + + private static readonly object SyncRoot = new(); + private static TaskCompletionSource? _pendingRequest; + + public static Task EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken) + { + if (HasLocationPermission(activity)) + { + return Task.FromResult(true); + } + + lock (SyncRoot) + { + if (_pendingRequest is null) + { + _pendingRequest = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode); + } + + if (!cancellationToken.CanBeCanceled) + { + return _pendingRequest.Task; + } + + return WaitAsync(_pendingRequest.Task, cancellationToken); + } + } + + public static bool HandleResult(int requestCode, Permission[]? grantResults) + { + if (requestCode != RequestCode) + { + return false; + } + + var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted); + TaskCompletionSource? pendingRequest; + lock (SyncRoot) + { + pendingRequest = _pendingRequest; + _pendingRequest = null; + } + + pendingRequest?.TrySetResult(granted); + return true; + } + + private static bool HasLocationPermission(Activity activity) + { + return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted + || ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted; + } + + private static async Task WaitAsync(Task task, CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => + { + lock (SyncRoot) + { + _pendingRequest?.TrySetCanceled(cancellationToken); + _pendingRequest = null; + } + }); + + return await task.ConfigureAwait(false); + } + } +} diff --git a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs index eef3fe21..d80d3195 100644 --- a/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs +++ b/src/PostIt/PostIt.Tests/BillingCommandPageViewModelTests.cs @@ -1,5 +1,6 @@ using System.Net.Http; using System.Text.Json; +using PostIt.Services; using PostIt.ViewModels; using Yavsc; using Yavsc.Abstract.Workflow; @@ -58,6 +59,61 @@ public class BillingCommandPageViewModelTests Assert.Contains("n'est pas encore pris en charge", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload() + { + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, + client) + { + EventDateText = "2026-09-02 14:30", + Reason = "Point de cadrage", + Address = "1 rue du Test", + LatitudeText = string.Empty, + LongitudeText = string.Empty, + Consent = true, + }; + + await vm.SubmitCommand.ExecuteAsync(null); + + using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody)); + var location = json.RootElement.GetProperty("Location"); + Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString()); + Assert.False(location.TryGetProperty("Latitude", out _)); + Assert.False(location.TryGetProperty("Longitude", out _)); + } + + [Fact] + public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider() + { + var original = Platform.TryGetCurrentLocationAsync; + try + { + Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522)); + + var api = new RecordingApi(); + var client = new BillingApiClient(api, "https://business.example/api/v1/"); + var vm = new BillingCommandPageViewModel( + new ActivityBrowseItemDto { Code = "dev", Name = "Développement" }, + new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" }, + new CommandFormSummaryDto { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }, + client); + + await vm.UseCurrentLocationCommand.ExecuteAsync(null); + + Assert.Equal("48.8566", vm.LatitudeText); + Assert.Equal("2.3522", vm.LongitudeText); + } + finally + { + Platform.TryGetCurrentLocationAsync = original; + } + } + [Fact] public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation() { @@ -216,4 +272,4 @@ public class BillingCommandPageViewModelTests public ValueTask DisposeAsync() => ValueTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/PostIt/PostIt/Services/CurrentLocationResult.cs b/src/PostIt/PostIt/Services/CurrentLocationResult.cs new file mode 100644 index 00000000..ea19da43 --- /dev/null +++ b/src/PostIt/PostIt/Services/CurrentLocationResult.cs @@ -0,0 +1,28 @@ +namespace PostIt.Services; + +public sealed class CurrentLocationResult +{ + private CurrentLocationResult(bool isSuccess, bool isPermissionDenied, double? latitude, double? longitude, string message) + { + IsSuccess = isSuccess; + IsPermissionDenied = isPermissionDenied; + Latitude = latitude; + Longitude = longitude; + Message = message; + } + + public bool IsSuccess { get; } + public bool IsPermissionDenied { get; } + public double? Latitude { get; } + public double? Longitude { get; } + public string Message { get; } + + public static CurrentLocationResult Success(double latitude, double longitude, string? message = null) + => new(true, false, latitude, longitude, message ?? "Position récupérée."); + + public static CurrentLocationResult PermissionDenied(string? message = null) + => new(false, true, null, null, message ?? "La géolocalisation n'est pas autorisée."); + + public static CurrentLocationResult Unavailable(string? message = null) + => new(false, false, null, null, message ?? "La géolocalisation n'est pas disponible sur cette plateforme."); +} diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs index 8e5f7e25..2e5ac76a 100644 --- a/src/PostIt/PostIt/Services/Platform.cs +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -1,4 +1,7 @@ +using System; using IdentityModel.OidcClient.Browser; +using System.Threading; +using System.Threading.Tasks; namespace PostIt.Services; @@ -37,4 +40,12 @@ public static class Platform /// public static System.Func? CreateBrowser { get; set; } = () => new CustomSchemeBrowser(CustomScheme); + + /// + /// Optional platform hook used by the shared billing form to request a + /// current device position. Platforms that do not expose a native + /// location provider can leave the default delegate in place. + /// + public static Func> TryGetCurrentLocationAsync { get; set; } = + _ => Task.FromResult(CurrentLocationResult.Unavailable()); } diff --git a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs index c9050cbf..2ffeec26 100644 --- a/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/BillingCommandPageViewModel.cs @@ -8,12 +8,12 @@ using System.Linq; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using PostIt.Services; using Yavsc; using Yavsc.Abstract.Workflow; using Yavsc.Api.Client; using Yavsc.Models.Billing; using Yavsc.Models.Haircut; -using Yavsc.Models.Relationship; namespace PostIt.ViewModels; @@ -67,6 +67,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase [ObservableProperty] public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted; + public bool CanUseCurrentLocation => IsSupported && !IsBusy; + public string Title => Form.Title; public string PerformerLabel => Performer.UserName; public string ActivityLabel => Activity.Name; @@ -122,6 +124,12 @@ public partial class BillingCommandPageViewModel : ViewModelBase OnPropertyChanged(nameof(SubmitLabel)); } + partial void OnIsBusyChanged(bool value) + { + OnPropertyChanged(nameof(CanUseCurrentLocation)); + UseCurrentLocationCommand.NotifyCanExecuteChanged(); + } + public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null) { if (!IsBrush && !IsMultiBrush) @@ -198,27 +206,17 @@ public partial class BillingCommandPageViewModel : ViewModelBase return; } - if (!TryParseCoordinate(LatitudeText, out var latitude)) + if (!TryParseCoordinates(out var latitude, out var longitude, out var coordinateError)) { - StatusMessage = "Latitude invalide."; - return; - } - - if (!TryParseCoordinate(LongitudeText, out var longitude)) - { - StatusMessage = "Longitude invalide."; + StatusMessage = coordinateError; return; } IsBusy = true; try { - var location = new Location - { - Address = Address.Trim(), - Latitude = latitude, - Longitude = longitude, - }; + var address = Address.Trim(); + var locationPayload = BuildLocationPayload(address, latitude, longitude); var payload = new BillingQueryDetailsDto { @@ -233,9 +231,9 @@ public partial class BillingCommandPageViewModel : ViewModelBase AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(), Location = new BillingLocationDto { - Address = location.Address, - Latitude = location.Latitude, - Longitude = location.Longitude, + Address = address, + Latitude = latitude, + Longitude = longitude, } }; @@ -253,7 +251,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = eventDate, - Location = location, + Location = locationPayload, Reason = payload.Reason, Status = payload.Status, }).ConfigureAwait(true); @@ -281,7 +279,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = (DateTime?)eventDate, - Location = location, + Location = locationPayload, PrestationId = SelectedPrestation.Id, AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(), Status = payload.Status, @@ -311,7 +309,7 @@ public partial class BillingCommandPageViewModel : ViewModelBase PerformerId = Performer.PerformerId, Consent, EventDate = eventDate, - Location = location, + Location = locationPayload, Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(), Status = payload.Status, }).ConfigureAwait(true); @@ -336,6 +334,44 @@ public partial class BillingCommandPageViewModel : ViewModelBase } } + [RelayCommand(CanExecute = nameof(CanUseCurrentLocation))] + private async Task UseCurrentLocationAsync() + { + if (!CanUseCurrentLocation) + { + return; + } + + IsBusy = true; + try + { + var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true); + if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue) + { + StatusMessage = result.Message; + return; + } + + LatitudeText = result.Latitude.Value.ToString(CultureInfo.InvariantCulture); + LongitudeText = result.Longitude.Value.ToString(CultureInfo.InvariantCulture); + StatusMessage = string.IsNullOrWhiteSpace(Address) + ? "Position récupérée. Complétez l'adresse puis envoyez la commande." + : result.Message; + } + catch (OperationCanceledException) + { + StatusMessage = "La récupération de la position a été annulée."; + } + catch (Exception ex) + { + StatusMessage = $"Impossible de récupérer la position: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + private void ApplyExistingQuery(BillingQueryDetailsDto existingQuery) { ExistingQueryId = existingQuery.Id; @@ -354,8 +390,8 @@ public partial class BillingCommandPageViewModel : ViewModelBase if (existingQuery.Location is not null) { Address = existingQuery.Location.Address ?? string.Empty; - LatitudeText = existingQuery.Location.Latitude.ToString(CultureInfo.InvariantCulture); - LongitudeText = existingQuery.Location.Longitude.ToString(CultureInfo.InvariantCulture); + LatitudeText = existingQuery.Location.Latitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; + LongitudeText = existingQuery.Location.Longitude?.ToString(CultureInfo.InvariantCulture) ?? string.Empty; } if (IsBrush && existingQuery.PrestationId is not null) @@ -396,4 +432,59 @@ public partial class BillingCommandPageViewModel : ViewModelBase return double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.CurrentCulture, out value) || double.TryParse(text, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); } + + private static object BuildLocationPayload(string address, double? latitude, double? longitude) + { + if (latitude.HasValue && longitude.HasValue) + { + return new + { + Address = address, + Latitude = latitude.Value, + Longitude = longitude.Value, + }; + } + + return new + { + Address = address, + }; + } + + private bool TryParseCoordinates(out double? latitude, out double? longitude, out string error) + { + latitude = null; + longitude = null; + error = string.Empty; + + var latitudeMissing = string.IsNullOrWhiteSpace(LatitudeText); + var longitudeMissing = string.IsNullOrWhiteSpace(LongitudeText); + + if (latitudeMissing && longitudeMissing) + { + return true; + } + + if (latitudeMissing != longitudeMissing) + { + error = "Latitude et longitude doivent être renseignées ensemble, ou laissées vides toutes les deux."; + return false; + } + + if (!TryParseCoordinate(LatitudeText, out var parsedLatitude)) + { + error = "Latitude invalide."; + return false; + } + + if (!TryParseCoordinate(LongitudeText, out var parsedLongitude)) + { + error = "Longitude invalide."; + return false; + } + + latitude = parsedLatitude; + longitude = parsedLongitude; + return true; + } } \ No newline at end of file diff --git a/src/PostIt/PostIt/Views/BillingCommandPage.axaml b/src/PostIt/PostIt/Views/BillingCommandPage.axaml index 2dba7336..ee8cb61d 100644 --- a/src/PostIt/PostIt/Views/BillingCommandPage.axaml +++ b/src/PostIt/PostIt/Views/BillingCommandPage.axaml @@ -5,7 +5,7 @@ x:DataType="vm:BillingCommandPageViewModel" Header="Commande billing"> - + - - +