setup the avatar
This commit is contained in:
parent
b820348558
commit
343538a7ea
14 changed files with 300 additions and 92 deletions
|
|
@ -67,7 +67,7 @@ public class BearerScopeTests
|
|||
Scopes = userScopes,
|
||||
RedirectUri = "postit://callback",
|
||||
},
|
||||
BusinessApiUrl = "https://example.invalid/api/v1/",
|
||||
ApiUrl = "https://example.invalid/api/v1/",
|
||||
};
|
||||
|
||||
var tokensPath = Path.Combine(
|
||||
|
|
@ -266,7 +266,7 @@ public class BearerScopeTests
|
|||
// private HttpClient is independent, so we resolve the
|
||||
// absolute URI ourselves from Settings.BusinessApiUrl —
|
||||
// the same URL BlogApiClient would have set as BaseAddress.
|
||||
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
|
||||
using var req = new HttpRequestMessage(method, absolute);
|
||||
req.Headers.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ public class PostAclDialogTests
|
|||
HttpMethod method, string path, object? body = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
|
||||
var absolute = new Uri(new Uri(Settings.ApiUrl), path);
|
||||
using var req = new HttpRequestMessage(method, absolute);
|
||||
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
|
@ -123,8 +123,8 @@ public class PostAclDialogTests
|
|||
var handler = new CountingHttpHandler();
|
||||
var settings = new Settings();
|
||||
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
|
||||
var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
|
||||
var aclClient = new BlogAclApiClient(api, settings.ApiUrl);
|
||||
var circleClient = new CircleApiClient(api, settings.ApiUrl);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(settings);
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ public class SettingsLoadTests
|
|||
settings.Authentication.RedirectUri =
|
||||
global::AuthenticationSettings.DesktopRedirectUri;
|
||||
|
||||
settings.BusinessApiUrl = flip
|
||||
settings.ApiUrl = flip
|
||||
? "https://a.example.test/api/v1/"
|
||||
: "https://b.example.test/api/v1/";
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public class YavscApiClientTests
|
|||
// calls CallAsync("posts", ...) directly (bypassing
|
||||
// BlogApiClient, which is the only thing that would set
|
||||
// it in production). Mirror prod here.
|
||||
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
||||
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||
|
||||
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
|
||||
HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
|
||||
|
|
@ -118,7 +118,7 @@ public class YavscApiClientTests
|
|||
RedirectUri = "postit://callback",
|
||||
Scopes = new[] { "openid" },
|
||||
},
|
||||
BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
|
||||
ApiUrl = "https://127.0.0.1:5003/api/v1",
|
||||
};
|
||||
var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
|
||||
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
|
||||
|
|
@ -162,7 +162,7 @@ public class YavscApiClientTests
|
|||
RedirectUri = authority.LoopbackRedirectUri,
|
||||
Scopes = new[] { "openid", "profile", "blog" }
|
||||
},
|
||||
BusinessApiUrl = apiBaseUrl
|
||||
ApiUrl = apiBaseUrl
|
||||
};
|
||||
|
||||
private static async Task<YavscApiClient> LoginAndPersistAsync(
|
||||
|
|
@ -175,7 +175,7 @@ public class YavscApiClientTests
|
|||
// directly (bypassing BlogApiClient) rely on the same
|
||||
// BaseAddress the production chain sets in BlogApiClient's
|
||||
// ctor. Mirror that here so "posts" resolves to the stub.
|
||||
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
|
||||
client.Http.BaseAddress = new Uri(settings.ApiUrl);
|
||||
|
||||
// Force the API client to use the test browser by routing the
|
||||
// LoginInteractiveAsync call through a small wrapper.
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ public static class ServiceCollectionHelpers
|
|||
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
|
||||
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
|
||||
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
|
||||
var activityClient = new ActivityApiClient(api, settings.BusinessApiUrl);
|
||||
var billingClient = new BillingApiClient(api, settings.BusinessApiUrl);
|
||||
var activityClient = new ActivityApiClient(api, settings.ApiUrl);
|
||||
var billingClient = new BillingApiClient(api, settings.ApiUrl);
|
||||
var userDirectory = new UserDirectory(userSearchClient);
|
||||
|
||||
// Vues
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
|
@ -351,6 +352,56 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
|
|||
_store.Save(_tokens);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upload a user avatar to the Yavsc API. The server expects a
|
||||
/// single multipart file named <c>file</c> and validates the image
|
||||
/// content type before persisting it.
|
||||
/// </summary>
|
||||
public async Task SetAvatarAsync(
|
||||
Stream imageStream,
|
||||
string fileName,
|
||||
string? contentType = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (imageStream is null)
|
||||
throw new ArgumentNullException(nameof(imageStream));
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
throw new ArgumentException("A file name is required.", nameof(fileName));
|
||||
|
||||
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
|
||||
|
||||
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var attemptUpload = async () =>
|
||||
{
|
||||
if (imageStream.CanSeek)
|
||||
imageStream.Position = 0;
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var fileContent = new StreamContent(imageStream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
|
||||
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
|
||||
content.Add(fileContent, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
|
||||
return await Http.SendAsync(request, ct).ConfigureAwait(false);
|
||||
};
|
||||
|
||||
var response = await attemptUpload().ConfigureAwait(false);
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
response.Dispose();
|
||||
await ForceRefreshAsync(ct).ConfigureAwait(false);
|
||||
response = await attemptUpload().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync()
|
||||
{
|
||||
_store.Clear();
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
|||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Yavsc.Abstract.Workflow;
|
||||
using Yavsc.Api.Client;
|
||||
using Yavsc.Models.Billing;
|
||||
using Yavsc.Models.Haircut;
|
||||
namespace PostIt.ViewModels.Commands;
|
||||
|
||||
|
|
@ -21,7 +22,6 @@ public partial class BrushViewModel : RdvViewModel
|
|||
[ObservableProperty]
|
||||
public partial HairPrestationDto? SelectedPrestation { get; set; }
|
||||
|
||||
|
||||
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
|
||||
: base(activity, performer, form, billingClient)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
|
|||
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string BusinessApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
||||
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/";
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string SearchText { get; set; } = string.Empty;
|
||||
|
|
@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase
|
|||
|
||||
partial void OnDarkModeChanged(bool value) => MarkDirty();
|
||||
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnApiUrlChanged(string value) => MarkDirty();
|
||||
partial void OnSearchTextChanged(string value) => MarkDirty();
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -306,9 +306,9 @@ public partial class Settings : ViewModelBase
|
|||
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
|
||||
? settings.BlogsApiUrl
|
||||
: legacyApiUrl ?? this.BlogsApiUrl;
|
||||
this.BusinessApiUrl = !string.IsNullOrWhiteSpace(settings.BusinessApiUrl)
|
||||
? settings.BusinessApiUrl
|
||||
: this.BusinessApiUrl;
|
||||
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
|
||||
? settings.ApiUrl
|
||||
: this.ApiUrl;
|
||||
this.SearchText = settings.SearchText ?? string.Empty;
|
||||
if (!(settings.Authentication is null))
|
||||
{
|
||||
|
|
@ -384,7 +384,7 @@ public partial class Settings : ViewModelBase
|
|||
};
|
||||
this.DarkMode = false;
|
||||
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
|
||||
this.BusinessApiUrl = "https://api.pschneider.fr/api/v1/";
|
||||
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
|
||||
this.SearchText = string.Empty;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,13 +33,11 @@
|
|||
<TextBlock Grid.Row="5"
|
||||
Text="Motif"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Reason, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<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" />
|
||||
|
|
@ -60,14 +58,12 @@
|
|||
<TextBlock Grid.Row="10"
|
||||
Text="Prestation"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsSinglePrestation}" />
|
||||
Margin="0,0,12,8" />
|
||||
<ComboBox Grid.Row="10"
|
||||
Grid.Column="1"
|
||||
ItemsSource="{Binding AvailablePrestations}"
|
||||
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsSinglePrestation}">
|
||||
Margin="0,0,0,8">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Spacing="2">
|
||||
|
|
@ -78,39 +74,14 @@
|
|||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="11"
|
||||
Text="Prestations"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsMultiplePrestations}" />
|
||||
<ListBox Grid.Row="11"
|
||||
Grid.Column="1"
|
||||
ItemsSource="{Binding MultiPrestations}"
|
||||
IsVisible="{Binding ShowsMultiplePrestations}"
|
||||
MaxHeight="180"
|
||||
Margin="0,0,0,8">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
|
||||
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="12"
|
||||
Text="Informations"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="12"
|
||||
Grid.Column="1"
|
||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||
Content="Je consens à la création de cette commande"
|
||||
|
|
|
|||
|
|
@ -29,13 +29,11 @@
|
|||
<TextBlock Grid.Row="5"
|
||||
Text="Motif"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="5"
|
||||
Grid.Column="1"
|
||||
Text="{Binding Reason, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsReason}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<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" />
|
||||
|
|
@ -57,13 +55,11 @@
|
|||
<TextBlock Grid.Row="12"
|
||||
Text="Informations"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,12,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,12,8" />
|
||||
<TextBox Grid.Row="12"
|
||||
Grid.Column="1"
|
||||
Text="{Binding AdditionalInfo, Mode=TwoWay}"
|
||||
Margin="0,0,0,8"
|
||||
IsVisible="{Binding ShowsAdditionalInfo}" />
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
|
||||
Content="Je consens à la création de cette commande"
|
||||
|
|
|
|||
|
|
@ -39,25 +39,30 @@
|
|||
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
|
||||
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="8" Text="Business API URL"/>
|
||||
<TextBlock Grid.Row="8" Text="API URL"/>
|
||||
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
|
||||
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
|
||||
Text="{Binding ApiUrl, Mode=TwoWay}"/>
|
||||
|
||||
<TextBlock Grid.Row="10" Text="Dark mode"/>
|
||||
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
|
||||
|
||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||
VM. The source generator emits an ICommand property whose
|
||||
name matches the source method exactly (no "Command"
|
||||
suffix is added), so we bind {Binding Save} here. See
|
||||
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
|
||||
de binding pour [RelayCommand]" for the full rationale.
|
||||
IsEnabled tracks IsDirty so the button auto-disables
|
||||
when there's nothing to persist. -->
|
||||
<Button Grid.Row="12" Content="Sauver"
|
||||
HorizontalAlignment="Right"
|
||||
Command="{Binding Save}"
|
||||
IsEnabled="{Binding IsDirty}"/>
|
||||
<StackPanel Grid.Row="12" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Choisir l'avatar"
|
||||
Click="ChooseAvatar_Click"
|
||||
Margin="0,0,8,0"/>
|
||||
|
||||
<!-- Sauver: bound to the Save RelayCommand on the Settings
|
||||
VM. The source generator emits an ICommand property whose
|
||||
name matches the source method exactly (no "Command"
|
||||
suffix is added), so we bind {Binding Save} here. See
|
||||
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
|
||||
de binding pour [RelayCommand]" for the full rationale.
|
||||
IsEnabled tracks IsDirty so the button auto-disables
|
||||
when there's nothing to persist. -->
|
||||
<Button Content="Sauver"
|
||||
Command="{Binding Save}"
|
||||
IsEnabled="{Binding IsDirty}"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
</ContentPage>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PostIt.Services;
|
||||
|
||||
namespace PostIt.Views;
|
||||
public partial class SettingsPage: ContentPage
|
||||
|
|
@ -9,4 +15,56 @@ public partial class SettingsPage: ContentPage
|
|||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
|
||||
private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = "Choisir un avatar",
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter = new[]
|
||||
{
|
||||
new FilePickerFileType("Images")
|
||||
{
|
||||
Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var file = files.FirstOrDefault();
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await file.OpenReadAsync();
|
||||
var api = ((App)App.Current!).ServiceProvider!.GetRequiredService<YavscApiClient>();
|
||||
await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetMimeType(string fileName)
|
||||
{
|
||||
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".webp" => "image/webp",
|
||||
".gif" => "image/gif",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue