Platform.TryGetCurrentLocationAsync
All checks were successful
Dotnet build and test / build (pull_request) Successful in 8m30s

This commit is contained in:
Paul Schneider 2026-09-02 14:41:05 +01:00
commit 914e9486b5
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
11 changed files with 395 additions and 46 deletions

View file

@ -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]

View file

@ -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<string>? _pending;

View file

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

View file

@ -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<CurrentLocationResult> 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<bool>? _pendingRequest;
public static Task<bool> EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken)
{
if (HasLocationPermission(activity))
{
return Task.FromResult(true);
}
lock (SyncRoot)
{
if (_pendingRequest is null)
{
_pendingRequest = new TaskCompletionSource<bool>(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<bool>? 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<bool> WaitAsync(Task<bool> task, CancellationToken cancellationToken)
{
using var registration = cancellationToken.Register(() =>
{
lock (SyncRoot)
{
_pendingRequest?.TrySetCanceled(cancellationToken);
_pendingRequest = null;
}
});
return await task.ConfigureAwait(false);
}
}
}

View file

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

View file

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

View file

@ -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
/// </summary>
public static System.Func<IBrowser?>? CreateBrowser { get; set; } =
() => new CustomSchemeBrowser(CustomScheme);
/// <summary>
/// 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.
/// </summary>
public static Func<CancellationToken, Task<CurrentLocationResult>> TryGetCurrentLocationAsync { get; set; } =
_ => Task.FromResult(CurrentLocationResult.Unavailable());
}

View file

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

View file

@ -5,7 +5,7 @@
x:DataType="vm:BillingCommandPageViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="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" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
@ -40,18 +40,25 @@
<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" />
<TextBlock Grid.Row="7" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="7" Grid.Column="1" Text="{Binding LatitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding LongitudeText, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding LatitudeText, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9"
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding LongitudeText, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="10"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsSinglePrestation}" />
<ComboBox Grid.Row="9"
<ComboBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
@ -67,12 +74,12 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="10"
<TextBlock Grid.Row="11"
Text="Prestations"
VerticalAlignment="Top"
Margin="0,0,12,8"
IsVisible="{Binding ShowsMultiplePrestations}" />
<ListBox Grid.Row="10"
<ListBox Grid.Row="11"
Grid.Column="1"
ItemsSource="{Binding MultiPrestations}"
IsVisible="{Binding ShowsMultiplePrestations}"
@ -90,23 +97,23 @@
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="11"
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<TextBox Grid.Row="11"
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8"
IsVisible="{Binding ShowsAdditionalInfo}" />
<CheckBox Grid.Row="12" Grid.ColumnSpan="2"
<CheckBox Grid.Row="13" 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="13" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"

View file

@ -286,12 +286,22 @@ public sealed class BillingApiClient
return null;
}
return new
var payload = new Dictionary<string, object?>
{
Address = location.Address,
Latitude = location.Latitude,
Longitude = location.Longitude,
["Address"] = location.Address,
};
if (location.Latitude.HasValue)
{
payload["Latitude"] = location.Latitude.Value;
}
if (location.Longitude.HasValue)
{
payload["Longitude"] = location.Longitude.Value;
}
return payload;
}
private sealed class BillingLocationResponse
@ -351,4 +361,4 @@ public sealed class BillingApiClient
{
public long PrestationId { get; set; }
}
}
}

View file

@ -30,6 +30,6 @@ public sealed class BillingQueryDetailsDto
public sealed class BillingLocationDto
{
public string Address { get; set; } = string.Empty;
public double Latitude { get; set; }
public double Longitude { get; set; }
}
public double? Latitude { get; set; }
public double? Longitude { get; set; }
}